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
- 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.
- 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. - 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.
- Two trigger families, reusing infra we already have: event-driven via the
in-process
EventEmitter2bus (@OnEvent), and time-based via a daily@Cronsweep gated bycronsEnabled(). Both enroll the subject; the engine advances it. - Channels behind a
ChannelStrategyinterface.pushandemailare live (PushNotificationsService,EmailService);whatsapp/smsare stubbed until FIT-140 . Swapping the stub for a real adapter is one file. - Idempotency is two database constraints:
UNIQUE(automation, subject, enroll_key)(never enroll the same occurrence twice) andUNIQUE(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. - Authoring lives in
apps/webatdashboard/automations, following the existing workout-builder (custom step-list state machine) and forms template-editor patterns — no React Flow. See § Frontend. - Bounded blast radius. Automations fan out, so per-member failures are recorded
as data (
automation_step_runs+warnlogs), never per-item Sentry captures (in prodlogger.error→ Sentry viapinoIntegration). A per-automation circuit breaker auto-pauses a misbehaving rule; BullMQ rate/concurrency limits, a per-org send budget, and a globalAUTOMATIONS_ENABLEDkill switch protect the API, DB, and wallet. See § Observability & safety. - 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 holdingSELECT … FOR UPDATEon 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. - 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.localeexists; 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.
| Capability | Reality in main | Evidence |
|---|---|---|
| In-process event bus | EventEmitterModule.forRoot() is global, but exactly one domain event is emitted: membership.activated. | apps/api/src/memberships/membership-events.ts → FormsService.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 sweeps | Established pattern: @Cron + if (!cronsEnabled()) return + runWithRetry(...) + windowed query + Promise.allSettled. | apps/api/src/notifications/notification-scheduler.service.ts |
| Push channel | Live. PushNotificationsService.notifyUser/scheduleNotifyUser. BullMQ-backed, prefs-gated. | apps/api/src/push-notifications/ |
| Email channel | Live. EmailService.send({ to, subject, html }) (Resend), synchronous, no prefs gating. | apps/api/src/notifications/email.service.ts |
| WhatsApp / SMS | Not built. No provider code or deps. Roadmapped under FIT-140/FIT-157. | grep: zero hits outside docs |
| An automation precursor | taskTriggerType enum + taskSource: 'auto' already auto-create tasks on triggers. | libs/db/src/lib/schema/tasks.ts |
| Member signals | users.birthDate, users.phone, bookings.checkedInAt (attendance), bookings.status ∈ {attended, no_show}, subscriptions.currentPeriodEnd. | libs/db/src/lib/schema/{users,scheduling,payments}.ts |
| Builder UI precedent | A 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 — Rule | Tier 2 — Journey (chosen model) | Tier 3 — Workflow / Temporal | |
|---|---|---|---|
| Shape | trigger → delay → action | trigger → [delay · branch · action]* (linear + simple branch) | arbitrary DAG, parallel, sub-workflows, code steps |
| Execution unit | one send | a member enrollment walking steps, carrying context | durable workflow instance running code |
| ”Input/output” / state | a var snapshot at send | enrollment.context JSONB accumulates across steps | typed activity I/O, signals, queries |
| Covers | 100% of documented Arbox automations | drip onboarding, escalating win-back | ops orchestration, sagas, provisioning |
| Authoring UI | a form | vertical step list (workout-builder pattern) | flow canvas (React Flow) |
| Build cost | low | medium | high |
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:
| Concern | Member subject | Lead subject |
|---|---|---|
| Channels | push · email · whatsapp · sms | email · 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). |
| Tokens | full 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. |
| Locale | users has no locale (open question) → org default | leads.locale exists — resolve from the lead row directly. The funnel side is better off here. |
| Lifecycle | enrollment ends on exit/completion | a 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 | cancelledAuthoring 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):
| Guard | Constraint | Prevents |
|---|---|---|
| Enrollment dedup | UNIQUE(automation_id, subject_type, coalesce(subject_user_id, subject_lead_id), enroll_key) | enrolling the same occurrence (member or lead) twice |
| Step-send dedup | UNIQUE(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
absentkeys 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
delayor finishes — no tight enqueue loop. - Manual trigger:
POST /automations/:id/runresolves the audience andenrolls each subject (member segment, or a lead segment). - Cron backstop: a sweep re-enqueues
status='active' AND next_run_at <= nowenrollments that BullMQ may have dropped (durability beyond the queue), selecting withFOR UPDATE SKIP LOCKEDso the backstop and a late BullMQ delivery never double-process (the pattern inrecurring-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_idFK 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:
Race Control Existing precedent worker advances while owner pauses/edits advance()= one tx,SELECT … FOR UPDATEon the enrollment row, re-checkautomations.status='active'before dispatch — the losing side sees the flip and parksworkout-assignments.service.ts:819-834(tx +.for('update'); note: Drizzle’s relationalfindFirstcan’t lock — use the query-builder)backstop sweep races a late BullMQ delivery FOR UPDATE SKIP LOCKEDon due-enrollment pickuprecurring-charge.service.ts:62-77both still collide somehow the UNIQUE(enrollment, step)step-run claim — at most one dispatchesthis ADR’s ledger (
pg_advisory_xact_lockalso 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>;
}| Channel | Strategy | Backing | State |
|---|---|---|---|
push | PushChannelStrategy | PushNotificationsService.notifyUser (prefs-gated, queued) — supportsLeads=false | live, members only |
email | EmailChannelStrategy | EmailService.send (Resend) — wrap body in a template; to.email works for members and leads | live |
whatsapp | WhatsAppChannelStrategy | stub: isConfigured()=false; records intent, step-run = skipped | stub → FIT-140 |
sms | SmsChannelStrategy | stub, same shape | stub → 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.
| Trigger | Family | Signal today? | To light it up |
|---|---|---|---|
new_member | event | ✅ membership.activated | listener only — works on day one |
birthday | cron | ✅ users.birthDate | sweep |
membership_expiring | cron | ✅ subscriptions.currentPeriodEnd | sweep |
membership_ended | cron | ✅ expired-subs cron exists | sweep |
absent | cron | ✅ bookings.checkedInAt | sweep + “last attendance” query |
class_missed | event | ✅ booking.no_show emitted from the auto-no-show cron | wired |
session_completed | event | ✅ booking.attended + per-automation attended-count milestone match | wired |
membership_renewed | event | ✅ subscription.renewed emitted from handleChargeSuccess | wired |
drop_in | event | ✅ booking.attended + drop-in plan check in the listener | wired |
manual | api | ✅ n/a | controller endpoint |
new_lead | event | ◑ lead created, but auto-creates a task directly — not on the bus | emit lead.created (co-locate with the new_lead task at organization-leads.service.ts:532) |
trial_booked | event | ◑ status→trial_booked writes lead_status_events, not the bus | emit lead.status_changed on the status transition |
trial_no_show | cron | ✅ organizationLeads.trialDate + leads.status | sweep: trialDate < now AND status='trial_booked' |
lead_cold | cron | ✅ leads.status + statusChangedAt | sweep: 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.
| Verb | Surface | Reuses / mirrors |
|---|---|---|
| Manage | dashboard/automations — list (name · trigger · channel · status · enrolled count · sent-30d), status toggle, per-row enrollment history drawer | tasks list patterns |
| Create | dashboard/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 vars | forms template-editor.tsx |
| Time | timing panel: delay (n hours/days), send-at-time, wait-until a date (trial reminder), quiet-hours window, timezone | maps 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, mirroringuse-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/failed → one 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:
| Class | Examples | Retry? | Logged | Sentry |
|---|---|---|---|---|
| terminal | bad template, member has no email/phone, opted out, channel not configured, audience no longer matches | no | warn | no |
| retryable | provider 5xx / 429 / timeout | BullMQ bounded (attempts: 3, backoff) | warn | no — counts toward breaker |
| engine bug | unexpected throw in advance() | no (fail the enrollment) | error | once, 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 ceiling —
maxStepsPerEnrollment(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.
| Surface | Source | Audience |
|---|---|---|
automation_step_runs queries | the ledger (source of truth) | API/UI — per-automation sent/skipped/failed, last error |
| Enrollment-history drawer + stats strip | ledger + enrollments | gym owner — trust/debug; “auto-paused: reason” banner |
| bull-board (already mounted, ADR-0009) | BullMQ | operator — queue depth, failed/stuck jobs |
PostHog aggregated counters via observability.emit | engine | product — enrolled/sent/failed/auto_paused, tagged org+automation+channel (bounded cardinality) |
Admin health view (apps/admin) | ledger + queue | platform — auto-paused list, top failers, daily volume/cost |
| One alert on breaker trip | engine | on-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
ChannelStrategyseam 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
draftreview 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
leadspipeline (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 approval →db: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 math —
send_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 (
taskTriggerTypevsautomationTriggerType), and the overlap is now literal on the lead side:new_leadandtrial_bookedexist in both. TodaytaskTriggerTypeauto-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 leadlead.created/lead.status_changedemits should feed both, and converging “create task” into anactionstep 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
beforeSendceiling are not optional polish; they are load-bearing given the fan-out, and their thresholds need real-world tuning.
Alternatives considered
- Flat single-send model (this ADR’s first draft: one
automation_runsrow = 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. - 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.
- 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
actionstep type. - Per-event hardcoded hooks (the forms
@OnEventauto-issue style). Rejected: doesn’t scale to owner-configurable rules; every automation would be a code change + deploy. - 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.
- Send directly from cron/listeners, no engine (today’s class reminders). Fine as plumbing; not the configurable, auditable, multi-channel product.
- 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.automationSubjectTypeand the four lead triggers).libs/db/src/lib/schema/automations.ts—automations,automationSteps,automationEnrollments(polymorphic subject:subjectType+subjectUserId/subjectLeadId, the CHECK, thecoalesce(...)dedup unique index; importsusersandleads),automationStepRuns+ relations +$inferSelect/$inferInsert.libs/db/src/lib/schema/index.ts— re-export.libs/shared/src/lib/schemas/automation.schema.ts—*_VALUEStuples +z.enums,automationResponseSchema(with nestedsteps),createAutomationSchema,updateAutomationSchema,listAutomationsQuerySchema(mirrortask.schema.ts).libs/shared/src/index.ts— re-export.pnpm db:generate→ review SQL → request approval →pnpm db:migrate(mind_journal.json when).
Phase 1 — engine core (channel-agnostic)
apps/api/src/automations/automations.module.ts—BullModule.registerQueue({ name: AUTOMATIONS_ADVANCE_QUEUE }); import Push + Notifications modules.apps/api/src/automations/automation-engine.service.ts— subject-awareenroll(automation, subject, enrollKey, seedContext)for member or lead (dedup insert + firstnextRunAt+ enqueue) andadvance(...)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.tsw/ 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
AutomationsModuleintoapps/api/src/app/app.module.ts.
Phase 2 — channels
…/channels/channel.strategy.ts(interface +Recipient+DispatchResult+supportsLeads),…/channels/{push,email}.channel.ts(live; pushsupportsLeads=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, writeautomation_step_runsoutcome.- Save-time validation in
automations.service.ts: lead-triggered automation can’t pickpushas 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 (mirrorcrons-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-itemerrorlogs / captures). - BullMQ
limiter+ workerconcurrency+ per-org in-flight cap; enqueue jitter; per-org daily send budget; channel rate limits. - Step-graph cycle validation at save time +
maxStepsPerEnrollmentruntime ceiling. - Extend
apps/api/src/instrument.tsbeforeSendwith fingerprint error-event rate limiting. - Aggregated PostHog metrics via the existing
observability.emit; admin health view inapps/admin(can trail).
Phase 3 — trigger sources (members + leads, both v1)
…/automation-events.listener.ts—@OnEvent(MEMBERSHIP_ACTIVATED)→ enroll fornew_member. End-to-end proof: new member → welcome message.- Lead events: emit
lead.created(co-locate with thenew_leadtask atorganization-leads.service.ts:532) andlead.status_changed(on the status transition that already writeslead_status_events), both via themembership-events.tsrecipe; listeners enroll fornew_lead/trial_booked. On→ converted, exit active lead-funnel enrollments. …/automation-scheduler.service.ts—@Crondaily sweeps forbirthday/membership_expiring/membership_ended/absentand the lead sweepstrial_no_show/lead_cold, gated bycronsEnabled(), wrapped inrunWithRetry; 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_coldenroll-key stability across consecutive sweeps, save-time push-blocked-for-lead validation. - Integration:
new_memberevent → enrollment → dispatch;new_leadevent → 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-mobileapp/(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 anactionstep. The token-gated/forms/sign/:tokencold 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,usersstill has nolocalecolumn → 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 recordsskipped(lead_opted_out | no_explicit_consent)inautomation_step_runs— visible, never silently dropped. The gate is unconditional (fail-closed compliance, not a feature flag). - Lead converts mid-journey? Conversion should
exitactive lead-funnel enrollments (goal met) and letnew_memberautomations take over — needs thelead.status_changed → converted(or the existingMEMBERSHIP_ACTIVATED) listener to cancel/exit matching lead enrollments. - Trial-reminder timing model? Chosen: a
delaystep withuntilContextField='trial_date'(wait until trialDate − offset), seeded by thetrial_bookedtrigger — no extra cron. Confirm this over a dedicatedtrial_remindersweep; 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 againstcontext+ 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
exitedenrollment 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/WhatsApp | action.config.channel (+ fallbackChannel) |
{שם פרטי} / {שם משפחה} | rewrite → {first_name} / {last_name} |
| template + trigger | one 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 workerconcurrency(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
sentwrite — 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
sentfor 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.
Related
- 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-0004 —
organization_idscoping (every table here) apps/api/src/forms/forms.service.tshandleMembershipActivated— the closest existing pattern (one hardcoded automation)apps/api/src/notifications/notification-scheduler.service.ts— the cron-sweep pattern the schedulers copyapps/web/src/components/overview/workouts/workout-builder/— the step-list builder pattern the v2 UI followsapps/web/src/components/overview/forms/template-editor.tsx— the message-composer patternapps/api/src/instrument.ts— Sentry init;pinoIntegrationmakeslogger.error→ Sentry in prod (the quota-burn mechanism)apps/api/src/notifications/notification-scheduler.service.ts:54-93— theallSettled→ one-summary-log pattern sweeps must followapps/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 patternAUTOMATIONS_ENABLEDmirrorslibs/db/src/lib/schema/leads.ts—leads(name/email/phone/locale/status/statusChangedAt),lead_status_events,organization_leads(trialDate,convertedMembershipId) — the lead-subject source tablesapps/api/src/organization-leads/organization-leads.service.ts— lead lifecycle;:532auto-creates thenew_leadtask (wherelead.createdshould be emitted);convertLead()emitsMEMBERSHIP_ACTIVATED(source:'lead_converted') — the funnel/member seamlibs/db/src/lib/schema/enums.tstaskTriggerType— the pre-existing lead-trigger vocabulary (new_lead,trial_booked) the automation engine overlapsapps/api/src/workout-assignments/workout-assignments.service.ts:819-834— tx +SELECT … FOR UPDATErow-lock patternadvance()follows (incl. the DrizzlefindFirst-can’t-lock caveat)apps/api/src/payments/services/recurring-charge.service.ts:62-77—FOR UPDATE SKIP LOCKEDsweep-pickup pattern the backstop followsapps/api/src/platform-billing/platform-billing.service.ts:156—pg_advisory_xact_lockprecedent (available, not needed here)