Follow-ups architecture
The decision record is ADR-018. This page describes how the pieces fit together. For the product behaviour, see the follow-ups guide; for the endpoints, the Follow-ups API.
Components
| Piece | Where | Responsibility |
|---|---|---|
| Domain logic | packages/followups | Scheduling (due times, send window, past-due), template variables (vars.*, anchor.*), enroll() (re-entry, dedupe, plan limit), stop paths, rule trigger recording and matching, settings and entitlement caches, outbox events |
| Shared send path | packages/campaigns/src/outbound.ts | queueContactMessage: message row, status event, message.queued, usage, inbox threading (also used by the campaign releaser) |
| Worker | apps/followup-worker | Release loops (one step per transaction), AI steps (lease + fence), rule trigger evaluation, bulk enrollment |
| API | apps/api/src/modules/followups | Sequences (draft → versions), enrollments, rules, settings, conversation shortcut, statistics |
| Stop hooks | apps/whatsapp-worker/src/message-events.ts, apps/api (inbox replies, contacts), packages/crm/src/events.ts, apps/scheduler/src/inbox.ts | Reply, human message, opt-out, lead closed, conversation resolved, contact erased — in the transaction of the event |
| Trigger hooks | Contacts service (created, tags), CRM events (lead created / status, deal stage), inbound keywords, conversation resolved | recordTrigger (only when the project has a rule for that trigger) |
| Campaigns | apps/campaign-worker/src/releaser.ts | Enroll each released recipient in the campaign's no-reply sequence (savepoint) |
| AI | packages/ai/src/followup-prompt.ts, AiEngine.followUp | Prompt, validated JSON output, budget ledger kind followup |
| Retention | apps/scheduler/src/followups.ts | Finished enrollments after the project's retentionDays; processed triggers; unused one-off versions; old bulk jobs |
| Dashboard | apps/web/app/app/[orgId]/p/[projectId]/followups, components/followups | Sequences builder and results, enrollments, rules, settings; inbox and contact cards; campaign builder option |
Data
followup_sequences (draft definition, status) ──< followup_sequence_versions (immutable definition; sequence_id null = one-off)
followup_enrollments (contact, version, step_index, next_run_at, anchor_at, variables, stop flags, lease/fence)
└──< followup_step_runs (PK enrollment × step: idempotent release; message_id) messages.followup_enrollment_id
followup_rules (trigger, segment conditions, anchor) ··· followup_triggers (queued events, processed_at)
followup_bulk_jobs (contact list or frozen segment rule, cursor, lease)
followup_instance_pacing (number: next_release_at) followup_settings (project)
- One live enrollment per sequence and contact is a partial unique index (
status in (active, paused));dedupe_keyis unique per project for business-level idempotency. - Versions are immutable: enrollments pin one, so publishing a changed sequence never alters running enrollments.
- Stop flags are copied onto each enrollment, so every stop path is one indexed
UPDATE … RETURNINGon(contact_id) where status in (active, paused). messages.followup_enrollment_idhas no foreign key (retention deletes messages and enrollments independently).
Release (one step, one transaction)
claim oldest active enrollment with next_run_at <= now, lease free, number not inside its pacing gap
FOR UPDATE SKIP LOCKED
contact FOR SHARE SKIP LOCKED (busy → retry in 2 s; the releaser never waits for a lock)
checks sequence paused → pause · plan lost → stop · opted out → stop · replied (backstop) → stop
timing due later (rescheduled) → defer · reminder past grace → skip · onlyIf (no reply / not read) → skip
send send window → defer to opening · consent mode → skip · number (enrollment, fixed, last conversation,
default, any connected) unusable → retry 15 s, pause after 30 min · pacing row SKIP LOCKED · window of
follow-up messages QUEUED/PROCESSING per number · monthly quota / backlog → defer · per-contact 24 h cap
→ defer · media · render (missing vars.*/anchor.* → skip)
AI step first pass: lease (locked_until) + attempts++ → commit → model call without locks → second pass
re-runs every check under the lock, fenced on attempts; low confidence / failure → template or skip
queue queueContactMessage (priority 5, source followup) in the existing inbox thread (SKIP LOCKED) →
followup_sent activity → step run → pacing += sendDelayMs → followup.step_queued → advance
advance next step scheduled from now (window applied to messages) or completed (followup.completed)
after send-nudge, inbox realtime events
Unexpected errors roll the transaction back; the enrollment then backs off exponentially (30 s … 1 h) and is marked
failed after FOLLOWUP_MAX_FAILURES, so a poison row cannot hot-loop.
Stop paths and ordering
The releaser locks the enrollment, then the contact (share, skip locked) and the conversation (skip locked). Stop paths lock the contact (inbound message, opt-out, CRM) or the conversation (inbox reply), then update enrollments. Because the releaser never waits, the two cannot deadlock:
- if the stop commits first, the releaser sees
stopped(or the reply backstop) and does nothing; - if the release commits first, the stop finds
last_message_idstillQUEUEDand fails it withCANCELLED(message.failed), unless the send lane has already claimed it.
The reply always wins until the send lane picks the message up.
Rules
recordTrigger runs in the transaction of the event and inserts a small row only when the project has an enabled
rule for that trigger type (per-process cache, invalidated over followup-settings-invalidate, 30 s TTL). The worker
claims triggers in batches (SKIP LOCKED), matches rules (trigger filter, then segment conditions compiled to SQL),
and enrolls in a savepoint per rule; business rejections (already enrolled, opted out, plan limit) are counted, other
errors retry with backoff and are given up after 5 attempts. Tags added by a follow-up step do not fire rules (no
loops between sequences).
Failure behaviour
| Failure | Effect |
|---|---|
| Worker crash during a release | Transaction rolled back; the enrollment is claimed again |
| Worker crash during an AI call | Lease expires (FOLLOWUP_AI_LEASE_MS); another replica re-runs the step with a new fence; a late draft is dropped |
| Model unavailable / budget exhausted | Template sent (or the step skipped, per step) |
| Number disconnected | Retried every 15 s; after 30 min the enrollment pauses (instance_unavailable) |
| Quota used up | Deferred 30 min at a time |
| Redis down | Nudges lost; loops poll every second and sleep at most 5 s |
| Reply while a follow-up is queued | The follow-up is cancelled if the send lane has not claimed it |
Observability
Metrics: whatstig_followup_steps_total{action,outcome}, whatstig_followup_release_lag_seconds,
whatstig_followup_enrollments_total{source,result}, whatstig_followup_finished_total{status,reason},
whatstig_followup_triggers_total{type,outcome}, and whatstig_ai_requests_total{kind="followup"}. Alerts:
FollowUpReleaseLagging, FollowUpTriggerFailures, FollowUpWorkerDown. The scheduler reports last_job_success
for followup-retention.