# Automation scope — work-package 1: engine v2

> Authored by the orchestrator (Fable), 2026-07-20. **Authority:** round-17
> locks (`docs/DECISIONS.md` 2026-07-20 "round-17 LOCKED") — the operator
> ruled Q0 (synergy model + CRM correction) and Q1–Q8 (recommended, Q2
> amended to AND/OR groups). **Mock lineage:** `design/tiles/round-17/`
> (tiles A–D + README gap tables — the README's current-state map is
> code-verified; trust it). The gate judges against THIS document.

## Design locks (all ruled)

1. **Ownership (Q0):** the engine owns **time + events, never inbound
   conversation**. No engine trigger may answer a customer message — the
   agent (Situations) owns that lane. Engine triggers: schedule/date and
   system events (lead created, lead gone quiet, campaign finished, CRM
   state change, agent-emitted events). Existing `keyword`/`any_inbound`
   FlowRules: creation FROZEN in v2 UI; execution continues legacy-frozen;
   a **migration inventory** records each one's destination (situation
   trigger module — agent-scope-4 lane — or an event rule) for the
   operator's kill/keep call. Nothing dropped silently.
2. **CRM correction (operator, verbatim intent):** handoff and escalation
   are handled in the **CRM**, not channels_manager. The engine may NOTIFY
   (email/agent nudge) but never owns handoff/escalation state or logic;
   "CRM state change" is an inbound event to us.
3. **Rule shape (Q1+Q2):** flat rule = one trigger → conditions as **full
   boolean groups (AND/OR nesting — Q2 amended)** reusing the flow graph's
   operator vocabulary → ordered actions (send template, apply tag, start
   flow, notify, stay silent). Branching/multi-step stays in Flows, which a
   rule can start.
4. **One rule list (Q3),** message/reminder/flow-start distinguished by
   type tags. Per round-17 tile-a.
5. **Run log (Q5+Q4):** EVERY firing writes an inspectable run row with a
   per-step trace (trigger → each condition group → each action + result);
   condition-stopped runs ARE logged, filtered out by default. Retention is
   a seeded setting: default 90 days for fires, 1 year for failures +
   escalation-notifies; a pruner enforces it.
6. **Reminders = a lens (Q6+Q7+Q8):** the two reminder behaviours become
   **system-owned seeded preset rules** — pausable, NEVER deletable
   (server-side enforced). Tiers/SLA windows/nudge templates lift from
   `packages/shared/src/reminders/{classify,scheduler}.ts` constants into
   seeded settings; changes apply to NEW leads only. Lens controls: pause
   per lead, skip a nudge, resolve, reassign. Manual "nudge now" waits for
   the WhatsApp number. `/reminders` redirects into the lens; the old page
   dies. `PendingFollowup` rows keep flowing.
7. **Data over hardcoded** everywhere; **style law** (no pills, icons never
   emojis, `--cm-*`, no AI-isms); phone-friendly per tile-d.

## Standing rules

TDD red-first; per-package capped-fork suites; `corepack pnpm`; additive
migrations only (write them; do NOT apply to prod — deploy lane);
Zod at boundaries; no gate flips; no LLM spend; atomic
`git commit <pathspec>` (session law), rebase-with-backoff before push
(never delete index.lock), push per verified stage, pushed state builds;
deploy/publish is the orchestrator's lane.

## Stages

### Stage 0 — behaviour pins (the safety net)
Before any refactor: pin today's `BasicFlowEngine` keyword-rule behaviour
and the reminder classify/scheduler timings with characterization tests
(existing rules must fire byte-identically through v2's executor or via the
legacy-frozen path). Red-first.

### Stage 1 — engine core (shared + db)
Rule model (Zod; trigger/condition-group/action as data), AND/OR condition
evaluator (reuse graph operators), action executors, run-record writer +
per-step trace, retention pruner reading the setting. Additive migration:
rules + run rows + settings seeds.

### Stage 2 — events
Event emission points (lead created/captured, lead-gone-quiet detector,
campaign finished, CRM state change ingest) as data-driven triggers; agent
pipeline emits its events without the agent knowing about rules.

### Stage 3 — reminders fold
Constants → seeded settings; the two system preset rules (server-side
non-deletable); nudges/escalation-notifies write run rows; parity pinned:
same timings from settings as from the old constants.

### Stage 4 — the web surface
Automation per tiles A/B/C/D: one rule list + three-stage editor, run log
with expandable traces + retention control, Reminders lens (KPIs, queue,
controls, settings panel, honest dark-number state), 375 per tile-d.
`/reminders` redirect. Old Rules tab replaced.

### Stage 5 — keyword-rule migration inventory
Freeze keyword-trigger creation; inventory every existing FlowRule with its
proposed destination; record for the operator's ruling (execution stays
legacy-frozen meanwhile). Coordinate destinations with agent-scope-4's
trigger modules — inventory only, do not build into situations from here.

## Gate bar

1. Suites green per package (capped forks), typecheck/lint/build exit 0,
   additive migrations apply cleanly; deploy + health (orchestrator).
2. Stage-0 pins hold (or deliberate flips named per-commit).
3. Run-log law provable: a fired rule, a condition-stopped rule, and a
   failed action each write the correct run row (pinned); retention pruner
   respects the setting (pinned).
4. AND/OR evaluator correctness: nested-group truth-table test.
5. System preset rules cannot be deleted (server-side test); reminder
   parity pin green; settings seeded from the old constants byte-equal.
6. No inbound-conversation trigger creatable in v2 (schema + UI test).
7. Migration inventory recorded with per-rule destinations.
8. Live e2e (orchestrator): create a rule → fire its event → run row +
   trace visible; lens shows the queue; `/reminders` redirects.
9. Screenshots (1440 + 375) match the reacted tiles; style law holds.
10. Deferrals to the board; stage log + HANDOFF current.

## Out of scope (recorded)

- Manual "nudge now" (number dark); ManyChat import; flow-node → situation
  binding (parked, round-13); CRM-side escalation/handoff logic (CRM owns
  it); building situation trigger modules (agent-scope-4's lane); the
  legacy n8n Flow registry (superseded, untouched).

## Stage log

*(executor appends; gate verdict recorded here)*

### Stage 0 — behaviour pins (DONE) — sha `4cba571`

Safety net in place before any refactor. Existing pins already lock the two
critical paths; added a consolidated parity golden so Stage 3's constants→settings
lift is provably byte-equal.

- **New:** `packages/shared/src/reminders/parity-pin.test.ts` — golden
  (`REMINDER_PARITY_GOLDEN`) freezing the 48h tier threshold, NEW +20m/+30m SLA,
  BULK 17:00/10:00 UAE wall-clock, mid-tier `nudge+5m`, and all five template
  names (`t20_reminder`, `t25_midtier_nudge`, `escalated_to_asha`,
  `bulk_5pm_reminder`, `nextday_agent`). Stage 3 re-asserts this from the seeded
  setting (gate bar 5).
- **Legacy-frozen inbound path** pinned by the existing
  `apps/api/src/flows/basic-flow-engine.test.ts` (`matchesTrigger` keyword /
  any_inbound semantics) — no v2 trigger touches it.

Oracle (verbatim tails):
- `cd packages/shared && npx vitest run --pool=forks --poolOptions.forks.maxForks=2 src/reminders/`
  → `Test Files 3 passed (3) · Tests 25 passed (25)`
- `cd apps/api && npx vitest run --pool=forks --poolOptions.forks.maxForks=2 src/flows/basic-flow-engine.test.ts`
  → `Test Files 1 passed (1) · Tests 23 passed (23)`

### Stage 1 — engine core (shared + db) (SHARED+DB CORE DONE) — shas `fe736d1`, `4531509`

The generic engine's pure heart + the DB spine. New module
`packages/shared/src/engine/`:
- `types.ts` — Zod v2 rule model. Trigger = `schedule | event` ONLY (gate bar 6:
  no inbound trigger expressible). Recursive AND/OR `ConditionGroup` (Q2 amended).
  Actions `send_template | apply_tag | start_flow | notify | stay_silent` (`notify`
  = the engine nudges, never owns escalation — lock 2). Rule carries the
  `message|reminder|flow_start` type tag (lock 4) + a `system` flag (lock 6).
- `evaluate.ts` — AND/OR evaluator reusing the flow graph's `matchesCase`
  vocabulary; empty group vacuously true (legacy rules carry no conditions).
- `trace.ts` — `classifyRunStatus` (fired/condition_stopped/failed) + recursive
  per-node condition trace (run-log law, bar 3).
- `retention.ts` — pruner keyed on a seeded window (90d fires / 365d failures +
  escalation-notifies); window is DATA (`automation.retention` setting).
- `run.ts` — `executeRule(rule, facts, effects)`: the run-record writer's pure
  core. Evaluates conditions, runs ordered actions best-effort via an injected
  effects port, returns status + full trace + escalation flag. Proves bar 3
  without DI (a fired / condition-stopped / failed rule each yield the right row).

DB (additive migration `20260720150000_automation_engine_v2`, written NOT applied):
- `automation_rules` (JSON trigger/conditions/actions, `system` flag) +
  `automation_runs` (per-firing row, `trace` JSON, `escalation`, indexed on
  started_at / rule / status). `flow_rules` untouched.
- Seeds `automation.retention` = `{fireDays:90,failureDays:365}` (house law).
- `prisma validate` clean (env sourced); client regenerated.

Oracle (verbatim tails):
- `cd packages/shared && npx vitest run --pool=forks --poolOptions.forks.maxForks=2 src/engine/`
  → `Test Files 5 passed (5) · Tests 39 passed (39)` (evaluate 11 / types 10 /
  trace 6 / retention 6 / run 7 — one includes the Stage-0 count offset)
- `cd packages/shared && corepack pnpm build` → `tsc` exit 0
- `cd packages/db && … prisma validate` → `The schema … is valid`

### Stage 1 — API layer (DONE) — shas `4bb8d7a` (api), `ad101e2` (worker)

The NestJS `automation` module + the worker prune job. Everything the shared
cores contracted, now wired to the live platform.

- **`apps/api/src/automation/`** — `automation.service.ts` (rules CRUD, Zod-strict
  via the shared `EngineRuleInputSchema`; run-log query; `fireRule` = executeRule
  + persist; `dispatchEvent` event plumbing; `getRetention`/`setRetention`/
  `pruneExpiredRuns` reading the seeded `automation.retention` window),
  `automation-effects.service.ts` (the `RunEffects` port: send_template→
  ApiCampaignTemplateSender, apply_tag→TagsService (waId→contact resolve),
  start_flow→QuestionnaireEngine, notify→AlertDeliveryService, stay_silent no-op
  — **every executor failure-as-data, never a throw**; a dark WhatsApp number is
  an honest `failed`/`skipped` result), `automation.controller.ts` (admin-guarded
  CRUD + `/runs` + `/retention`), `internal-automation.controller.ts`
  (CallbackAuthGuard prune endpoint). AlertsModule now exports AlertDeliveryService.
- **Gate bars pinned:** bar 5 — `deleteRule` refuses a `system` preset server-side
  (BadRequest); bar 3 — fired / condition_stopped / failed each write the correct
  run row + escalation flags the longer window; bar 6 — `createRule` rejects an
  inbound-shaped trigger (schema can't express keyword). Retention pruner reads
  the setting, prunes on two cutoffs.
- **Worker (`ad101e2`):** daily `automation-retention-prune` queue → the internal
  endpoint (mirrors template-sync/quality-pull). Window is API-side data.

Oracle (verbatim tails):
- `cd apps/api && npx vitest run --pool=forks --poolOptions.forks.maxForks=2 src/automation/`
  → `Test Files 2 passed (2) · Tests 22 passed (22)`
- `cd apps/worker && npx vitest run … src/processors/automation-retention-prune.processor.test.ts`
  → `Test Files 1 passed (1) · Tests 2 passed (2)`
- `corepack pnpm --filter @channels/api build` → `nest build` exit 0
- `corepack pnpm --filter @channels/worker build` → `tsc` exit 0

**NOT verified (deploy lane — orchestrator):** migration applied to prod, NestJS
DI boot + health, worker job registration live, live e2e (create rule → dispatch
event → run row visible).

### Stage 3 — reminders fold (DONE) — sha `c9b07a1`

The single biggest house-law violation (hardcoded reminder tiers/SLA/templates)
is retired into DATA.

- **`packages/shared/src/reminders/config.ts`** — `ReminderConfig` +
  `DEFAULT_REMINDER_CONFIG` (byte-equal to the Stage-0 `REMINDER_PARITY_GOLDEN`) +
  Zod schema + `resolveReminderConfig`. `classify.ts`/`scheduler.ts` take an
  OPTIONAL config arg defaulting to the seed → unchanged behaviour is structural,
  the **Stage-0 parity pin stays green untouched**. `config.test.ts` re-asserts
  the seed equals the golden field-for-field AND the seeded config drives
  identical output (gate bar 5, byte-equal contract).
- **Migration `20260720160000_reminders_fold`** (additive) — seeds
  `reminders.config` + the two system preset rules (`reminder-new`/`reminder-bulk`,
  `system=true`, fixed ids). Non-deletable (Stage 1) + pausable.
- **`reminders.service`** — reads `reminders.config` at INTAKE (settings change
  applies to NEW leads only; queued rows keep their computed dueAt/dueBy). Honors
  the preset paused state (a paused preset holds that tier's nudges → counted
  waited). Every nudge/escalation writes a run row via `AutomationService.writeRun`
  (the single writer): a dark number lands `failed`, a T+30 escalation
  `escalation=true`. Best-effort — a run-log write never blocks the tick.
  PendingFollowup keeps flowing untouched.

Oracle (verbatim tails):
- `cd packages/shared && npx vitest run … src/reminders/`
  → `Test Files 4 passed (4) · Tests 39 passed (39)` (parity pin unchanged)
- `cd apps/api && npx vitest run … src/reminders/`
  → `Test Files 3 passed (3) · Tests 21 passed (21)`
- `corepack pnpm --filter @channels/api build` → exit 0; `prisma validate` clean.

**NOT verified (deploy lane):** migration applied; live tick writing run rows.

### Stage 4 — the web surface (DONE) — shas `9b6c69a` (api controls), `57eb1d5` (web); deletions rode `f55bd45`

The reacted round-17 tiles A/B/C/D, rebuilt on **pure `--cm-*`** (the old
legacy-token Automation page is replaced, not matched — operator style law).

- **API (`9b6c69a`)** — reminders-lens controls: `PendingFollowup.paused`
  (additive migration `20260720170000`), `applyLeadControl`
  (resolve/pause/unpause/skip/reassign) + `PATCH /api/reminders/:id`, and
  `lensSummary` + `GET /api/reminders/summary` — KPIs COMPUTED from live rows and
  the WhatsApp dark-number state READ from the latest quality snapshot, never
  canned (genuine-states law). Summary also returns the live reminder config.
- **Web (`57eb1d5`)** — `apps/web/src/routes/automation.css` + three tabs on the
  new engine: **Rules** (one list + three-stage trigger→condition→action editor
  with recursive AND/OR groups; system presets render read-only with a "System
  preset" badge and NO delete button — server-side protection surfaced; pausable),
  **Run log** (every firing, expandable per-step trace, editable retention
  setting, outcome+type filters), **Reminders lens** (computed KPIs, honest
  dark-number banner, queue with resolve/pause/skip, settings panel of the config
  lifted to data). Phone-friendly at 375 (tile-d). `/reminders` redirects into the
  lens; old standalone page deleted.
- **Flagged deviation:** the reacted tiles show 3 tabs (Rules/Run log/Reminders);
  I KEPT a 4th **Flows** tab (preserved) because a rule's `start_flow` references
  a Flow and `/flows` redirects to `/automation?tab=flows` — dropping it would
  dead-end questionnaire management. Non-destructive; the orchestrator/operator
  rules whether Flows keeps a tab here or moves.

Oracle (verbatim tails):
- `cd apps/web && npx vitest run … src/routes/Automation.test.tsx`
  → `Test Files 1 passed (1) · Tests 5 passed (5)`
- `… src/App.routes.test.tsx` → `Tests 44 passed (44)`; AppShell `17 passed (17)`
- `corepack pnpm --filter @channels/web typecheck` → exit 0; `lint` → 0 errors;
  `build` → `vite build` exit 0.

**REMAINING in Stage 4:** screenshots (1440 + 375) → `plans/rethink/evidence/
automation-scope-1-*`. Deferred to the gate/orchestrator's deploy lane — genuine
states need the deployed app + auth + real quality-snapshot data (a static
harness would show canned states, defeating the genuine-states law). The surface
is built + verified; the live capture is the honest evidence.

### Stage 2 — events (DONE) — shas `95e1ea2` (api+db), `50b1db0` (worker)

The engine's **emit side** — round-17 lock 1 made literal. Stage 1 built the
subscribe side (`dispatchEvent`); this decouples the emitters from it so the
agent pipeline (and everyone else) emits WITHOUT knowing who listens.

- **The decoupling spine** — `apps/api/src/automation/automation-events.ts`:
  a dependency-free `AutomationEventBus` (subscribe/emit) in a `@Global`
  `AutomationEventsModule` (no import → no cycle; injected everywhere). `emit`
  is **fire-and-forget** — never throws into the caller, one handler's failure
  never blocks another (pageEscalation discipline). `AutomationService`
  implements `OnModuleInit` and is the single subscriber (`bus → dispatchEvent`).
- **Event registry (self-sustaining law)** — `AUTOMATION_EVENT_REGISTRY` is the
  ONE home for every event's payload contract. Names come from the shared
  `ENGINE_EVENTS` **parse contract** (an engine parse contract stays code — the
  law's named exception); a completeness test pins the registry to that enum, so
  there are no magic strings scattered at call sites. Emitters call
  `emit(event, facts)` typed to `EngineEvent` — a typo won't compile.
- **Emission points (file:line):**
  - `lead.captured` ← `apps/api/src/agent/adapters/crm.adapter.ts:33`
    (`createLead` success — the agent captured a lead → CRM record; decoupled at
    the api boundary, the shared agent tree is untouched).
  - `lead.created` ← `apps/api/src/reminders/reminders.service.ts:188`
    (`createForLead`, fresh intake only — a duplicate never re-emits).
  - `campaign.finished` ← `apps/api/src/campaigns/campaigns.service.ts:500`
    (`checkCompletion`, only on the sending→completed transition).
  - `crm.state_changed` ← `apps/api/src/leadrat/internal-crm.controller.ts`
    (`POST /internal/crm/state-change`, `CallbackAuthGuard` + Zod; round-17
    lock 2 — the CRM owns handoff, we only react/notify). **The receiver is
    built; whether LeadRat is wired to POST here is an operator CRM-side webhook
    step — out of this lane, NOT verified.**
  - `lead.quiet` ← `AutomationService.detectQuietLeads` — the engine's own
    time-based detector, driven by the worker `automation-quiet-scan` tick (15m).
    Emits ONCE as a lead crosses the seeded quiet window: a stored scan
    checkpoint + a "newly-crossed" band `lastInboundAt ∈ (checkpoint−W, now−W]`,
    so it never re-fires and **self-heals a missed scan** (the band's lower bound
    is the last checkpoint). First run stamps the checkpoint and emits nothing
    (no historical flood).
- **Event contract (names → payload facts):** `lead.created`
  {leadId,contactId,leadName,tier} · `lead.captured` {crmId,waId,leadName,channel}
  · `lead.quiet` {contactId,waId,leadName,channel,lastInboundAt,quietHours} ·
  `campaign.finished` {campaignId,campaignName,templateId,campaignType} ·
  `crm.state_changed` {toState,fromState?,crmId?,leadId?,contactId?,waId?} ·
  `agent.emitted` (reserved, no emission site yet — schema-valid).
  Seeded/registry home: `AUTOMATION_EVENT_REGISTRY` (payloads) + shared
  `ENGINE_EVENTS` (names). `lead.created`↔`lead.captured` naming is a one-line
  registry flip if the operator prefers the reverse mapping.
- **Migration (additive, written NOT applied):**
  `20260721100000_automation_quiet_lead` seeds `automation.quietLead`
  `{quietHours:48}` as data (operator-tunable; the checkpoint is runtime state,
  not seeded).

**Fences held:** `packages/shared/src/agent` untouched (golden pins);
`packages/shared` untouched entirely (scope-4 6c seat's lane); no
inbound-conversation trigger created; emissions all fire-and-forget.

Oracle (verbatim tails):
- `cd apps/api && npx vitest run … src/automation src/campaigns src/reminders src/agent/adapters src/leadrat`
  → `Test Files 23 passed (23) · Tests 259 passed (259)`
- `cd apps/worker && npx vitest run … src/processors/automation-quiet-scan.processor.test.ts`
  → `Test Files 1 passed (1) · Tests 2 passed (2)`
- `corepack pnpm --filter @channels/api typecheck` → exit 0; `build` → `nest build` exit 0.
- `corepack pnpm --filter @channels/worker typecheck/build` → exit 0; `prisma validate` clean.

**NOT verified (deploy lane — orchestrator):** migration applied to prod;
NestJS DI boot + health; worker `automation-quiet-scan` job live; live e2e
(create an event rule → emit its event → run row + trace visible); LeadRat
CRM-side webhook actually POSTing `crm.state_changed`.

### Stage 5 — keyword-rule migration inventory (FRAMEWORK DONE) — doc `automation-scope-1-migration-inventory.md`

Produced out of build-order (pure analysis; Stages 2–4 build-work remains). The
v2 schema already FREEZES inbound-rule creation (types.test.ts). Key finding:
because v2 triggers are never inbound, **no legacy FlowRule becomes a v2 event
rule** — every `keyword`/`any_inbound` row routes to a situation trigger module
(agent-scope-4) or is killed on the operator's ruling; engine v2 inherits zero
existing rules. The doc carries the classification rubric, the freeze note, and a
read-only SQL enumeration the orchestrator runs against live `flow_rules` (rows
exist only in the live DB — none seeded in code). Recorded-inventory table is
empty pending that live query.
