Skip to content

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

PieceWhereResponsibility
Domain logicpackages/followupsScheduling (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 pathpackages/campaigns/src/outbound.tsqueueContactMessage: message row, status event, message.queued, usage, inbox threading (also used by the campaign releaser)
Workerapps/followup-workerRelease loops (one step per transaction), AI steps (lease + fence), rule trigger evaluation, bulk enrollment
APIapps/api/src/modules/followupsSequences (draft → versions), enrollments, rules, settings, conversation shortcut, statistics
Stop hooksapps/whatsapp-worker/src/message-events.ts, apps/api (inbox replies, contacts), packages/crm/src/events.ts, apps/scheduler/src/inbox.tsReply, human message, opt-out, lead closed, conversation resolved, contact erased — in the transaction of the event
Trigger hooksContacts service (created, tags), CRM events (lead created / status, deal stage), inbound keywords, conversation resolvedrecordTrigger (only when the project has a rule for that trigger)
Campaignsapps/campaign-worker/src/releaser.tsEnroll each released recipient in the campaign's no-reply sequence (savepoint)
AIpackages/ai/src/followup-prompt.ts, AiEngine.followUpPrompt, validated JSON output, budget ledger kind followup
Retentionapps/scheduler/src/followups.tsFinished enrollments after the project's retentionDays; processed triggers; unused one-off versions; old bulk jobs
Dashboardapps/web/app/app/[orgId]/p/[projectId]/followups, components/followupsSequences 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_key is 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 … RETURNING on (contact_id) where status in (active, paused).
  • messages.followup_enrollment_id has 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_id still QUEUED and fails it with CANCELLED (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

FailureEffect
Worker crash during a releaseTransaction rolled back; the enrollment is claimed again
Worker crash during an AI callLease expires (FOLLOWUP_AI_LEASE_MS); another replica re-runs the step with a new fence; a late draft is dropped
Model unavailable / budget exhaustedTemplate sent (or the step skipped, per step)
Number disconnectedRetried every 15 s; after 30 min the enrollment pauses (instance_unavailable)
Quota used upDeferred 30 min at a time
Redis downNudges lost; loops poll every second and sleep at most 5 s
Reply while a follow-up is queuedThe 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.