Skip to Content
Living documentation — last reviewed 2026-05-28
DecisionsADR-0011: Native automations engine (FIT-157)

ADR-0011: Native automations engine (FIT-157)

Status: Proposed — spike / design only (not yet built) Date: 2026-06-09 · revised 2026-06-11 (added execution-model tiering, frontend, observability & safety; fixed absent enroll-key + retry-vs-ledger semantics, added edit/in-flight concurrency policy) · revised 2026-06-14 (polymorphic subject — leads as first-class v1 subjects: lead triggers, channels, tokens, consent) Context owner: Saar Issue: FIT-157 

This is a spike design record. It is longer than a standard ADR (the README asks for <200 lines) because it carries the decision plus the data model, the trigger/channel model, the authoring UI, and a file-by-file plan in one place. The pure decision is in TL;DR; the rest is the design it implies. No code has been written yet.


TL;DR — the decision

  1. Build Taikan’s own automation model first. The issue leads with “import a gym’s Arbox automations,” but there is nothing to import into yet. The native engine is the prerequisite; the Arbox importer (Phase 1) becomes a mapping layer on top.
  2. An automation is a journey, not a single rule — but v1 ships the rule. The execution unit is a subject enrollment that walks an ordered list of steps (delay · action · branch · exit), carrying a context blob that accumulates as it goes. A one-step journey is a rule, so we model the journey from day one (the expensive-to-change part) and expose only the single-action form in the UI first (deferring the expensive-to-build canvas). See § Rule vs journey vs Temporal.
  3. This is not Temporal / a DAG engine. Steps are declarative data advanced by Postgres (the enrollment cursor) + BullMQ (timers) + a cron backstop — infra we already run. Temporal is a durable-execution runtime for arbitrary code; adopting it to send a birthday message is a category error.
  4. Two trigger families, reusing infra we already have: event-driven via the in-process EventEmitter2 bus (@OnEvent), and time-based via a daily @Cron sweep gated by cronsEnabled(). Both enroll the subject; the engine advances it.
  5. Channels behind a ChannelStrategy interface. push and email are live (PushNotificationsService, EmailService); whatsapp/sms are stubbed until FIT-140 . Swapping the stub for a real adapter is one file.
  6. Idempotency is two database constraints: UNIQUE(automation, subject, enroll_key) (never enroll the same occurrence twice) and UNIQUE(enrollment, step) on the step-run ledger (never send a step twice on a BullMQ retry). The step-run row is a claim (pending → outcome), not a tombstone — so bounded retries of transient provider failures still work. See § Two idempotency layers.
  7. Authoring lives in apps/web at dashboard/automations, following the existing workout-builder (custom step-list state machine) and forms template-editor patterns — no React Flow. See § Frontend.
  8. Bounded blast radius. Automations fan out, so per-member failures are recorded as data (automation_step_runs + warn logs), never per-item Sentry captures (in prod logger.error → Sentry via pinoIntegration). A per-automation circuit breaker auto-pauses a misbehaving rule; BullMQ rate/concurrency limits, a per-org send budget, and a global AUTOMATIONS_ENABLED kill switch protect the API, DB, and wallet. See § Observability & safety.
  9. Pause-to-edit + row locks for in-flight safety. Structural edits require a non-active automation and cancel in-flight enrollments; advance() runs in a transaction holding SELECT … FOR UPDATE on the enrollment row with a status re-check before dispatch. Both locking primitives already exist in the codebase (workout-assignments fork, recurring-charge sweep). See § Editing & in-flight concurrency.
  10. Subjects are members and leads — both in v1. The enrollment subject is polymorphic (subjectType + subjectUserId/subjectLeadId), so lead-funnel automations (“new lead → instant WhatsApp”, “trial reminder”, “ghosted trial → follow-up”) ship in v1 — they matter most to personal trainers and alpha users. Only four things differ by subject kind (channels: no push for leads; token set; locale source — leads.locale exists; convert-exits-the-funnel), all bounded. See § Subjects — members and leads.

Context

The problem (from the issue)

When a gym migrates from Arbox to Taikan, its automation stack breaks on day one — “we miss you” re-engagement, birthday messages, renewal reminders, health-declaration form links, session-milestone celebrations. All of it lives inside Arbox. Taikan has no equivalent, so migration means starting from zero. That is a migration blocker, and solving it is a differentiator.

What actually exists in the code today (verified, not assumed)

The roadmap implies a rich event bus; the code says otherwise. This reshapes the design.

CapabilityReality in mainEvidence
In-process event busEventEmitterModule.forRoot() is global, but exactly one domain event is emitted: membership.activated.apps/api/src/memberships/membership-events.tsFormsService.handleMembershipActivated
payment.*, agent.* “events”observability.emit(...) → PostHog/Sentry telemetry, not the EventEmitter2 bus. Nothing can @OnEvent them.apps/api/src/payments/**, ai/agent/**
Time-based sweepsEstablished pattern: @Cron + if (!cronsEnabled()) return + runWithRetry(...) + windowed query + Promise.allSettled.apps/api/src/notifications/notification-scheduler.service.ts
Push channelLive. PushNotificationsService.notifyUser/scheduleNotifyUser. BullMQ-backed, prefs-gated.apps/api/src/push-notifications/
Email channelLive. EmailService.send({ to, subject, html }) (Resend), synchronous, no prefs gating.apps/api/src/notifications/email.service.ts
WhatsApp / SMSNot built. No provider code or deps. Roadmapped under FIT-140/FIT-157.grep: zero hits outside docs
An automation precursortaskTriggerType enum + taskSource: 'auto' already auto-create tasks on triggers.libs/db/src/lib/schema/tasks.ts
Member signalsusers.birthDate, users.phone, bookings.checkedInAt (attendance), bookings.status ∈ {attended, no_show}, subscriptions.currentPeriodEnd.libs/db/src/lib/schema/{users,scheduling,payments}.ts
Builder UI precedentA rich custom workout-builder (state machine + right-rail + section list) and a forms template-editor. No React Flow / dnd-kit installed.apps/web/src/components/overview/workouts/workout-builder/, .../forms/template-editor.tsx

Takeaway: the engine is greenfield but slots onto patterns the codebase already uses heavily. The one new capability it needs from elsewhere is more domain events on the bus (today only new_member is available) — see the coverage matrix.


Decision

Rule vs journey vs Temporal

The first real fork is what an automation is. There’s a spectrum:

Tier 1 — RuleTier 2 — Journey (chosen model)Tier 3 — Workflow / Temporal
Shapetrigger → delay → actiontrigger → [delay · branch · action]* (linear + simple branch)arbitrary DAG, parallel, sub-workflows, code steps
Execution unitone senda member enrollment walking steps, carrying contextdurable workflow instance running code
”Input/output” / statea var snapshot at sendenrollment.context JSONB accumulates across stepstyped activity I/O, signals, queries
Covers100% of documented Arbox automationsdrip onboarding, escalating win-backops orchestration, sagas, provisioning
Authoring UIa formvertical step list (workout-builder pattern)flow canvas (React Flow)
Build costlowmediumhigh

Where gym automations sit. Every reference automation in the Arbox dataset (absent-8-days, expiry-10-days-before, birthday, Nth-session, missed-class-2h-after) is Tier 1 — one trigger, one wait, one message. But the natural next ask — “new member → welcome → wait 3 days → if no booking, nudge → wait 7 days → if still cold, create a coach task” — is Tier 2: a linear sequence with a branch. That is standard retention tooling (ActiveCampaign / HubSpot / Customer.io / Walla all ship it).

Decision: model Tier 2, ship Tier 1’s UI. A one-step journey is a rule, so the journey schema degrades perfectly and nothing is wasted — the channel/interpolation/ trigger work all lives under an action step. We adopt the steps+enrollments model now (cheap to add, expensive to retrofit onto live data later) and expose only the single-action form in v1 (the canvas is expensive to build and not yet needed).

Why not Temporal / a DAG engine. Temporal is a durable-execution runtime for orchestrating arbitrary code that must survive mid-execution crashes (payment sagas, multi-system provisioning). Our steps are declarative data — “wait 3 days”, “send this template”, “if absent”. We already own the durability it sells: the enrollment cursor lives in Postgres, the timers are BullMQ delayed jobs, and the cron sweep is the backstop. Standing up a Temporal cluster + workers + SDK to send a birthday SMS buys nothing and adds a stateful service to operate. Reach for Temporal-class tooling only if Taikan later moves into code-heavy cross-system orchestration — not member messaging. Arbitrary DAGs (parallel branches, fan-in) are also deferred: linear-plus-branch covers the use cases, and its UI is a step list, not a graph canvas.

On “input/output per automation.” There is data flow, but it is enrollment.context (a per-member JSON blob), not typed function I/O. The trigger payload seeds it; branch steps read it; action steps may write results back (form_sent_at, booked: true). Cross-automation composition (“A’s output feeds B”) is simply an action step that enrolls the member into automation B — composability without a formal type system.

Subjects — members and leads (v1)

The engine enrolls two subject kinds, both in v1: members (users.id) and pre-conversion leads (leads.id). Lead-funnel automations (“new lead → instant WhatsApp”, “trial reminder”, “ghosted trial → follow-up”) are first-class — they matter to personal trainers and alpha users, where the funnel is the product. The subject is polymorphic (subjectType + one of subjectUserId/subjectLeadId), so the rest of the engine (steps, enrollments, ledger, dedup, breaker) is identical for both; only four things differ by subject kind, all bounded:

ConcernMember subjectLead subject
Channelspush · email · whatsapp · smsemail · whatsapp · sms only — no push (a lead has no app account / device token). Save-time validation blocks push on a lead-triggered automation; a push step that somehow reaches a lead records skipped(channel_unavailable_for_lead).
Tokensfull set ({expiry_date}, {session_count}, {plan_name}, {debt_amount}, …)lead subset: {first_name} (parsed from leads.name), {org_name}, {link}, {trial_date}. Member-only tokens validate as unknown for lead automations and are flagged in the builder.
Localeusers has no locale (open question) → org defaultleads.locale exists — resolve from the lead row directly. The funnel side is better off here.
Lifecycleenrollment ends on exit/completiona lead converting (→ converted, emits MEMBERSHIP_ACTIVATED) should exit active lead-funnel enrollments — the win-back/nurture goal is met. (Goal-based exit; see open questions.)

Conversion is the seam between the two worlds: convertLead() already emits MEMBERSHIP_ACTIVATED (source: 'lead_converted'), so a new_member automation fires for a converted lead today — the member side needs no lead awareness. Lead automations own only the pre-conversion funnel.

Domain model

Automation (definition / header) ├── trigger : type + config ── WHEN to enroll ├── audienceFilter : optional predicate ── matching (Phase 2; reserved) └── steps[] : ordered nodes ── the journey delay { wait, send-at-time, quiet hours } action { channel(+fallback), message } ←─ the ADR's channel/interpolation work lives here branch { condition on context → true/false next } exit { end the journey } AutomationEnrollment (one per subject per occurrence; subject = member OR lead) ├── subject : { type: member|lead, userId? , leadId? } ── polymorphic, exactly one id ├── currentStepId : cursor ├── context : JSONB, accumulates across steps ← the "workflow I/O" ├── nextRunAt : when the cursor is due └── status : active | completed | exited | failed | cancelled

Authoring writes one automations row + its automation_steps. Firing enrolls members (automation_enrollments), which the BullMQ worker advances step by step. A Tier-1 rule is just an automation whose steps are [action] (or [delay, action]).

Data model

Seven enums (in libs/db/src/lib/schema/enums.ts) and four tables (new file libs/db/src/lib/schema/automations.ts, which imports users and leads for the polymorphic subject FKs). Trigger names mirror the issue 1:1, plus the lead-funnel set.

// enums.ts export const automationTriggerType = pgEnum('automation_trigger_type', [ 'new_member', // event — membership.activated (exists today) 'membership_renewed', // event — needs subscription.renewed on the bus 'membership_expiring', // cron — subscriptions.currentPeriodEnd - N days 'membership_ended', // cron — subscription ended (N days after, 0 = at end) 'absent', // cron — no bookings.checkedInAt in N days 'session_completed', // event — Nth attended booking (milestone) 'class_missed', // event — booking → no_show 'birthday', // cron — users.birthDate matches today 'drop_in', // event — drop-in plan attendance 'manual', // api — staff-fired broadcast // ── lead-funnel triggers (subjectType='lead'); v1 scope, see § Subjects ── 'new_lead', // event — lead created (needs lead.created on the bus) 'trial_booked', // event — lead status → trial_booked (needs lead.status_changed) 'trial_no_show', // cron — trialDate passed, still trial_booked (not converted) 'lead_cold', // cron — lead stuck in new/contacted for N days (statusChangedAt age) ]); export const automationChannel = pgEnum('automation_channel', ['whatsapp','sms','push','email']); export const automationStatus = pgEnum('automation_status', ['draft','active','paused','archived']); export const automationStepType = pgEnum('automation_step_type', ['delay','action','branch','exit']); export const automationEnrollmentStatus = pgEnum('automation_enrollment_status', ['active','completed','exited','failed','cancelled']); export const automationStepRunStatus = pgEnum('automation_step_run_status', ['pending','executed','sent','skipped','failed']); // 'pending' = claimed, dispatch outcome not yet recorded export const automationSubjectType = pgEnum('automation_subject_type', ['member','lead']); // the enrollment subject is polymorphic: a member (users.id) or a pre-conversion lead // (leads.id). v1 ships member triggers only; the lead columns exist so lead-funnel // triggers (new_lead / trial_booked / lead_cold) are an additive change, not a migration.
// automations.ts — all org-scoped (ADR-0004), header soft-deleted export const automations = pgTable('automations', { id: uuid('id').defaultRandom().primaryKey(), organizationId: uuid('organization_id').notNull().references(() => organizations.id), name: varchar('name', { length: 200 }).notNull(), description: text('description'), status: automationStatus('status').notNull().default('draft'), // WHEN to enroll triggerType: automationTriggerType('trigger_type').notNull(), triggerConfig: jsonb('trigger_config').notNull().default({}), // membership_expiring → { daysBefore: 10 } · absent → { days: 8 } // membership_ended → { daysAfter: 0 } · session_completed → { milestones: [100,200,…] } audienceFilter: jsonb('audience_filter'), // Phase 2 — engine ignores for now timezone: varchar('timezone', { length: 64 }), // journey-wide; default org tz importedFrom: importSource('imported_from'), // reuse existing 'arbox' | 'csv' enum externalRef: varchar('external_ref', { length: 200 }), pausedReason: text('paused_reason'), // why auto-paused (circuit breaker) pausedAt: timestamp('paused_at', { withTimezone: true }), createdById: uuid('created_by_id').notNull().references(() => users.id), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), deletedAt: timestamp('deleted_at', { withTimezone: true }), }, (t) => [ index('automations_org_trigger_status_idx').on(t.organizationId, t.triggerType, t.status), index('automations_trigger_status_idx').on(t.triggerType, t.status), // cross-org cron sweeps ]); export const automationSteps = pgTable('automation_steps', { id: uuid('id').defaultRandom().primaryKey(), automationId: uuid('automation_id').notNull().references(() => automations.id), organizationId: uuid('organization_id').notNull().references(() => organizations.id), type: automationStepType('type').notNull(), config: jsonb('config').notNull().default({}), // delay → { delayMinutes, sendAtTime?, windowStart?, windowEnd?, // untilContextField?, offsetMinutes? } ← wait until a context timestamp ± offset // (e.g. trial reminder: untilContextField='trial_date', offsetMinutes=-60) // action → { channel, fallbackChannel?, messageBody, subjectLine? } // branch → { condition: { field, op, value } } orderIndex: integer('order_index').notNull().default(0), // trunk ordering // Successor pointers carry NO .references() on purpose: a step list inserts as one // batch and pointers are wired after; a self-FK would force two-pass ordering for // zero gain. Graph integrity (pointers resolve, no cycles) is validated at save time. nextStepId: uuid('next_step_id'), // self-ref: linear successor branchTrueStepId: uuid('branch_true_step_id'), // branch only branchFalseStepId: uuid('branch_false_step_id'), // branch only createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (t) => [index('automation_steps_automation_idx').on(t.automationId)]); export const automationEnrollments = pgTable('automation_enrollments', { id: uuid('id').defaultRandom().primaryKey(), automationId: uuid('automation_id').notNull().references(() => automations.id), organizationId: uuid('organization_id').notNull().references(() => organizations.id), // Polymorphic subject: a member (users.id) OR a pre-conversion lead (leads.id). // Exactly one of the two id columns is set, matching subjectType (CHECK below). subjectType: automationSubjectType('subject_type').notNull().default('member'), subjectUserId: uuid('subject_user_id').references(() => users.id), // when subjectType='member' subjectLeadId: uuid('subject_lead_id').references(() => leads.id), // when subjectType='lead' enrollKey: varchar('enroll_key', { length: 200 }).notNull(), // dedup discriminator (was trigger_key) status: automationEnrollmentStatus('status').notNull().default('active'), currentStepId: uuid('current_step_id'), // null when finished context: jsonb('context').notNull().default({}), // accumulates across steps — the "I/O" nextRunAt: timestamp('next_run_at', { withTimezone: true }), enrolledAt: timestamp('enrolled_at', { withTimezone: true }).defaultNow().notNull(), completedAt: timestamp('completed_at', { withTimezone: true }), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (t) => [ // Dedup spans the *logical* subject id (whichever column is set) so a member and a lead // can't collide and NULLs never weaken the constraint. The CHECK keeps the pair honest. uniqueIndex('automation_enrollments_dedup_idx') .on(t.automationId, t.subjectType, sql`coalesce(${t.subjectUserId}, ${t.subjectLeadId})`, t.enrollKey), check('automation_enrollments_subject_chk', sql` (subject_type = 'member' AND subject_user_id IS NOT NULL AND subject_lead_id IS NULL) OR (subject_type = 'lead' AND subject_lead_id IS NOT NULL AND subject_user_id IS NULL)`), index('automation_enrollments_due_idx').on(t.status, t.nextRunAt), // worker pickup / backstop index('automation_enrollments_subject_idx') .on(t.organizationId, t.subjectType, sql`coalesce(${t.subjectUserId}, ${t.subjectLeadId})`), ]); export const automationStepRuns = pgTable('automation_step_runs', { id: uuid('id').defaultRandom().primaryKey(), enrollmentId: uuid('enrollment_id').notNull().references(() => automationEnrollments.id), stepId: uuid('step_id').notNull().references(() => automationSteps.id), organizationId: uuid('organization_id').notNull().references(() => organizations.id), status: automationStepRunStatus('status').notNull(), channel: automationChannel('channel'), // action steps only sentAt: timestamp('sent_at', { withTimezone: true }), error: text('error'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }, (t) => [ uniqueIndex('automation_step_runs_once_idx').on(t.enrollmentId, t.stepId), // send-once guard ]);

Two idempotency layers (the linchpins):

GuardConstraintPrevents
Enrollment dedupUNIQUE(automation_id, subject_type, coalesce(subject_user_id, subject_lead_id), enroll_key)enrolling the same occurrence (member or lead) twice
Step-send dedupUNIQUE(enrollment_id, step_id)a BullMQ retry double-sending a step

enroll_key encodes “this occurrence” and must be stable for the occurrence’s lifetime so re-running a sweep is a no-op: birthday:<year>, expiring:<subscriptionId>:<periodEnd>, milestone:<n>, new_member:<membershipId>, absent:<lastAttendanceISODate> (fallback absent:never:<membershipId> for members with no check-in yet), manual:<batchId>. Lead-funnel keys follow the same “key on the subject’s state” rule: new_lead:<leadId>, trial_booked:<leadId>:<trialDate> (re-fires if the trial is rebooked), trial_no_show:<leadId>:<trialDate>, lead_cold:<leadId>:<statusChangedAt> (one nudge per cold episode; resets when the lead moves). Producers INSERT … ON CONFLICT DO NOTHING.

Why absent keys on last attendance, not the sweep window. Keying on the window start (absent:<today-8d>) shifts every day, so a still-absent member would re-enroll — and be messaged — on every daily sweep. The last-attendance date is constant for the whole absence episode and naturally resets to a new key only when the member actually returns. Same rule for any future sweep trigger: derive the key from the member’s state, never from the sweep’s clock.

The step-run row is a claim, not a tombstone. advance() inserts it as pending before dispatching and records the outcome after. On conflict (a BullMQ retry or a backstop re-enqueue) the worker reads the existing row: sent/skipped/executed ⇒ already done, move the cursor on; pending (crashed mid-dispatch) or failed with a retryable error class ⇒ dispatch again and update. Without this, the unique index would silently defeat the retry policy — a provider 503 would conflict on retry and the message would never send. Residual window: a crash after the provider accepted but before we recorded sent can double-send on redelivery; that’s irreducible without provider-side idempotency keys, and push/email tolerate it.

The enrollment dedup index is deliberately not partial: a cancelled enrollment is terminal for that occurrence — an operator (or a structural edit, see below) cancelling means that member is not re-enrolled for the same occurrence. New occurrences get new keys.

Engine / execution

PATH A — event-driven PATH B — time-based ───────────────────── ──────────────────── domain event on EventEmitter2 @Cron daily, cronsEnabled() gate, runWithRetry MEMBERSHIP_ACTIVATED · booking.* · members: birthday / expiring / ended / absent lead.created · lead.status_changed leads: trial_no_show / lead_cold │ │ ▼ ▼ AutomationEventsListener ───────┐ ┌─── AutomationSchedulerService (@OnEvent → trigger match) │ │ (query members OR leads matching the condition) ▼ ▼ AutomationEngineService.enroll(automation, subject, enrollKey, seedContext) │ subject = { type:'member', userId } | { type:'lead', leadId } │ INSERT automation_enrollments (currentStep = entry) ON CONFLICT DO NOTHING │ compute nextRunAt; enqueue BullMQ { enrollmentId } with delay BullMQ queue 'automations.advance' AutomationProcessor.advance(enrollmentId) ── a small state machine ── BEGIN tx; SELECT enrollment FOR UPDATE (serializes vs pause/edit/backstop) if enrollment.status != active OR automation.status != active → skip (idempotent) loop from currentStep: • delay → set nextRunAt = now + wait (snap to send-at-time / quiet hours); move cursor to next; enqueue delayed job; RETURN ── the only thing that waits • action → claim: INSERT step_run status='pending' ON CONFLICT DO NOTHING; on conflict read row: terminal → move on · pending / retryable-failed → re-dispatch dispatch via ChannelStrategy → record sent/skipped/failed; move cursor; continue • branch → evaluate condition on context; move cursor to true/false; continue • exit / no next → status = completed; RETURN AutomationDispatcherService → push / email (live) · whatsapp / sms (stub)
  • The worker advances through consecutive non-waiting steps in-process (action → branch → action) and only re-enqueues when it hits a delay or finishes — no tight enqueue loop.
  • Manual trigger: POST /automations/:id/run resolves the audience and enrolls each subject (member segment, or a lead segment).
  • Cron backstop: a sweep re-enqueues status='active' AND next_run_at <= now enrollments that BullMQ may have dropped (durability beyond the queue), selecting with FOR UPDATE SKIP LOCKED so the backstop and a late BullMQ delivery never double-process (the pattern in recurring-charge.service.ts:62-77). Matches ADR-0009 discipline.

Editing & in-flight concurrency

Owners will edit automations while members are parked mid-journey (currentStepId pointing into automation_steps). The policy and its race control:

  • Pause-to-edit (structural). Changing the step graph (add/remove/reorder steps, branch targets, trigger type) requires status != 'active' — the API rejects with 409, the UI offers “pause & edit”. Content-only edits (message body, subject, channel, fallback) apply in place without pausing — in-flight members simply get the new copy.

  • A structural save cancels in-flight enrollments (status='cancelled', reason recorded). Predictable over clever: nobody resumes into a graph their cursor never saw. Per the dedup rule above, cancellation is terminal for that occurrence; new occurrences enroll into the new graph. (A “migrate in-flight members” option implies definition versioning — deferred, see open questions.)

  • Steps are never hard-deleted once referenced — step_runs.step_id FK guarantees it. A structural save rewrites the active step list in one transaction; replaced steps stay as orphaned rows so the ledger keeps its history.

  • Race control reuses locking primitives already in the codebase — nothing new:

    RaceControlExisting precedent
    worker advances while owner pauses/editsadvance() = one tx, SELECT … FOR UPDATE on the enrollment row, re-check automations.status='active' before dispatch — the losing side sees the flip and parksworkout-assignments.service.ts:819-834 (tx + .for('update'); note: Drizzle’s relational findFirst can’t lock — use the query-builder)
    backstop sweep races a late BullMQ deliveryFOR UPDATE SKIP LOCKED on due-enrollment pickuprecurring-charge.service.ts:62-77
    both still collide somehowthe UNIQUE(enrollment, step) step-run claim — at most one dispatchesthis ADR’s ledger

    (pg_advisory_xact_lock also exists — platform-billing.service.ts:156 — if a named cross-row lock is ever needed; the per-enrollment row lock makes it unnecessary here.)

Channels

// The dispatcher resolves the subject (member or lead) to a Recipient before dispatch, // so strategies never touch the DB for identity. push needs userId; email needs email; // sms/whatsapp need phone — leads carry email/phone but no userId, hence supportsLeads. type Recipient = | { subjectType: 'member'; userId: string; email?: string; phone?: string } | { subjectType: 'lead'; leadId: string; email?: string; phone?: string }; interface ChannelStrategy { channel: AutomationChannel; supportsLeads: boolean; // push=false (no device token); email/sms/whatsapp=true isConfigured(orgId: string): Promise<boolean>; send(p: { to: Recipient; subject?: string; body: string; automationName: string }): Promise<DispatchResult>; }
ChannelStrategyBackingState
pushPushChannelStrategyPushNotificationsService.notifyUser (prefs-gated, queued) — supportsLeads=falselive, members only
emailEmailChannelStrategyEmailService.send (Resend) — wrap body in a template; to.email works for members and leadslive
whatsappWhatsAppChannelStrategystub: isConfigured()=false; records intent, step-run = skippedstub → FIT-140
smsSmsChannelStrategystub, same shapestub → FIT-140

The dispatcher resolves the subject to a Recipient (member → user email/phone; lead → leads.email/leads.phone), then tries config.channel; if !isConfigured, the channel doesn’t supportsLeads for a lead subject, or send fails, and a fallbackChannel is set, it tries the fallback (covers “WhatsApp → SMS”, “gym hasn’t set up WhatsApp yet”, and “push chosen but subject is a lead” — the step-run lands skipped, visible, not lost). Swapping a stub for the real FIT-140 adapter is one file behind this interface.

Save-time guard: an automation whose trigger is lead-funnel (subjectType resolves to lead) can’t select push as channel or fallback — the editor’s channel picker hides push for lead triggers, and the API rejects it. This turns an un-deliverable config into a validation error rather than a runtime skipped.

Variable interpolation & context

interpolate(template, context) over Taikan syntax: {first_name}, {last_name}, {expiry_date}, {session_count}, {debt_amount}, {link}, {org_name}, {plan_name}. The context is the enrollment’s accumulating blob — seeded by the trigger, read by branch steps, optionally written by action steps. Unknown tokens are left intact (so they surface in preview); a companion validateTemplate(template, subjectType) returns the unknown-token list for the builder UI. Token availability is subject-scoped: lead automations expose only {first_name} (parsed from leads.name), {org_name}, {link}, {trial_date} — member-only tokens ({expiry_date}, {session_count}, {plan_name}, {debt_amount}) validate as unknown for a lead subject and are flagged before activation. Locale for interpolation comes from leads.locale (lead) or org default (member — users has no locale column). Arbox’s Hebrew variables ({שם פרטי}, {שם משפחה}) are rewritten to Taikan tokens at import time — the util itself speaks only Taikan syntax.

Trigger coverage matrix

What each trigger needs to go live. The engine ships whole; triggers light up as their signal is wired.

TriggerFamilySignal today?To light it up
new_membereventmembership.activatedlistener only — works on day one
birthdaycronusers.birthDatesweep
membership_expiringcronsubscriptions.currentPeriodEndsweep
membership_endedcron✅ expired-subs cron existssweep
absentcronbookings.checkedInAtsweep + “last attendance” query
class_missedeventbooking.no_show emitted from the auto-no-show cronwired
session_completedeventbooking.attended + per-automation attended-count milestone matchwired
membership_renewedeventsubscription.renewed emitted from handleChargeSuccesswired
drop_ineventbooking.attended + drop-in plan check in the listenerwired
manualapi✅ n/acontroller endpoint
new_leadevent◑ lead created, but auto-creates a task directly — not on the busemit lead.created (co-locate with the new_lead task at organization-leads.service.ts:532)
trial_bookedevent◑ status→trial_booked writes lead_status_events, not the busemit lead.status_changed on the status transition
trial_no_showcronorganizationLeads.trialDate + leads.statussweep: trialDate < now AND status='trial_booked'
lead_coldcronleads.status + statusChangedAtsweep: status ∈ {new,contacted} AND statusChangedAt < now - N days

New bus events follow the existing membership-events.ts recipe (constant + typed payload by the producer, @OnEvent consumer). None block shipping the engine. The two lead event triggers (new_lead, trial_booked) need their emits wired — same one-line recipe; the two lead cron triggers work off existing columns immediately. Note Taikan already has a parallel lead-trigger vocabulary in taskTriggerType (new_lead, trial_booked) that auto-creates staff tasks; see the task-overlap note in Consequences.

Frontend / authoring (apps/web)

The backend is only half the feature — owners have to create, time, connect, construct, and operate automations. Grounded in the actual web stack: Next.js 16, i18n [lang] routes under (protected)/dashboard, shadcn/ui, the custom workout-builder pattern (state machine + right-rail + section list — not React Flow, which isn’t installed), and the forms template-editor.

VerbSurfaceReuses / mirrors
Managedashboard/automations — list (name · trigger · channel · status · enrolled count · sent-30d), status toggle, per-row enrollment history drawertasks list patterns
Createdashboard/automations/[id] — start from a trigger (member or lead) or a template gallery (“Welcome series”, “Win-back”, “Birthday”, “New-lead instant reply”, “Trial reminder”, “Ghosted-trial follow-up”)
Construct (message)rich-text composer + variable-insert menu scoped to the trigger’s subject (member tokens vs lead tokens) + channel picker (push hidden for lead triggers) + fallback + live preview (push card / email / SMS bubble); validateTemplate() flags unknown varsforms template-editor.tsx
Timetiming panel: delay (n hours/days), send-at-time, wait-until a date (trial reminder), quiet-hours window, timezonemaps to delay-step config
Connect (steps)v2: vertical stack of step cards with ”+” between them; branch = indented split. v1 hides this and shows the single action.workout-builder/ (section-editor, builder-right-rail, builder-action-bar, use-builder-state)
Operate / trust”Preview as member”, “Send test to me”, “N members enrolled / waiting at step Y”
  • v1 UI compiles to steps under the hood: the single-action form writes [action] (or [delay, action] when a wait is set). The engine only ever sees steps, so the v2 builder is purely additive — no backend change.
  • i18n: all chrome strings via en/he/ru dicts (owner-authored message bodies are data, not dict keys). Tier-gated (pro/elite) — web equivalent of PlatformTierGuard.
  • Testing: driver + data-testid + int specs, mirroring use-builder-state.int.spec.tsx.
  • When to adopt React Flow: only if Taikan ever needs arbitrary DAGs (parallel branches, fan-in). Linear-plus-branch is a step list and must not pull in @xyflow/react.

Observability & safety (blast-radius control)

Automations fan out: one sweep or event enrolls many members, so a single fault repeats per-member × per-retry × per-org. Treating each repeat as an exception is how a cron drains the Sentry quota and a worker pool starves the API. Two rules and a set of governors keep an automation’s blast radius bounded.

Why the quota burned (mechanism, grounded). In prod, apps/api/src/instrument.ts wires Sentry.pinoIntegration({ error: { levels: ['error','fatal'] } }) — so every logger.error becomes a Sentry event — and beforeSend only filters 401/403, with no rate limit on error events. A fan-out loop that logger.errors per failed item therefore emits N Sentry events and N error logs. The fix is discipline at the source plus a global ceiling.

Rule 1 — per-member outcomes are data, not exceptions. Every dispatch writes an automation_step_runs row (sent | skipped | failed + error). Per-member failures log at warn (not captured) and are counted — never captureException. Sweeps and batch dispatch follow the existing safe pattern in notification-scheduler.service.ts:54-93: Promise.allSettled → tally sent/failedone summary log per run. The ledger is the audit trail; Sentry is only for surprises.

Rule 2 — capture engine faults once, with counts. Only an unexpected engine error (a throw inside advance(), not a provider 4xx/timeout) may reach Sentry, and only when the circuit breaker trips — one captureException with a stable fingerprint (automation:<id>:<errClass>) and aggregate counts, like the single disciplined capture in platform-billing-recurring.service.ts:282-294 (but aggregated, since volume here is far higher and each member-failure is not its own incident).

Error taxonomy → retry + capture policy. Distinguishing terminal from retryable is what kills retry amplification — a bad template must fail once and stop, not retry 3× for every member:

ClassExamplesRetry?LoggedSentry
terminalbad template, member has no email/phone, opted out, channel not configured, audience no longer matchesnowarnno
retryableprovider 5xx / 429 / timeoutBullMQ bounded (attempts: 3, backoff)warnno — counts toward breaker
engine bugunexpected throw in advance()no (fail the enrollment)erroronce, on breaker trip

Circuit breaker / auto-pause (the key control). A per-automation rolling failure counter in Redis (already in the stack, ADR-0009). When failures cross a threshold in a window (e.g. ≥20 failures, or >50% of ≥20 attempts, in 10 min), the engine flips automations.status → 'paused', writes pausedReason/pausedAt, emits one alert, and stops enrolling/advancing it. One broken automation pauses itself instead of storming every org. Reset on operator re-activation.

Rate & concurrency governors (protect the API, the DB, and the wallet).

  • BullMQ queue limiter (max jobs / interval) + capped worker concurrency — automations can’t monopolize the worker.
  • Per-org fairness — a cap on in-flight jobs per org, so one tenant’s broadcast can’t starve others (the “overloaded the API for no reason” case).
  • Schedule jitter — spread an enrolled cohort across the send window instead of firing all at nextRunAt = now; avoids a thundering herd on the channel and on the database.
  • Per-org daily send budget (configurable) + channel rate limits, especially SMS/WhatsApp which cost real money — a runaway automation hits a budget wall, not a five-figure bill.
  • Worker isolation — the engine is deployable as a separate BullMQ worker process (same code, worker-only mode); automation load then cannot degrade HTTP latency for live users.

Loop & fan-out guards.

  • Save-time graph validation — v1 allows only forward branches (no cycles); reject a step graph that loops.
  • Runtime ceilingmaxStepsPerEnrollment (e.g. 50) + a max enrollment lifetime; an enrollment that exceeds it is force-failed, so even a validation miss can’t loop forever.
  • Fan-out confirmation — a manual broadcast or sweep that would enroll more than a threshold surfaces “this will message N members” / batches, rather than blasting silently.

Global kill switches. AUTOMATIONS_ENABLED (mirror common/crons-enabled.ts) stops the whole subsystem without a deploy; per-automation pause stops one. Both are operator levers for the 2am incident.

Harden the global backstop. Extend instrument.ts beforeSend with fingerprint-based error-event rate limiting (drop duplicates beyond N/min) so no future mistake — automation or otherwise — can drain the quota again. A ceiling, independent of engine discipline.

Where you watch it.

SurfaceSourceAudience
automation_step_runs queriesthe ledger (source of truth)API/UI — per-automation sent/skipped/failed, last error
Enrollment-history drawer + stats stripledger + enrollmentsgym owner — trust/debug; “auto-paused: reason” banner
bull-board (already mounted, ADR-0009)BullMQoperator — queue depth, failed/stuck jobs
PostHog aggregated counters via observability.emitengineproduct — enrolled/sent/failed/auto_paused, tagged org+automation+channel (bounded cardinality)
Admin health view (apps/admin)ledger + queueplatform — auto-paused list, top failers, daily volume/cost
One alert on breaker tripengineon-call — never per-failure

Consequences

Positive

  • Reuses everything — bus, cron+cronsEnabled, BullMQ, push/email, soft-delete, org-scoping, zod-DTO + builder/template-editor UI patterns. Mostly composition.
  • Future-proof without speculative UI — the journey schema absorbs drip/branch sequences with no later data migration, while v1 ships the simple form.
  • Idempotency is structural (two DB unique indexes), per ADR-0009’s “every job idempotent.”
  • Honest about WhatsApp — the ChannelStrategy seam lets us ship/demo on push/email now and drop in FIT-140 later untouched.
  • Operable — enrollment history + “waiting at step Y” + test-send give owners trust; the original draft had no such surface.
  • The Arbox importer becomes a clean mapper into a model built for it, with a draft review state already in the lifecycle.
  • Lead funnel covered in v1 — the polymorphic subject lets the same engine drive top-of-funnel automations (instant new-lead reply, trial reminders, ghosted-trial follow-up) that personal trainers and alpha users lead with, reusing the existing leads pipeline (leadStatus, statusChangedAt, trialDate, leads.locale) with no parallel system.

Negative / costs

  • More schema than a flat model — four tables + seven enums, a polymorphic subject with a CHECK + coalesce-based dedup index, and the engine is a state machine, not a one-shot send. Justified by avoiding a live-data migration later (subject polymorphism especially — retrofitting leads onto a users-only FK is exactly the pain the DB policy warns about), but it is more to build and test up front.
  • Migration discipline — per Database Policy: db:generate → review SQL → explicit approvaldb:migrate; mind the strict-monotonic _journal.json when.
  • Trigger staging — most triggers need a one-time bus-event emit first. Shipping the engine ≠ all ten triggers live (new_member + cron triggers work immediately).
  • WhatsApp stubbed until FIT-140 — the channel gyms care about most; demos run on push/email.
  • Timezone/quiet-hours mathsend_at_time / windows imply DST-aware local-time logic; keep v1 simple (org single tz) and note the edge.
  • Overlaps the task-trigger system — two trigger vocabularies (taskTriggerType vs automationTriggerType), and the overlap is now literal on the lead side: new_lead and trial_booked exist in both. Today taskTriggerType auto-creates a staff task on a new lead (organization-leads.service.ts:532); the automation engine will fire a member-facing message on the same signal. Both can coexist (internal task vs external message), but the lead lead.created/lead.status_changed emits should feed both, and converging “create task” into an action step is the long-term cleanup. Accepted for v1.
  • A real control surface to build and tune — circuit-breaker thresholds, per-org budgets, rate limits, the kill switch, and the beforeSend ceiling are not optional polish; they are load-bearing given the fan-out, and their thresholds need real-world tuning.

Alternatives considered

  1. Flat single-send model (this ADR’s first draft: one automation_runs row = one send). Rejected: cannot represent “member parked at step 2 of 4, waiting.” Journeys are table-stakes retention tooling, and retrofitting enrollment state onto flat rows means migrating live data — the exact pain the DB policy warns about. The step+enrollment model degrades to this for free.
  2. Full workflow engine / Temporal / arbitrary DAG. Rejected: Temporal is a durable-execution runtime for arbitrary code; our steps are declarative data and we already own the durability (Postgres cursor + BullMQ timers + cron backstop). It adds a stateful service to operate for zero benefit on member messaging. Arbitrary DAGs also imply a graph-canvas UI we don’t need. Revisit only for code-heavy cross-system orchestration.
  3. Extend the task-trigger system instead of new tables. Rejected: tasks model internal staff to-dos, not member-facing timed multi-channel journeys with dedup. Better long-term: “create task” becomes one action step type.
  4. Per-event hardcoded hooks (the forms @OnEvent auto-issue style). Rejected: doesn’t scale to owner-configurable rules; every automation would be a code change + deploy.
  5. Embed a third-party canvas (n8n / Zapier-style). Rejected for v1: heavy, another runtime, weak multi-tenant story, fitness-specific vocabulary. Revisit only if owners demand arbitrary branching workflows.
  6. Send directly from cron/listeners, no engine (today’s class reminders). Fine as plumbing; not the configurable, auditable, multi-channel product.
  7. Arbox-import-first (the issue’s framing). Rejected as sequencing — the importer needs a target model to map into; build it deliberately, then map.

Implementation plan (file-by-file)

Phased so each step is reviewable and the riskiest unknowns die first. Nothing here is built yet.

Phase 0 — schema + shared contracts

  • libs/db/src/lib/schema/enums.ts — add the seven enums (incl. automationSubjectType and the four lead triggers).
  • libs/db/src/lib/schema/automations.tsautomations, automationSteps, automationEnrollments (polymorphic subject: subjectType + subjectUserId/subjectLeadId, the CHECK, the coalesce(...) dedup unique index; imports users and leads), automationStepRuns + relations + $inferSelect/$inferInsert.
  • libs/db/src/lib/schema/index.ts — re-export.
  • libs/shared/src/lib/schemas/automation.schema.ts*_VALUES tuples + z.enums, automationResponseSchema (with nested steps), createAutomationSchema, updateAutomationSchema, listAutomationsQuerySchema (mirror task.schema.ts).
  • libs/shared/src/index.ts — re-export.
  • pnpm db:generatereview SQL → request approval → pnpm db:migrate (mind _journal.json when).

Phase 1 — engine core (channel-agnostic)

  • apps/api/src/automations/automations.module.tsBullModule.registerQueue({ name: AUTOMATIONS_ADVANCE_QUEUE }); import Push + Notifications modules.
  • apps/api/src/automations/automation-engine.service.ts — subject-aware enroll(automation, subject, enrollKey, seedContext) for member or lead (dedup insert + first nextRunAt + enqueue) and advance(...) step state-machine — one tx, .for('update') on the enrollment row, automation-status re-check, pending-claim conflict handling (see § Editing & in-flight concurrency).
  • apps/api/src/automations/automation.interpolation.ts (+ *.unit.spec.ts w/ driver) — interpolate + validateTemplate.
  • apps/api/src/automations/automation.processor.ts — BullMQ worker → engine.advance; status guard.
  • apps/api/src/automations/automations.service.ts — org-scoped CRUD (automation + steps), status transitions, pause-to-edit enforcement (409 on structural edit while active) + cancel-in-flight on structural save.
  • apps/api/src/automations/automations.controller.ts — CRUD + POST /:id/run (manual) + POST /preview. @CurrentUser, tier-gated.
  • Wire AutomationsModule into apps/api/src/app/app.module.ts.

Phase 2 — channels

  • …/channels/channel.strategy.ts (interface + Recipient + DispatchResult + supportsLeads), …/channels/{push,email}.channel.ts (live; push supportsLeads=false), …/channels/{whatsapp,sms}.channel.ts (stubs).
  • …/automation-dispatcher.service.ts — resolve subject → Recipient (member: user email/phone; lead: leads.email/leads.phone), select strategy, supportsLeads/fallback handling, write automation_step_runs outcome.
  • Save-time validation in automations.service.ts: lead-triggered automation can’t pick push as channel or fallback.
  • (Optional) new push NotificationCategory: 'automation' + matching pref key.

Phase 2.5 — guardrails & observability (not optional)

  • apps/api/src/common/automations-enabled.ts — global kill switch (mirror crons-enabled.ts).
  • …/automation-circuit-breaker.service.ts — Redis rolling failure counters → auto-pause (status='paused' + pausedReason/pausedAt) + single alert.
  • Error-class taxonomy + retry policy in the dispatcher; Promise.allSettled → one-summary-log discipline in sweeps (no per-item error logs / captures).
  • BullMQ limiter + worker concurrency + per-org in-flight cap; enqueue jitter; per-org daily send budget; channel rate limits.
  • Step-graph cycle validation at save time + maxStepsPerEnrollment runtime ceiling.
  • Extend apps/api/src/instrument.ts beforeSend with fingerprint error-event rate limiting.
  • Aggregated PostHog metrics via the existing observability.emit; admin health view in apps/admin (can trail).

Phase 3 — trigger sources (members + leads, both v1)

  • …/automation-events.listener.ts@OnEvent(MEMBERSHIP_ACTIVATED) → enroll for new_member. End-to-end proof: new member → welcome message.
  • Lead events: emit lead.created (co-locate with the new_lead task at organization-leads.service.ts:532) and lead.status_changed (on the status transition that already writes lead_status_events), both via the membership-events.ts recipe; listeners enroll for new_lead / trial_booked. On → converted, exit active lead-funnel enrollments.
  • …/automation-scheduler.service.ts@Cron daily sweeps for birthday / membership_expiring / membership_ended / absent and the lead sweeps trial_no_show / lead_cold, gated by cronsEnabled(), wrapped in runWithRetry; plus the overdue-enrollment backstop.
  • (Later issues) emit booking.attended / booking.no_show / subscription.renewed + listeners.

Phase 4 — frontend (apps/web)

  • app/[lang]/(protected)/dashboard/automations/page.tsx — list (mirror tasks list).
  • .../automations/[id]/page.tsx + components/overview/automations/v1 single-action editor (trigger picker incl. lead triggers, config, timing panel with wait-until, message composer reusing the template-editor pattern with a subject-scoped token menu, channel + fallback (push hidden for lead triggers), live preview, activate). Compiles to [action] / [delay, action].
  • Lead templates in the gallery (new-lead instant reply, trial reminder, ghosted-trial follow-up).
  • i18n keys in en.json / he.json / ru.json; driver + data-testid + int spec.
  • (v2, later) the vertical step-list builder (delay/branch/exit) following workout-builder.

Phase 5 — tests

  • Unit: interpolation (member + lead token scopes), nextRunAt/window/wait-until math, branch evaluation, both dedup guards (incl. lead enroll-keys), pending-claim conflict handling (terminal skips, retryable re-dispatches), absent + lead_cold enroll-key stability across consecutive sweeps, save-time push-blocked-for-lead validation.
  • Integration: new_member event → enrollment → dispatch; new_lead event → lead enrollment → email/whatsapp dispatch (never push); lead converts → active lead-funnel enrollments exit; sweep idempotent on re-run; a 2-step [delay, action] advances correctly; structural edit while active → 409; pause + structural save cancels in-flight enrollments; advance-vs-pause race parks the enrollment (no dispatch).

Phase 6 (separate issue) — Arbox importer & v2 builder

  • Out of scope here; see below + issue Phase 2.

Open questions

  • Default channel for imports? Preserve source channel where Taikan supports it; map WhatsApp-origin automations to draft + channel=whatsapp (won’t fire until FIT-140 + fallback). Don’t silently rewrite everything to WhatsApp.
  • WhatsApp not set up yet? Step-run lands skipped(channel_not_configured), optionally falls back. Visible, not lost.
  • Form-sending automations (resolved — no link). Roughly half the Arbox library is “fill out this form: [link]” (health declaration, parent consent, waivers, freeze/cancel). Taikan does not model these as a link in an automation message. Forms are a native, embedded module (FIT-158 API + ../taikan-mobile app/(tabs)/profile/forms/): a form is issued to the member (auto-issue on join, or staff/agent assign) and surfaces in-app in “My Forms” with a pending badge — no URL is sent. So an Arbox form-send maps to the forms module’s issue-and-surface flow, not an action step. The token-gated /forms/sign/:token cold link stays for the ~5% pre-install / non-member edge cases (parent consent, studio rental), generated explicitly in the web UI. The automations engine therefore needs no form-attachment action; {link} is reserved for genuine external URLs (Google review, payment/debt link). (Confirmed with owner.)
  • Arbox API auth model? Unresolved; needs a live API spike (Phase 6). Does not block the engine.
  • Locale of the message? Resolved for leads (leads.locale). For members, users still has no locale column → resolve from org default / member-profile in the dispatcher; flagged open for the member side — confirm source of truth before building templates.
  • Lead messaging consent / opt-out? Resolved → ADR-0013 (FIT-206). Per-lead, per-channel consent gates every lead send at the dispatcher choke-point: email is opt-out (soft opt-in; mandatory unsubscribe link + List-Unsubscribe; suppress on opt-out), SMS/WhatsApp are explicit opt-in. A blocked send records skipped(lead_opted_out | no_explicit_consent) in automation_step_runs — visible, never silently dropped. The gate is unconditional (fail-closed compliance, not a feature flag).
  • Lead converts mid-journey? Conversion should exit active lead-funnel enrollments (goal met) and let new_member automations take over — needs the lead.status_changed → converted (or the existing MEMBERSHIP_ACTIVATED) listener to cancel/exit matching lead enrollments.
  • Trial-reminder timing model? Chosen: a delay step with untilContextField='trial_date' (wait until trialDate − offset), seeded by the trial_booked trigger — no extra cron. Confirm this over a dedicated trial_reminder sweep; the journey-native approach is cleaner but needs the absolute-wait delay mode built and tested.
  • Branch condition vocabulary? v1 ships a minimal set (attended_since, has_active_subscription, booked_since) evaluated against context + a fresh member read. Expand as journeys mature.
  • Tier gate? Assume pro/elite. Confirm the tier matrix.
  • Circuit-breaker thresholds + per-org daily send budget defaults? Need starting values (e.g. pause at ≥20 failures / 10 min; budget by tier) — tune against real volume.
  • Dedicated worker process from day one, or split later? Worker-only deploy fully isolates automation load from HTTP; decide whether that’s launch-blocking or a fast-follow.
  • Async exit criteria? “Stop the win-back the moment they book” — branches only evaluate when the cursor reaches them. Every mature journey tool grows goal-based exits (conditions checked on advance or on relevant events). The exited enrollment status already reserves the slot; defer the mechanism.
  • Migrate-in-flight on edit? v1 cancels in-flight enrollments on structural edit. If owners ask to carry members over to the edited journey, that implies versioned definitions (enrollments pin a version) — a real feature, not a tweak. Wait for the ask.

Arbox import (Phase 1) — how it layers on

Once the native model exists, the importer is a mapper + review screen. Each Arbox automation maps to a one-step (or two-step) journey:

Arbox concept→ Taikan
”Absent 8 days”triggerType=absent, triggerConfig={days:8}, steps [action]
”10 days before expiry, 15:00”triggerType=membership_expiring, {daysBefore:10}, steps [delay {sendAtTime:'15:00'}, action] (send-at-time lives in delay config)
“New member”triggerType=new_member, [action]
”Birthday, 10:00”triggerType=birthday, steps [delay {sendAtTime:'10:00'}, action]
”After Nth session”triggerType=session_completed, {milestones:[…]}
”Missed class, 2h after”triggerType=class_missed, steps [delay {120m}, action]
”Send health declaration / waiver / consent”not an automation → the native forms module issues the form (auto-issue on join, or staff assign); it surfaces in-app in “My Forms” with a badge. No link sent.
channel SMS/PUSH/WhatsAppaction.config.channel (+ fallbackChannel)
{שם פרטי} / {שם משפחה}rewrite → {first_name} / {last_name}
template + triggerone automations row, status=draft, importedFrom='arbox', externalRef=<id>

Imported automations land as draft → owner reviews in the editor → bulk activate. The draft state, provenance columns, and the WhatsApp stub fallback all exist in this design specifically to make Phase 1 clean.


Known limitations / follow-ups (post prod-readiness review)

Addressed before ship: cross-tenant audience validation on manual run (org-scope the audience), per-automation sent metric (join via enrollment), terminate an enrollment after BullMQ retries exhaust (no infinite backstop churn), backstop skips paused/archived automations, archive cancels in-flight enrollments, and unseeded tokens are blanked on send / removed from the catalog (no literal {token} ever reaches a recipient). Deferred (tracked separately, not launch-blocking given the feature ships behind AUTOMATIONS_ENABLED=off + gradual per-org rollout):

  • Dispatch inside the FOR-UPDATE tx. advance() holds the enrollment row lock (and its connection) across the provider send. Mitigated for launch by capping worker concurrency (4) well under the pool; the real fix is to dispatch outside the lock (the step-run claim already makes it idempotent) and/or run a dedicated worker process.
  • At-least-once send on a crash between provider-accept and the sent write — needs provider-side idempotency keys; push/email tolerate the rare dup.
  • Per-org budget is check-then-incr (not atomic) — a soft cap; can overshoot slightly under concurrency.
  • Push reports sent for device-less members — inflates the sent metric/budget.
  • Milestone uses exact = match — if two check-ins land between events the count can jump past the configured milestone; revisit with >= + last-fired tracking.
  • Token validity is subject-scoped, not trigger-scoped — a member token used in a trigger that doesn’t seed it renders blank (not literal) but isn’t flagged at author time.

  • FIT-157  — this feature
  • FIT-140  — WhatsApp ISV integration (unblocks the channel)
  • ADR-0009 — BullMQ/cron + idempotency discipline (the timer/retry substrate)
  • ADR-0005 — business logic in the API (why the engine, not clients, owns this)
  • ADR-0004organization_id scoping (every table here)
  • apps/api/src/forms/forms.service.ts handleMembershipActivated — the closest existing pattern (one hardcoded automation)
  • apps/api/src/notifications/notification-scheduler.service.ts — the cron-sweep pattern the schedulers copy
  • apps/web/src/components/overview/workouts/workout-builder/ — the step-list builder pattern the v2 UI follows
  • apps/web/src/components/overview/forms/template-editor.tsx — the message-composer pattern
  • apps/api/src/instrument.ts — Sentry init; pinoIntegration makes logger.error → Sentry in prod (the quota-burn mechanism)
  • apps/api/src/notifications/notification-scheduler.service.ts:54-93 — the allSettled → one-summary-log pattern sweeps must follow
  • apps/api/src/platform-billing/platform-billing-recurring.service.ts:282-294 — disciplined single-capture-with-tags (per-item here is OK because volume is low; automations aggregate instead)
  • apps/api/src/common/crons-enabled.ts — the kill-switch pattern AUTOMATIONS_ENABLED mirrors
  • libs/db/src/lib/schema/leads.tsleads (name/email/phone/locale/status/statusChangedAt), lead_status_events, organization_leads (trialDate, convertedMembershipId) — the lead-subject source tables
  • apps/api/src/organization-leads/organization-leads.service.ts — lead lifecycle; :532 auto-creates the new_lead task (where lead.created should be emitted); convertLead() emits MEMBERSHIP_ACTIVATED (source:'lead_converted') — the funnel/member seam
  • libs/db/src/lib/schema/enums.ts taskTriggerType — the pre-existing lead-trigger vocabulary (new_lead, trial_booked) the automation engine overlaps
  • apps/api/src/workout-assignments/workout-assignments.service.ts:819-834 — tx + SELECT … FOR UPDATE row-lock pattern advance() follows (incl. the Drizzle findFirst-can’t-lock caveat)
  • apps/api/src/payments/services/recurring-charge.service.ts:62-77FOR UPDATE SKIP LOCKED sweep-pickup pattern the backstop follows
  • apps/api/src/platform-billing/platform-billing.service.ts:156pg_advisory_xact_lock precedent (available, not needed here)