# channels-scope-5 — ManyChat gradual replacement · FULL-SYSTEM SPEC

> **PREMISE FINAL 2026-07-23 (DECISIONS):** no cutover day — ManyChat runs
> alongside until replaced piece by piece. **Design settled through FOUR
> rounds** (rounds 1–2 rejected; round-3 accepted with the full-system
> mandate; round-4 accepted 2026-07-24 with amendments — DECISIONS both
> dates). The approved surface is `design/tiles/funnel-round-4/round-4.html`
> (10 mocks, binding/demo sheets per mock). This section is the work order;
> everything below "Design round — round 1" is history + still-binding
> constraints (Meta caps, scout wiring map, Stage-1 log).

## The locked frame (all ledgered)

- **Full parity before cutover, no slices.** ManyChat-faithful layout,
  structure, ergonomics inside our tokens. Flows live in **Automation**.
- **Default Reply trigger OMITTED** — an unmatched DM is the AI
  conversation. **Sequences fast-follow. No Basics tab.**
- **Per-flow Off · Shadow · Live** flip; live-flip = typed confirm +
  "pause the ManyChat twin" reminder. Shadow = propose-and-approve.
- **Leads DIRECT to LeadRat; n8n never fed.**
- **AI agent × flows no-conflict is a hard engine requirement** (round-4
  mock 10 table is the contract).
- **WhatsApp: build for it NOW** (2026-07-24 ruling) — the engine, trigger
  picker tab, and senders are channel-agnostic and WhatsApp-capable from
  day 1; only the live number gates actual sends.
- **Contact-events tab** = one event vocabulary: ManyChat's 8 events mapped
  onto engine v2 + our own lead events, one tab.
- **Ref URL / QR / Ads triggers deferred, not abandoned** — greyed in the
  picker with the honest reason; boarded (Channels backlog).
- Exact keywords only (no misspelling variants); randomized public replies
  are a first-class list; ManyChat's add-trigger-before-save quirk NOT
  copied.

## Build stages (each: TDD, own oracle, review before gate)

**Stage A — data model + engine core.** Flow/FlowVersion (draft vs
published, autosave draft, explicit publish), Trigger stack, Node/Edge
graph, per-contact FlowSession (the mutex — ONE active session per
contact; extends/generalizes `QuestionnaireSession`, see scout: rebind,
don't rebuild), run log rows per node execution. Migrations additive.
Oracle: engine unit tests — trigger match starts a session; mid-session
inbound advances it (reply-driven, never scheduled — red-team caps);
expiry releases the thread; agent never fires while a session is active
(no-conflict invariant test); goldens byte-identical.

**Stage B — node executors.** Message (content blocks incl. Gallery=Card×10
and Dynamic=inline external request with fallback; send modes: 24h window /
private reply), Question (11 reply types, typed validation, 3× retry,
timeout branch), Condition (all/any groups, operator matrix per field
type, else-if chain), Actions, Randomizer (weighted), Smart Delay
(duration/date, business-hours), Start-flow (returns control — not a dead
end), AI-agent handover node (goal-text-only steering — 2026-07-24 ruling).
Buttons XOR quick-replies per message; quick-replies ≤13 × 20 chars (caps
below). Oracle: per-executor unit tests + one e2e walking a seeded
Collector-shaped flow in shadow.

**Stage C PRE-SPIKE (orchestrator finding, 2026-07-25 — read before
dispatching Stage C).** The identity half of the contract is not
buildable as written yet: `apps/api/src/leadrat/leadrat.service.ts`
exposes `createLead` / `getLead(crmId)` / `updateLead` / notes / assign /
user-directory — **no search-by-phone endpoint at all** — and `Contact`
has no crm-id column (only `phoneE164`), so "the lead id stores on the
contact once known" needs an additive migration. Two further API shapes
collide with the spec's wording: `appendNote` is
`PUT /api/v1/lead/notes/{crmId}` and REPLACES the field (append = read-
then-write, with a lost-update race to disclose), and `updateLead` is a
full-replace PUT (partial updates must read first). Spike first: does
LeadRat expose lead lookup by phone? If not, identity must key off a
stored crm id with a documented first-contact path.

**Stage C — CRM actions (LeadRat), smart handling.** Actions: **Save the
lead** · **Update lead fields** (name, budget, …) · **Append a note** ·
(status change if the LeadRat API supports it — verify, don't assume).
Contract (operator order 2026-07-24, "flagged and not silently dropped"):
- Identity = phone-keyed lookup against LeadRat (read-back verified; the
  lead id stores on the contact once known — self-rectifying rule).
- **Create when it already exists → upsert**: update fields + append a
  "re-entered flow <name>" note; run log says "already in LeadRat —
  updated, not duplicated". Never a second row.
- **Update/append when no lead exists → per-action fallback**, seeded to
  "create the lead first, then apply"; alternative "skip and flag". Both
  leave a run-log entry; skip-and-flag also raises a Needs-action item.
- **LeadRat API failure = data**: outcome recorded on the run row +
  Needs-action item. Nothing silent, ever.
Oracle: adapter tests for all four cases (exists/create, exists/update,
missing/update-with-create-fallback, API-fail) + one live read-back against
LeadRat in shadow.

**Stage D — triggers.** Channel-tab picker (Instagram · Facebook ·
Contact events · WhatsApp — WhatsApp REAL, config-editable; sends gated on
the number). IG: post/reel comment, DM (6 match modes + AI intent — "is
about" switches panels, their behavior), story reply (scope/filter/delay/
auto-❤️), live comment. FB: post comment (+ their 2 extra toggles),
Messenger message (both sub-modes; receive itself is channels-scope-4 —
this stage only stores the config). Contact events: the mapped vocabulary.
AI-intent rides the existing LLM pipeline. Oracle: trigger-match unit
tests per mode incl. AND/OR condition rows; an AI-intent golden.

**Stage E — the canvas UI.** Published (read-only + banner + per-node
stats + funnel panel) vs draft (autosave, undo/redo, publish with
replay-gate/typed-confirm conventions). Node palette, "choose next step"
superset rule (Start-flow/Select-existing only mid-flow). Flows home:
folders, ManyChat's exact table columns, state pills, + New flow entry
menu. Runs tab = existing engine run log; Rules UI retires into it,
seeded rules migrate. Oracle: web tests + published/draft e2e; tokens law;
0 raw controls (primitives below).

**Stage F — seeding + migration.** Operator's real flows seeded (breakdown
§4) with exact keywords, stale event texts kept as editable examples; the
9-field schema + tags; existing automation Rules migrated to flows;
ManyChat-side pausing stays HIS click, prompted by the live-flip reminder.
Oracle: seeded flows render + dry-run in shadow; rules-migration
inventory table closed.

## Canvas UI primitives (ordered 2026-07-24 — spec'd here, built Stage E)

All on `design/tokens.md`; no new colors, no new type sizes. These are the
reusable pieces; if any needs a token the sheet lacks, that's an operator
round, not an invention:
- **cm-canvas** — the pannable/zoomable surface; 22px dot grid on `bg`;
  never scrolls the page (own overflow).
- **cm-node** — 240px card: `surface`, 1px `line-strong`, radius 6, shadow
  sm; header row (type swatch + name, 12px/600); optional stats strip
  (10px, `info` numbers); body 12px `ink-2`.
- **cm-port** — one output row per branch/button: label + 10px dot
  (`accent-soft` fill, 2px `accent-ink` ring). Every wire leaves a port.
- **cm-wire** — 2px `line-strong` bezier; selected: `accent`.
- **cm-node-swatch** — type color chips: trigger #0E7490 · message
  `accent` · condition `warn` · actions `ok` · start-flow #9333EA · AI
  agent #DB2777 (round-4 palette; the only place these hexes live).
- **cm-panel** — the right-side config panel: `surface`, 1px
  `line-strong`, radius 6, shadow lg, max 400px; field labels =
  small-caps 11px pattern.
- **cm-chip-input** — keyword chips with ✕ + dashed "+ add" chip (trigger
  keywords, reply lists).
- **cm-select / cm-input / cm-field** — the styled form controls (Input
  spec from tokens.md: `line-strong` border, radius 4, hover `accent`
  border, 2px `accent-ink` focus ring). Built HERE, then swept app-wide
  (appshell backlog item, same 2026-07-24 order — Settings first).
- **cm-modal-tabs** — the channel-tab modal (trigger picker): tabbar with
  `accent` underline, greyed tabs carry their reason inline.

**Stage-A gate: PASSED (orchestrator, 2026-07-24).** Executor report +
own oracles (34 funnel/agent tests green in own run; prod at `f53f4f9`,
health `{ok,db,redis}`, 5 funnel tables in prod DB) + independent
read-only review: NO MAJORS. Review follow-ups pinned to later stages:
- Stage B/E: graph JSON has NO runtime zod validation on the engine read
  path (`funnel-engine.service.ts:76,124` trust-casts) and no write path
  exists yet — the graph-editing API MUST `.parse()`/`validateFunnelGraph`
  on every write; add the engine-side parse when the first writer lands.
- Stage D: comment triggers wire-in must keep moderation FIRST (invariant
  test: moderation precedes flow trigger claim on comments) and cover the
  agent-never-replies-to-a-flow-claimed-comment half of the contract —
  today's guard covers DMs only (WhatsApp + IG DM entry points), which is
  every currently-wired entry.
- Cosmetic: dead defensive `if (this.funnels)` guard on a required ctor
  param (`agent.service.ts:178`) — clean up in passing.

**Stage-B gate: PASSED (orchestrator, 2026-07-24).** Landed `160c5a8` +
review-fix `42e5ea9`, deployed, health green; own oracles: funnel api
tests 47/47, worker 60/60, prod checkout verified. Independent review
found 4 MAJORs — ALL fixed same session (stale-node crash → `stale_node`
run event; smart-delay inbound black hole → `inbound_during_delay`
logged; dead `timeoutMinutes` → real question-timeout jobs with
reply-invalidation; actions-node gate bypass → off=skip/shadow=
`would_execute` log/live=execute) + minor (zod on internal controller).
Reviewer-confirmed clean: single gated send site (shadow never sends,
live is WhatsApp-only today), claim-first idempotency, window re-check
on resume, no worker double-attach. **Pins:**
- Stage E: SSRF guard on dynamic-block/external-request URLs before any
  graph-WRITE path ships (https-only zod exists; no internal/link-local
  guard).
- Note: handover-resume race (sweep flips `agent_handover`→`active` with
  no lock vs an in-flight agent turn) — narrow cron-cadence window,
  revisit if handover goes high-traffic.
- **IG button spike RE-PINNED from "before Stage B closes" to Stage D**:
  live sends are WhatsApp-only in Stage B (IG/FB no-op
  `skipped_unsupported_channel`), so button rendering on IG is moot
  until IG send enablement — the spike needs live Meta calls anyway.
- **Operator fork (boarded):** should the AI agent answer an inbound
  that arrives mid-Smart-Delay? Today: logged + ignored (no-conflict
  law); alternative: agent answers while the flow keeps waiting.

**Stage G — Automation-section retirement (operator ruling 2026-07-24).**
Scope-5's end-state: the Automation section = Flows · Sequences · Runs.
The n8n-registry Flow screen/model and the Rules UI are OBSOLETE at
cutover — retire behind redirects, additive-first; the `FunnelFlow*`
prefix may then take the plain `Flow` name (optional housekeeping). n8n
stays integrated-not-owned: only the registry UI dies.

## Out of scope (this package)

FB Messenger real receive (channels-scope-4) · Sequences (fast-follow
package) · Basics (maybe never) · Ref URL/QR/Ads triggers (boarded) ·
subscriber export (Stage 2b, dead pending operator) · TikTok.

## Open forks for the operator (only if they bite)

- LeadRat status-change action: only if the API exposes it cleanly —
  verified in Stage C, dropped with a note if not.
- IG button-support spike (scout ⚠ CONFLICT below) still decides whether
  buttons render tappable or as link lines. One-shot spike before Stage B
  closes.

## Design round — round 1 (2026-07-23)

**Mockups published** (artifact "Funnel builder — round 1 mockups", sent to
the operator): funnel list inside the existing trigger module · editor as a
straight step list (Message/buttons · Question-saves-field · Save the lead;
no canvas, no branching) · live-flip confirm with the pause-ManyChat
reminder · shadow via the existing Activity approval path. Each mock has a
BINDING vs DEMO sheet. 4 open questions to the operator: shadow mid-funnel
approval scope · "Edit first" on drafts · link-ending funnels · off-script
replies (rec: agent answers, funnel re-asks once).

**Red-teamed (Opus seat, web research, 2026-07-23).** Verdict TRIM was
OVERRULED in part: its core claim (Meta won't deliver steps 2..N) is wrong
for a reply-driven machine — every outbound after the opener follows a user
reply, which is exactly how ManyChat runs these funnels on the same API.
**Hard constraints adopted as BINDING:**
- ONE private reply per comment, ever, within 7 days — the opener is the
  only unprompted message; later steps are reply-driven, never scheduled.
- Buttons XOR quick-replies in one message; quick-replies ≤13 × 20 chars.
- 1 automated DM per user per 24h from comment triggers; 200 automated
  DMs/hr/account (2026 caps). HUMAN_AGENT tag is human-only.
- Promotional content only inside the 24h window; name/phone capture needs
  consent/opt-out language → seeded consent line on data-saving Questions.
- Mid-funnel per-user state must persist across days, expire on window
  close, resume on re-entry — the real engineering meat.
Open design tension the red-team is right about: funnel vs AI agent
precedence mid-funnel (question 4 to the operator).

## Scout — funnel-engine wiring map (Opus seat, 2026-07-23, read-only)

Full report in the session log; the load-bearing facts:
- **The step engine ~90% exists**: `QuestionnaireSession`
  (schema.prisma:929-953: `currentStepId`, `variables`, one-active-per-
  contact) + `questionnaire-engine.service.ts` (reply-interpret → save
  field :544-556, validated_text, branch :489, `leadrat` create_lead
  :458-486). Gap: WhatsApp-bound (`waId`, WA `interactiveId`) and started
  by FlowRule/QR, not comments. Rebind, don't rebuild.
- **Per-step sender exists**: Stage 1's `sendDmOpener`
  (comment-pipeline.ts:982-1049) carries the off/shadow/live gate, review-
  item propose path, `recordBotAction`, failsafe-on-error — later steps
  ride it with `sendMessage` (in-window) instead of `sendPrivateReply`.
- **Save-the-lead exists 3 ways** (crm.adapter.ts:26, tools.ts:305,
  questionnaire-engine:463) + `lead.captured` bus emission — field mapping
  only, no new CRM work.
- **Missing**: multi-step `Trigger.payload` (zod locks it to {text,dmText},
  triggers-admin.controller.ts:20-34) · funnel cursor for IG DMs (inbound
  DMs go straight to stateless `runAgentTurn`, ig-dm.service.ts:157) ·
  reply-driven advance · any button/quick-reply send support.
- **⚠ CONFLICT to spike, not assume**: `triggers.ts:70-76` asserts IG
  private replies carry no tappable buttons (CTAs fold into text links);
  red-team's Meta docs say quick-replies/button templates ARE supported on
  IG messaging (13 × 20-char cap), and ManyChat's live SG funnel shows a
  tappable button. One-shot spike on the test account decides; mock 2's
  button chip is BINDING only if the spike passes, else buttons render as
  a link line (DEMO fallback).

# (historical) ManyChat cutover (URGENT: funnel broken)

> Kickoff: Fable, 2026-07-22 evening. Operator, verbatim: "AI commenter of
> manychat got cutoff because I took webhook from it… now comment-to-dms
> trigger doesn't work. so we need to like cutover manychat. we cannot
> share." The live comment→DM funnel is BROKEN today — this package
> restores it on our platform and retires ManyChat.

## Why urgent

Since the webhook moved to our app, commenters no longer receive ManyChat's
DM follow-up. Interim relief (no build): comment reply drafts already land
in Activity Needs-action (shadow propose-and-approve) with the DM-CTA
wording — the operator can approve public replies today. The DM opener is
the missing half.

## Stage 1 — Comment → DM opener (private replies) [DISPATCHED NOW]

Meta "private replies": send ONE DM to a commenter in response to their
comment (window-limited; the IG variant rides the Messenger Send API with
a comment-id recipient — seat verifies the exact endpoint + window against
current Meta docs; token already carries instagram_manage_messages +
pages_messaging, debug_token-verified 2026-07-22).

Build:
- A comment-to-DM trigger leg in the comment pipeline: when a comment
  matches (keyword rule, or "any comment" rule), send the seeded DM opener
  via private reply. Rules + opener text = DATA (seeded defaults, operator
  editable — the existing triggers/situations mechanisms first; never a
  parallel second trigger system without checking what exists).
- New gate `instagram:comment_to_dm`, ships OFF; shadow = propose-and-
  approve (a waiting approve item with the draft opener, consistent with
  DECISIONS 2026-07-22 shadow semantics); live = auto-send.
- Failed sends = data (review item), never silent; claim-first idempotent
  so a retry can't double-DM (IG retry precedent, DECISIONS 2026-07-15).
- FB comments: same leg works for FB private replies if the API allows —
  verify; if yes, gate `facebook:comment_to_dm` ships off too.

Bars: invariant test no-send while off; shadow creates approve item, never
sends; unit+e2e green; goldens untouched (this is pipeline, not prompt);
review (step 3) before gate; deploy+health. The LIVE witnessed proof (a
real DM opener landing) is the operator's cutover-day step.

## Stage 2 — ManyChat rule inventory [API EXPORT DONE 2026-07-22 — texts pending]

API spike run with the operator's key (Fable, live): ManyChat page
confirmed (Silver Oak Properties, 109939868479359, pro, Asia/Dubai).

**Comment triggers (growth tools, type feed_comment_trigger):**
- "Post or Reel Comments #14" (55193269)
- "Post or Reel Comments #14 copy" (55748814)
- "Post or Reel Comments #17 copy" (56090391)

**Flows (25 total; the funnel-relevant ones):**
- Comments-to-DMs folder: Comment "INVEST" · Keyword "SG" ·
  Keyword "TORONTO" · Keyword "INVITE"
- DMs-automation folder: DM Keyword trigger Job-seeker
- client interaction folder: facebook - Default Reply · Instagram - Job
  Seeker interaction · facebook - client interaction · Client Collector ·
  Job Seeker Collector · Client Follow-up #1 · Default Reply · WIP
- functions folder: facebook/instagram - Validate Phone Number
- Tags: Job-seeker Flow Completion · Client Flow Completion · Real Estate
  Professional

**Keywords recovered from names: INVEST · SG · TORONTO · INVITE (+ a
job-seeker DM keyword).**

**Still missing (dashboard-only, operator):** the message TEXT inside each
funnel flow and the exact trigger→flow wiring (which posts each comment
tool watches, exact keyword match settings). Operator screenshots of the
Comments-to-DMs flows + the two collector flows close this.

### FLOW CONTENT — read directly from the dashboard (Fable, browser session, 2026-07-22)

**Keyword "SG" (LIVE)** — trigger: comment contains `SG`, any post/reel:
1. DM: "You're officially invited to our exclusive Dubai Property Expo 2026
   in Singapore on 11th & 12th July at Mandarin Oriental, Singapore. 🏢"
   [button: I'm interested]
2. On click: "Your spot is just one step away! 🌟 Complete your
   registration to secure your place at the Dubai Property Expo 2026 in
   Singapore." [button: Register →
   https://events.silveroakglobal.com/singapore?dl=953d64db]

**Keyword "TORONTO" (LIVE)** — trigger: comment contains any of
`TORONTO TORANTO TORRONTO TORONOTO TORONTON TRONOTO TOROTO TRONTO`:
1. DM: "You're officially invited to our exclusive Dubai Property Expo
   2026 in Toronto on 4–5 July at Hyatt Place Toronto/Mississauga
   Centre. 🏢" [I'm interested]
2. On click: "Your spot is just one step away! 🌟 Complete your
   registration…in Toronto." [Register →
   https://events.silveroakglobal.com/toronto?dl=d05… (tail truncated;
   same pattern as SG)]

**Keyword "INVITE" (LIVE, 3 runs, 3 phones captured)** — trigger: comment
contains `INVITE`:
1. DM: "You're officially invited to our exclusive Dubai Property Expo on
   13–14 June at Holiday Inn, Business Bay." [I'm interested]
2. Ask: "To confirm your spot, what name should I put on the invitation?"
   (pause, save reply → Full Name)
3. "Great, thanks {Full Name}! 😊 Just need your phone number to confirm
   your invitation!" (pause, save reply)
4. Start sub-flow: Instagram - Validate Phone Number
5. **POST client data → External Request
   https://n8n.silveroakglobal.ae/webhook/instagram-client-contact-us**
   (the lead handoff contract — n8n owns the downstream)
6. "Perfect! See you at Dubai Property Expo on 13-14 at Holiday Inn,
   Business Bay! 🌟🏢"

**Comment "INVEST" (STOPPED, 0 runs ever)** — draft with follower-check
branching, opener "Hi there! 😊 Thanks for reaching out to us!"; never
live, deliberately NOT migrated (operator can ask later).

**Not crawled (functionally replaced by our agent, ruled not needed for
cutover):** the DM interaction flows (client/job-seeker collectors,
default reply, follow-up) — our situations engine + CRM capture replaces
scripted DM flows; the phone-validate sub-flow's exact retry wording.
Fetchable on demand — the browser route is proven.

**Migration note:** events dates in the openers are STALE (June/July
events, already past) — seed the MECHANISM with these as examples; the
operator updates the texts in the UI (they're operator-editable data).

**Research verdict (cited, 2026-07-22): no programmatic export exists.**
ManyChat's API is runtime-only (send/subscriber ops): no keyword-rule
endpoint, growth tools list names only, getFlows returns no content, no
JSON flow export (open unresolved feature requests on their board).
Manual read-off from the dashboard is the only path for content — a hard
constraint, not a to-do.

**Stage 2b (optional, valuable): subscriber export.** The API DOES expose
subscribers with tags + custom fields (the collectors validated phone
numbers). Before disconnect, export ManyChat subscribers → import as
Contacts (channel-tagged, deduped against existing). Operator to approve.

## Stage 3 — Cutover flips [OPERATOR, at his moment]

1. Instagram message access: ManyChat → our app (IG app settings toggle).
2. Gates at his pace: comment_to_dm shadow→live, instagram:dms shadow→live,
   comments reply shadow→live.
3. Disconnect ManyChat.
Each step reversible until ManyChat disconnects.

## Out of scope

FB Messenger receive arm (channels-scope-4, queued behind this), WhatsApp.

## Stage log

- 2026-07-22: kickoff written; Stage-1 seat dispatched.

### Stage C — PRE-SPIKE ANSWERED + BUILT (executor, 2026-07-25)

**PRE-SPIKE: LeadRat has NO lead lookup by phone, and no search at all.**
Read-only probes against the live API (auth POST + GETs only; nothing was
created, updated or deleted). `GET /api/v1/lead?PageNumber=1&PageSize=N`
works (26588 leads) but **every** candidate filter parameter is *silently
ignored* — `SearchText`, `searchText`, `Search`, `search`, `SearchValue`,
`SearchKey`, `ContactNo`, `contactNo`, `PhoneNumber`, `Phone`, `MobileNo`,
`Mobile`, `Query`, `q`, `Filter`, `FilterText`, `Name`, and the paired
`SearchBy`/`SearchType`/`searchColumn`/`Filters`/`filter` forms all returned
HTTP 200 with the **unfiltered** first page and the full `totalCount=26588`
(proof: probed with a real page-1 lead's own `contactNo` as the needle —
`firstIsSeed=true` on every one, i.e. page 1 unchanged). `Keyword` returns
`total=0`. `GET /api/v1/lead/phone/{n}`, `/lead/search`, `/lead/duplicate`,
`/lead/exists`, `/lead/by-contact/{n}`, `/lead/contact/{n}`,
`/lead/mobile/{n}` → **404**. No swagger/openapi at
`/swagger/v1/swagger.json`, `/swagger/index.html`, `/openapi.json` (404).
So the kickoff's pre-answered fork stands: **identity keys off a stored crm
id** (`Contact.crmLeadId`, additive migration) with this first-contact path
— the first CRM action for a contact creates the lead and records its id;
every later action reuses it, which is what makes a second "save the lead"
an upsert instead of a duplicate.
- Live read-back half of the oracle: `GET /api/v1/lead/{id}` returns the
  full lead object (HTTP 200, verified on the Phase-0 test lead) — the read
  path the full-replace update depends on is proven live.
- **Status change: NOT built, needs one operator-consented write.** The
  vocabulary IS readable (`GET /api/v1/lead/status` → 26 statuses, 2-level
  parent/child: DND · Open · RSVP · Close · In Progress · Booking Cancel ·
  Successful/Deal Done · New · Pending · Invoiced, each with `childTypes`)
  and a lead carries `status: {id, level, status, displayName, childType}`.
  But the only candidate write path is the **full-replace**
  `PUT /api/v1/lead/{id}`, and the payload shape for `status` cannot be
  verified without a live write to the operator's real CRM. Dropped with
  this note per the kickoff's own rule; one write on a disposable test lead
  closes it if the operator consents.
- **DISCLOSED RISK (operator):** a contact who is ALREADY a lead in LeadRat
  from another source (26588 existing leads) cannot be recognised — their
  first `save_lead` creates a NEW lead. LeadRat's own lead object carries
  `duplicateLeadVersion`, `parentLeadId`, `childLeadsCount`, so its
  server-side duplicate machinery may absorb it as a duplicate version
  rather than a clean second row — unverifiable without a live write.

**Built** (all gate-off: every flow is still `state=off`, nothing seeded):
- Migrations, additive: `20260725120000_review_kind_crm_action` (enum value,
  own transaction) + `20260725120100_contact_crm_lead_id`
  (`contacts.crm_lead_id` + index).
- `packages/shared`: the `leadrat` action grew from a typed slot into the
  real shape — `operation` · `fields` (`{{field}}` inserts) · `note` ·
  `missingLeadFallback` (**seeded** `create_then_apply`, alt
  `skip_and_flag`) · `reentryNote` (**seeded** `re-entered flow {{flow}}`).
  Both operator-editable per action — data, not constants.
  `validateFunnelGraph` now rejects an `append_note` with no note text (zod
  can't: a superRefine inside a discriminatedUnion member is illegal).
  `computeOutcome` maps a `crm_action` review to **flagged** (it read as
  "skipped" through the unmapped fallback).
- `apps/api/src/funnel-flows/funnel-crm.service.ts` — the adapter. Four
  cases: exists+save ⇒ `crm_upserted` (full-replace update + re-entry note,
  run-log detail "already in LeadRat — updated, not duplicated") · exists+
  update/note ⇒ `crm_updated`/`crm_note_appended` · missing+save ⇒
  `crm_created` · missing+update/note ⇒ `crm_created_then_applied` or
  `crm_skipped_no_lead` (+ Needs-action) ·
  any LeadRat error ⇒ `crm_failed` (+ Needs-action). Plus
  `crm_no_phone` for a contact LeadRat cannot hold a lead for (an IG
  contact with no number) — flagged, never a junk row. Nothing throws.
- Every update **reads the lead first** and re-supplies `name`/`contactNo`
  (the full-replace trap); a failed read means **no write**, so a transient
  error can never blank a real lead. The **lost-update race** in both
  read-then-writes is disclosed in a `ponytail:` comment on the service.
- Engine: the `deferred_stage_c` branch is gone — `leadrat` actions run
  through the adapter and their outcome lands on the run row. `off` touches
  the CRM not at all; `shadow` calls `FunnelCrmService.preview`, which
  resolves the payload from the contact and makes **zero** LeadRat calls.
- Needs-action = a waiting `review_items` row, new kind `crm_action`:
  resolvable (accept = "Mark handled", reject = "Won't do", both pure
  records — no send, no CRM write), own feed kind `crm` + severity 1, and a
  minimal Activity affordance so the badge can actually drain.

**Oracles (own run, this session):** `apps/api` 142 files / **1583** tests ·
`packages/shared` 97 / **1075** · `apps/web` 83 / **697** · `apps/worker` 18
/ **60**; `pnpm typecheck` 7/7, `pnpm lint` 0 errors, `pnpm build` 5/5. New:
`funnel-crm.service.test.ts` (17 — all four contract cases, read-first,
no-phone, thrown-error, best-effort flag, shadow preview), 4 engine tests
(execute/failure/shadow-preview/off), the Stage-B e2e extended with a real
`leadrat` action in shadow, 3 graph tests, 3 review-decide tests, 1 outcome
test, 1 web Activity test. Oracle proven to bite by mutation (dropping the
read-first turned 2 tests red).

### Stage C — REVIEW FIXES (FIX seat, 2026-07-25)

Independent review of `0be53c4..9c005d9` found 4 MAJORs + 5 MINORs. All fixed;
every fix carries a test proven to bite by mutation (mutation applied → red →
reverted). Nothing was written to LeadRat: read-only reasoning only.

| # | Fix | Where | Mutation that proved the test bites |
|---|---|---|---|
| M1 | ONE home for a contact's lead id: the `crm_lead_id` COLUMN. Additive backfill of `attributes->>'crmLeadId'`, agent adapter reads the column (legacy read-fallback) and writes only the column; the questionnaire step records + reads it too | `20260725130100_backfill…`, `contact-store.adapter.ts:34,73`, `questionnaire-engine.service.ts:465` | drop the column read → 2 red · restore the JSONB write → 1 red · drop the questionnaire's column write → 1 red · drop the backfill's `crm_lead_id IS NULL` guard → clobbers a set column (pglite) |
| M1+ | **Found while fixing M1, not in the review:** a FIFTH reader. `run-turn.ts` built the prompt's "RETURNING CONTACT" module from `attributes.crmLeadId`, and `loadContact` never selected the column — so removing the JSONB writer would have silently made every new lead read as a stranger, and a FLOW-created lead never counted at all. `ContactRecord` carries `crmLeadId` now | `ports.ts:27`, `run-turn.ts:176`, `contact-store.adapter.ts:22`, `in-memory.ts:64` | read the legacy path only → 2 red · in-memory writes JSONB again → 1 red · drop `crmLeadId` from either `select` → 1 / 2 red (this needed the stub to honour `select`; it did not, so a forgotten select passed silently — fixed too) |
| M2 | A full-replace update preserves every field it did not set: `LeadRatService.updateLeadFields` reads the lead and echoes its scalars under the caller's. `lead-full-replace.ts` documents what is echoed and what is dropped | `lead-full-replace.ts`, `leadrat.service.ts:191` | echo only name+contactNo (the old policy) → 3 red · skip the read-first → 2 red |
| M3 | Contact-derived `name`/`contactNo` seed the CREATE path only; on update the CRM's own values stand. Comment corrected; wa_id normalised to E.164 | `funnel-crm.service.ts:392` | inject on every path → 5 red |
| M4 | A `crm_action` item names somebody: `joinContext` resolves the contact through `flowSessionId` (no faked `turnId`), and the summary carries name · phone · lead id | `review-items.service.ts:319`, `funnel-crm.service.ts:330` | drop the session join → 1 red · drop the who-line → 2 red · null the web fixture's `contactName` → 1 red |
| m5 | The missing-contact branch flags like every other failure | `funnel-crm.service.ts:113` | remove the flag → 1 red |
| m6 | A stored lead id LeadRat no longer has (404 = permanent) is cleared, first-contact path taken ONCE | `funnel-crm.service.ts:126`, `leadrat.service.ts` `notFound` | remove the notFound branches → 2 red · stop flagging 404 in `getLead` → 3 red |
| m7 | Fields-saved-but-note-failed is reported as the partial success it is (`crm_updated_note_failed`), not "did not reach LeadRat" | `funnel-crm.service.ts:196` | restore the old `failed()` call → 1 red |
| m8 | Race disclosure narrowed to the truth: the mutex covers funnel sessions only — the agent during `agent_handover`, `expireStale`'s lockless resume, and a human in the LeadRat UI are all outside it | `funnel-crm.service.ts:76` | comment-only, no test can bite |
| m9 | `updateLead`'s "deferred / out of scope" docstring replaced by what the code does; it is now the raw primitive under `updateLeadFields` | `leadrat.service.ts:137` | docstring; the behaviour it points at is M2's tests |
| nits | a lead's first note is `[timestamp]`-prefixed like every later one · CRM items get their own `crm` review surface (was leaking into `list({surface:"dm"})`) · this log's outcome list gained `crm_created` | `funnel-crm.service.ts`, `20260725130000_review_surface_crm` | drop the prefix → 1 red · file it under `dm` → 1 red |

**Oracles (own run, this session):** `apps/api` 143 files / **1613** tests ·
`packages/shared` 97 / **1081** (goldens byte-identical) · `apps/web` 83 / **698**
· `apps/worker` 18 / **60**; `pnpm typecheck` 7/7, `pnpm lint` 0 errors,
`pnpm build` 5/5. The backfill SQL was run against a real Postgres engine
(pglite, throwaway) for its 8 cases including the no-clobber guard and
idempotency.

**NOT DEPLOYED by this seat.** `ssh root@channels.silveroakglobal.com:22` timed
out from this machine all session (outbound 22 works — `github.com:22` connects —
and 2200/2222/22022/2022 are closed too, so it is the VPS side), while
`https://channels.silveroakglobal.com/health` answers
`{"status":"ok","db":true,"redis":true}`. Code is pushed; prod still runs
`9c005d9`. **The deploy MUST run `prisma migrate deploy` (two new additive
migrations) BEFORE the restart** — `ReviewSurface.crm` has to exist before the
API can write a CRM Needs-action row. Everything here is gate-off anyway (every
flow `state=off`, nothing seeded), so nothing is firing in the meantime.

**STILL UNVERIFIABLE without an operator-consented live write** (unchanged from
the Stage C disclosure, now narrowed): whether LeadRat's full-replace PUT model
*requires* one of the nested fields we deliberately drop (`status`, `enquiry`,
the assigned-owner shape). If it does, the PUT answers 400 — which is data: the
outcome lands on the run row and a Needs-action item is raised, never a silent
overwrite. One write on a disposable test lead closes it, together with the
`status`-change action the stage log already left open.

### Stage D — BUILT (executor, 2026-07-25)

Triggers: the full config catalog, the matcher, AI intent, and the comment +
IG-DM wire-in. **Both Stage-A pins closed with invariant tests.**

**Shared** (`packages/shared/src/funnel-flows/`):
- `types.ts` — Stage A's placeholder `keyword` + never-matched `ai_intent`
  trigger pair is **replaced** by the real catalog: `post_comment` (IG+FB,
  post scope, randomized public replies, FB's `firstLevelOnly` +
  `publicAutoResponse`), `live_comment`, `dm` (IG/FB/**WhatsApp**),
  `story_reply` (scope · filter · their exact 5 delay values · auto-❤️),
  `contact_event`. All SIX ManyChat match modes live in ONE row union
  including `is_about`, so "is about" switches the panel instead of being a
  second trigger type — their real behaviour. Rows group AND/OR. No
  migration: nothing is seeded and no flow row exists.
- Contact events = **one vocabulary**: their 8 mapped onto engine v2 +
  our own lead events, with the custom/system field-changed collapse stated
  in `MANYCHAT_CONTACT_EVENT_MAP` rather than hidden.
- `trigger-catalog.ts` — the picker's 4 channel tabs as **data**. WhatsApp is
  a real OPEN tab with the send caveat; ref URL/QR/ads greyed with the honest
  reason; Default Reply absent (asserted by a test).
- `trigger-match.ts` — pure + sync. `is_about` verdicts are handed IN, so no
  verdict is never a match (the fail-safe). `pendingFunnelTriggerIntents` is
  the pre-pass that spends nothing when a deterministic row already decided
  the group (`any` with a hit, `all` with a miss) or the shape gate failed.
- `intent-policy.ts` — the AI-intent classifier on the existing LLM `complete`
  port. Fail-safe to no-match on every error path; a match below the
  threshold downgrades to no (same asymmetry moderation applies to `delete`).
  Threshold + per-inbound check cap are **seeded settings** (`funnelTriggers`),
  not constants. New golden `intent-policy.golden.test.ts`; **existing
  goldens byte-identical** (`git diff` on `agent/__snapshots__` empty).

**API**:
- `funnel-trigger.service.ts` — armed-trigger load (**zod-parsed +
  structurally validated, never trust-cast**; a broken flow is skipped and
  logged, never blinding the rest), lazy once-each intent resolution, claim →
  `startFromTrigger`, `trigger_matched` run-log row carrying the picked
  public reply. A **joined** session is reported as NO claim so a caller
  cannot act twice; an engine failure is no-claim, never a throw.
- `funnel-triggers.module.ts` — its own thin module (matching needs
  `LlmConfigService`, and AgentModule already imports FunnelFlowsModule, so
  folding it in would cycle). Imported by Instagram + Facebook modules.
- `comment-pipeline.ts` — ordering is now: legacy triggers → **moderation** →
  **flow claim (only on a `leave` verdict)** → reply pass (skipped when
  claimed). The claim survives a closed reply gate (a flow's state is its own
  gate) but never precedes moderation; with nothing armed every path is
  byte-identical, reply-off still short-circuits before any spend.
- `ig-dm.service.ts` — the DM claim sits after storage, before the agent turn,
  gated by the flow's state not `instagram:dms`.

**Pins:** ✔ moderation-first (4 invariant tests incl. reply-gate-off and both
live+shadow would-act verdicts) · ✔ agent-never-replies-to-a-flow-claimed
comment (6 tests) + the DM entry-point half (6 tests). **NO graph/trigger
WRITE path was introduced** (the config editor is Stage E), so the
graph-write zod pin is unchanged — and it is already satisfied on the read
paths (`parseGraph` since Stage C, `armedTriggers` now). **No trigger field
carries a URL**, so the SSRF pin does not apply to this stage.

**Oracles (own run):** `apps/api` 145 files / **1655** · `packages/shared`
101 / **1141** · `apps/web` 83 / **698** · `apps/worker` 18 / **60**;
`pnpm typecheck` 7/7, `pnpm lint` 0 errors, `pnpm build` 5/5. Ten mutations
run; each named in the executor report, including one that **survived** and
forced a stronger test (the broken-graph fixture had no `triggers` key, so a
trust-cast passed it — the fixture now carries a malformed `post_comment`
trigger and 2 tests go red without the parse).

**NOT DEPLOYED, NOT DI-VERIFIED.** `ssh root@…:22` timed out again (one
attempt); `https://channels.silveroakglobal.com/health` answers
`{"status":"ok","db":true,"redis":true}`. Stage D adds **new cross-module DI
edges** (FunnelTriggersModule → Instagram/Facebook) and CLAUDE.md's own rule
is that unit tests do not catch DI breaks — a real
`Test.createTestingModule().compile()` is impossible under vitest (esbuild
emits no `design:paramtypes`; tried, every module fails at argument 0), so the
module test asserts import edges + acyclicity only. **The deploy MUST
health-check**, and must still run `prisma migrate deploy` first for Stage C's
two migrations. Nothing fires meanwhile: every flow is `state=off` and nothing
is seeded.

**Left for the operator / later stages:** the IG button spike stays BLOCKED
(needs live Meta calls + the Stage-3 IG flip) and DM triggers cannot be
witnessed live for the same reason · FB Messenger and WhatsApp DM triggers are
stored + matchable but have **no receive call site** (FB receive is
channels-scope-4; WhatsApp inbound runs through `BasicFlowEngine`, a wider
seam left for a ruling while the number is dead) · story-reply and
live-comment triggers have no webhook ingest yet either — config + matcher
only · a comment trigger's `publicReplies` are picked and logged but **not
posted** (public replies stay the reply pass's job until Stage F migrates the
legacy `canned`/`dm_flow` rules) · the contact-event bus emission points are
not wired (Stage E/F).

### Stage D — review fixes (FIX seat, 2026-07-25)

Independent review of `97b8696..fb33a4f` found 4 MAJORs / 7 MINORs / 5 NITs.
All fixed in 14 commits (`2ac5fb9..765c3be`), each with a test proven by
mutation. What CHANGED in behaviour, beyond the bug fixes:

- **`advance()` is wired** (MAJOR 1 — it had no production call site at all, so
  every claimed thread was a black hole: flow parked on its entry node, agent
  muted behind it for `sessionTtlHours`). The single seam is
  `FunnelTriggerService.handleInbound` — mid-session ⇒ advance, else ⇒ claim —
  called from `IgDmService.processDm` and, new, `BasicFlowEngine.dispatch`
  (WhatsApp, ahead of the agent gate; `FlowsModule` now imports
  `FunnelTriggersModule`). A comment never advances a session — only DMs do; a
  comment from an owned contact is silenced, not fed to the graph.
- **A LIVE flow no longer arms on a channel with no wired sender**
  (`funnelChannelCanSend`, shared — WhatsApp only today). A live IG/FB flow is
  now inert and says so loudly instead of claiming a thread it cannot speak in.
  **This is the behaviour flip an operator would notice: park IG flows in
  `shadow` until IG send enablement lands, then add "instagram" to
  `FUNNEL_LIVE_SEND_CHANNELS`.**
- **`comments: off` + a flow armed reaching live moderation is disclosed**
  (MINOR 5) — a plain warn naming the armed flows and saying outright that
  comments can now be hidden/DELETED where they could not be before.
- **Moderation `off` still lets a flow claim** — ruled deliberate
  (orchestrator, 2026-07-25), the false "MODERATION HAS RUN" comment replaced
  with the real invariant, and the branch now has its own test.
- **New seeded setting**: `funnelTriggers.aiIntentMaxChecksPerHour` (100) — the
  aggregate ceiling the per-inbound cap never provided (500 comments = 500
  inbounds). In-process rolling bucket; trigger evaluation only runs inside
  `channels-api`, so one process is the system.
- Models screen: the `comment` role is relabelled **"Comment policy + trigger
  intent"** — it silently governed funnel AI-intent too.

**Oracles (fresh, this session):** `packages/shared` 102 files / **1146** ·
`apps/api` 145 / **1705** · `apps/web` 83 / **699** · `apps/worker` 18 / **60**
(per-package, forks pool, maxForks=2) · `pnpm typecheck` 7/7 · `pnpm lint` 0
errors (7 pre-existing web warnings) · `pnpm build` 5/5 · **every golden
byte-identical** (`git diff 5512103..HEAD -- "*.snap"` empty).

**STILL NOT DEPLOYED.** `ssh root@…:22` timed out again (ONE attempt, per the
standing instruction not to retry — repeated attempts may be what firewalled
this IP); `https://channels.silveroakglobal.com/health` answers
`{"status":"ok","db":true,"redis":true}`. 30 commits now undeployed.

#### Deploy checklist — MANDATORY, in this order

1. **`pnpm --filter @channels/shared build` FIRST.** The Stage-D deploy note
   above omitted it; CLAUDE.md requires it because api/worker import the built
   `dist/` and `packages/shared/src` changed again (`send-capability.ts`,
   `settings.ts`, `trigger-match.ts`). Stale dist = stale symbols =
   `funnelChannelCanSend is not a function` at runtime.
2. `pnpm --filter @channels/api build` (+ `@channels/worker` — untouched here,
   but Stage C's migrations are still pending).
3. `source .env` → `prisma migrate deploy` → `prisma generate` (Stage C's two
   migrations). **Stage D itself adds NO migration.**
4. **PRE-RESTART DB CHECK — do not skip:**
   `psql "$DATABASE_URL" -c 'select count(*) from funnel_flows;'` and
   `select count(*) from funnel_flow_versions;`
   Stage D replaced Stage A's `keyword`/`ai_intent` trigger kinds with the new
   catalog and shipped **no migration**, so any stored old-shape graph now
   fails `FunnelGraphSchema.parse`. If both counts are **0** (expected —
   nothing was ever seeded), there is nothing to migrate. If they are NOT 0,
   inspect `graph->'triggers'` for `kind` values outside the new catalog and
   fix or delete those versions before restarting. The blast radius is now
   bounded either way (`armedTriggers` skips a bad graph; `advance` records
   `stale_graph` and RELEASES the contact instead of throwing), but a flow that
   silently stops arming is still worth knowing about.
5. `pm2 restart channels-api channels-worker --update-env`.
6. `curl -s http://localhost:3100/health` → expect `{"status":"ok","db":true}`.
   **This is the only check that catches the new cross-module DI edge**
   (`FlowsModule → FunnelTriggersModule`, on top of Instagram/Facebook →
   FunnelTriggers): `BasicFlowEngine` gained an `@Optional()` constructor
   param, so a missing provider degrades to "no funnel on WhatsApp" rather than
   a boot failure — check the boot log for `FunnelTriggerService` resolution,
   not just the 200.
7. Web: `pnpm --filter @channels/web build` then `scripts/publish-web.sh`
   (the Models label change).

#### Identity namespace — UNRESOLVED BY CODE, must be witnessed live

Asked: do an IG **comment author id** and an IG **DM `senderId`** resolve to
the SAME `Contact` row? The code's answer:

- The two are written to the same composite key with no mapping layer between
  them: `funnel-trigger.service.ts:298` upserts on
  `(channel:"instagram", channelUserId: <comment from.id>)`;
  `ig-dm.service.ts:86` upserts on `(instagram, <messaging sender.id>)`. Those
  are the ONLY two Instagram Contact writers (`grep contact.upsert`).
- `instagram-comments.ts:124` takes `commenterId` from the comment webhook's
  `from.id`. `instagram-dms.ts:60` documents `senderId` as "the customer's
  Instagram-scoped user id (IGSID) — the contact identity".
- **The codebase itself assumes they are NOT interchangeable**:
  `instagram-client.ts:109` says the IGSID "only ever comes from a captured
  inbound DM webhook", and `sendPrivateReply` (`:131`) exists precisely because
  a DM must be opened with a `comment_id` recipient "BEFORE the commenter has
  ever DMed us" — i.e. we do not hold their IGSID at comment time. Stage D's
  claim is the first and only place that treats a comment author id as a
  contact identity.

So this cannot be settled from code, and it was not settled by observation
either (no DB, no Meta call). **If the ids differ**, a comment-claimed session
hangs on a different Contact row than the DM path resolves: the agent mute
protects nothing and the flow can never see the reply. The blast radius is
smaller after this batch (a LIVE IG flow no longer claims at all, so only a
`shadow` IG flow can reach it), but the check is real work for whoever gets a
live IG comment + a DM from the same person: compare
`select channel_user_id from contacts where channel='instagram'` against the
`from.id` in the comment webhook payload and the `sender.id` in that person's
DM. If they differ, Stage E/F needs an identity-resolution seam.

#### Residual, deliberately NOT changed (operator's call)

A **shadow** flow claiming an IG thread still mutes the agent while producing
only review items — the customer hears nothing until the operator approves each
one. That is what `shadow` means (propose-and-approve, ledgered), and
`advance()` now at least moves the session, but it is a real customer-facing
silence if a shadow flow is left armed and unattended. Worth a ruling before
Stage F seeds the real flows.

### Stage D — SECOND review fixes (FIX seat, 2026-07-25)

An independent second review of the first fix batch (`2ac5fb9..517bce9`) found
**4 MAJORs / 6 MINORs / 1 NIT** — regressions the first batch introduced by
wiring the funnel seam into the LIVE WhatsApp receive path. All fixed, each with
a test proven by mutation. Commits `41f4ae0..731efd8` (7).

**Behaviour flips an operator would notice:**

- **A STOP/START now outranks the funnel** (MAJOR 2, compliance). A mid-session
  `STOP` used to be fed to the graph as free text (saved as a Question node's
  answer — `Full Name = "STOP"`), the opt-out confirmation never went out, and
  the session stayed `active` (agent muted up to `sessionTtlHours`) on a contact
  we may no longer message. A `START` never recorded consent, so every later
  send was consent-rejected while the customer saw nothing. Keyword handling is
  now the FIRST thing `dispatch` does, and it RELEASES both in-progress engines
  (funnel session → `abandoned` with `released_opt_out` in the run log; legacy
  questionnaire → `abandoned`, the same word its own opt-out branch writes).
- **Turning a flow `off` now frees the threads it owns** (MAJOR 3). `off` gated
  arming but not advancing, so an owned contact's reply hit `skipped_off` and
  reported handled: no funnel message, no agent turn, no questionnaire, no
  default reply, no n8n — silence for up to 48h per claimed contact, at the exact
  moment the operator reached for the emergency stop. `advance` now treats `off`
  like an unparseable graph: record `flow_off_released`, release, report
  unhandled.
- **An in-progress legacy questionnaire beats a funnel claim** — ORCHESTRATOR
  DEFAULT, **operator-reversible** (MAJOR 4). The funnel used to pre-empt an
  active questionnaire and the customer's typed answer was simply LOST (the
  questionnaire never advanced and hung until its own sweep). Rationale: losing a
  typed answer is unrecoverable, a missed claim is merely a later claim. Reverse
  it by deleting the `hasActiveSession` guard in `BasicFlowEngine.dispatch`.
  Note the pre-existing wrinkle it does not touch: under a LIVE `whatsapp` agent
  gate the questionnaire is pre-empted by the AGENT anyway.
- **A shadow proposal can finally be approved** (MAJOR 1). `shadow` was
  documented as propose-and-approve while approve **HTTP 400'd**
  (`actOnInstagram` → "item has no linked comment") and the item bounced back to
  `waiting` — so a shadow flow claimed a thread, muted the agent, raised an item,
  and the customer heard nothing, ever. Worse: **Activity had no action block for
  `dm_opener` at all** (it rendered under the pill "Hidden comment", value
  "(no text)", no buttons), so this also fixes the LEGACY comment→DM opener,
  which had been unactionable in the UI since Stage 1. Approving now actuates
  through `FunnelEngine.deliverApprovedProposal` (the flow's own sender); a
  channel with no wired sender is an honest RECORD ("nothing is delivered either
  way"), never a 400. The row carries server-derived `flowActuation`
  (`send | record | null`) so the copy can never promise a send the engine cannot
  make.
- **A LIVE capture-only flow arms again** (MINOR 1). The first batch's guard
  inerted every live flow on a channel with no wired sender — including a graph
  that never sends (comment trigger → add_tag + LeadRat save → end), i.e. it
  silently stopped LEAD CAPTURE with only a `logger.warn`. The guard now asks the
  real question via `funnelGraphSpeaks` (shared): does a reachable node send?
- **A legacy `canned`/`dm_flow` comment rule no longer speaks over a funnel
  session** (MINOR 6). Those rules run at the top of `processComment` and RETURN,
  so they never reached the session gate: two bots in one conversation.

**Also fixed:** `entry_node_failed` releases the session (MINOR 2 — it used to
stay `active` on a node that never ran, so the next message delivered step 2
without step 1) · the hourly AI-intent bucket is spent only on a check that
actually happens, and its comment now says TUMBLING, which is what the code does
(MINOR 3) · the IG DM seam threads `interactiveId` (from
`message.quick_reply.payload`, parsed in shared) and `hasImage` (MINOR 5 — a tap
or image question could never validate, so it burned its `retryLimit` and took
the timeout edge) · dead `hasArmedTrigger` deleted (NIT).

**New seeded setting:** `funnelTriggers.aiIntentTimeoutMs` (6000, clamped
1000–25000) + a per-call `timeoutMs` on `LlmClient` (MINOR 4). The intent check
runs ON the inbound webhook and used to inherit the LLM connection's 60s chat
default: 4 serial checks = up to 240s, while the worker aborts
`/internal/webhooks/process` at 30s and the BullMQ retry short-circuits on the
`wamid` dedup. Worst case is now 24s.

#### Operator-facing: `FUNNEL_LIVE_SEND_CHANNELS` is CODE, not a setting

`packages/shared/src/funnel-flows/send-capability.ts` holds the list of channels
the funnel engine can send on (`["whatsapp"]` today), and it now decides whether
a LIVE flow **arms at all** on a channel. Adding `"instagram"` (or
`"facebook"`) is a **code change plus a shared rebuild plus an api restart** —
not a Settings toggle — and it must not be added before that channel's funnel
sender actually exists in `FunnelEngine.gatedSend`. Until then: park those flows
in `shadow`, where the operator's approval is the actuator (and now works).

#### Boarded for Stage F, with the reason

**Legacy comment rules still run ahead of MODERATION.** `matchTrigger` →
`handleTrigger` returns before the moderation pass, so a `canned` reply can post
before any verdict exists. That ordering **predates Stage D entirely** (it is not
a funnel regression), changing it changes long-standing behaviour on a LIVE
channel, and Stage F retires these rules into flows anyway — so it is boarded,
not silently fixed. The funnel-session half of the same hole IS closed (above).

**Oracles (fresh, this session):** `apps/api` 145 files / **1734** ·
`packages/shared` 102 / **1158** · `apps/web` 83 / **701** · `apps/worker` 18 /
**60** (per-package, forks pool, maxForks=2) · `pnpm typecheck` 7/7 · `pnpm lint`
0 errors (7 pre-existing web warnings) · `pnpm build` 5/5 · **every golden
byte-identical** (`git diff 517bce9..HEAD -- "*.snap"` empty).

**STILL NOT DEPLOYED — and the previous tip must not be.** `ssh root@…:22` timed
out again (ONE attempt, per the standing instruction);
`https://channels.silveroakglobal.com/health` answers
`{"status":"ok","db":true,"redis":true}`. **37 commits now undeployed.** The
Stage-D deploy checklist above still applies as written, plus: `apps/web` must be
rebuilt and published (the Activity action block), and the boot log must show
`FunnelEngine` resolving inside `ReviewModule` — that is a NEW cross-module DI
edge (`ReviewModule → FunnelFlowsModule`) that unit tests cannot catch.

**Not verified (cannot be, from here):** nothing in this batch ran against the
live DB, a real Meta call, or a booted Nest container. `ReviewItemsService` gained
a required constructor param, so a stale build would fail at boot rather than
degrade — the health-check is the oracle.

### Stage D — THIRD review fixes (FIX seat, 2026-07-25)

A third independent review found 4 MAJORs + 4 MINORs + 3 NITs. All fixed, each
with a test proved to BITE (mutate → red → restore). Eight commits.

**MAJOR 1 — the opt-out fix covered only the TEXT path.** A template quick-reply
labelled "Stop promotions" with payload `STOP` flipped consent upstream (the
inbound hook always matched both sources) while the flow seam matched
`incoming.text` only, so the label was fed to the graph as a Question answer, the
next node's send was consent-rejected, and no confirmation ever went out. The
verdict is now decided ONCE at the boundary that can see both sources
(`detectOptSignal`, `packages/shared/src/contacts/opt-out.ts`), carried on
`onInboundMessage` as `optOut`/`optIn`, and consumed by BOTH
`BasicFlowEngine.dispatch` and `QuestionnaireEngine.handleReply` (text fallback
only for a caller with no verdict). *Mutation: recompute `optOut` from
`msg.body` / from `incoming.text` → the three button-path tests go red.*

**MAJOR 2 + MINOR 1 — an opt-out missed `agent_handover`, and the sweep
resurrected the flow.** `releaseActiveSession` filtered `status: "active"`, but
`expireStale` RESUMES an expired handover — 48h after a STOP the graph woke up on
an opted-out contact and an `actions` node really ran `add_tag`/`set_field`,
POSTed an `external_request` with their data and wrote LeadRat, leaving the
session `active` again. Now: the release covers every non-terminal status; the
sweep refuses to resume an opted-out contact; and the sweep is the SELF-HEALING
pass for the best-effort release (consent is a column, the drift is detectable) —
any non-terminal session on an opted-out contact is ended with a
`released_opt_out` run event, reported as `releasedOptedOut`. *Mutations: filter
back to `active`; drop the resume refusal; make the healing pass a no-op — one
test red each.* The sweep's two `findMany` reads are now routed in the harness the
way the DB routes them; a flat mock handed each the other's rows.

**MAJOR 3 — an approved proposal blocked on consent or the 24h window bounced to
`waiting` forever.** `MessageService` applies its STRUCTURED rejections before any
Meta call, and both landed in `send_failed`, which the review path throws on. A
proposal made at 18:00 and approved at 10:00 next morning retried identically,
forever — the exact dead end the round-2 fix removed. `deliverApprovedProposal`
now returns `consent_blocked` / `window_closed` and records
`shadow_approved_undeliverable`; `actOnFlowSession` is a table of
permanently-undeliverable vs retryable outcomes and returns the plain-words WHY,
which `decide` stamps on the row (`reason: "undeliverable"`, `reasonText`).
`res.error.message` is carried, not just `title` — the operator was being shown
the bare token while `reject` had already written "24h customer-service window is
closed for … — a template message is required". *Mutations: collapse to
title-only `send_failed`; drop the two reasons from the permanence table — 3 and
2 tests red.*

**MAJOR 4 + MINOR 3 — a shadow `question` raced its own timeout.** The
`timeoutMinutes` job was armed unconditionally, including in `shadow` where
nothing was sent, so the clock ran from PROPOSE time: at T+60 the flow took the
timeout edge and raised a second item with zero customer contact, and at T+90
approving the FIRST item delivered a prompt the session had walked past
(`Budget = "Ammar Khan"`). `gatedSend` now reports which of four things it did
(`sent`/`failed`/`proposed`/`inert`); the timer is armed for a live send and
never for `shadow`/`off`; in shadow it is armed when the approval actually
delivers. `deliverApprovedProposal` refuses as data (`node_stale`) when the
session is not `active` or has moved off the node — closing MINOR 3 — and
`review-items` records that instead of bouncing. *Mutations: `if (false)` on the
stale check; arm unconditionally; drop the post-approval arm — 2, 1 and 1 red.*

**SCOPE CORRECTION worth recording.** The first cut armed the timer only on a
`sent`, which also killed it for a live send that FAILED — nobody asked for
that, and it strands the session with no escape from the node until the TTL. A
PRE-EXISTING test caught it (`schedules a one-off timeout job … when the question
is asked`, whose harness omits `contact.findUnique`, so its live send had always
been failing on "contact not found"). Narrowed to `shadow`/`off` only; that test
now passes UNCHANGED. No golden moved.

**MINOR 2 — `flowActuation: null` promised a send that recorded nothing.** `null`
meant both "not a flow item" and "flow item whose session join missed", and the
web reads `null` as the sendable case: it rendered "Message to send / Send it",
the click died on `session_gone`, and the operator got no feedback at all.
`"gone"` is now its own answer through `ReviewItemsService` → `AgentFeedService` →
`agent-api` → Activity, which shows "Flow message — the session has ended" and
the record shape. *Mutations: collapse back to `null`; stop excluding `gone` from
the sendable block — one red each.*

**MINOR 4 — `start_flow` counting as "speaks" re-created the regression one
indirection down.** A live IG capture-only flow shaped
`comment → actions(add_tag) → start_flow(save-to-CRM)` was judged to speak,
refused to arm, and lead capture stopped silently behind a `logger.warn`. **The
recursion was implemented, not the conservative alternative**:
`scanFunnelGraphReach` keeps "sends on its own" apart from "jumps into", and
`funnelFlowSpeaks(graph, resolve, visited)` follows the jumps with a visited-flow
set. An unresolvable target answers "cannot speak" — not a guess: `jumpToFlow`
records `start_flow_failed` and carries on down the caller's next edge.
`armedTriggers` passes a resolver for the target's newest published graph (its
own rule), and it costs nothing unless a jump is the only speaking candidate —
a graph with its own message/question answers first. `funnelGraphSpeaks` stays as
the conservative answer for a caller with no resolver, so **its existing tests
are unchanged**. *Mutation: make `funnelFlowSpeaks` assume a jump speaks — 3
shared + 1 api tests red.*

**NITs.** (1) `funnel-trigger.service.test.ts` title now says the guard is
channel AND speaks. (2) The re-open after a failed send clears `editedText` too —
it kept the operator's wording while the UI re-rendered `draftText`, so a retry
silently sent the original; DB and UI now agree on what a retry will send
(*mutation: leave `editedText` → red*). (3) The misaligned `flowActuation: null`
in `Activity.test.tsx` is aligned.

Also strengthened a PRE-EXISTING test whose harness excluded its own title:
`QuestionnaireEngine` "ends the session on an opt-out keyword" left
`questionnaire.findUnique` unmocked, and the "questionnaire is gone" branch
writes `abandoned` too, so it passed whether the opt-out branch fired or not.

**Oracles (fresh, this session):** `packages/shared` 102 files / 1167 tests ·
`apps/api` 145 / 1756 · `apps/web` 83 / 702 · `apps/worker` 18 / 60 — all green,
forks pool, `maxForks=2`. `pnpm typecheck` 7/7 · `pnpm lint` 0 errors (7
pre-existing web warnings) · `pnpm build` 5/5 · `apps/web` built. **Every golden
byte-identical** (`git diff 2f142dc..HEAD -- "*.snap"` empty).

Two commits in this batch exist only because `pnpm typecheck` caught what the
per-package tests could not: widening `flowActuation` and the sweep's response
shape crossed package boundaries (`@channels/worker`'s response cast,
`AgentFeedService`'s re-exported union).

**STILL NOT DEPLOYED.** `ssh root@…:22` timed out again (ONE attempt, per the
standing instruction); `https://channels.silveroakglobal.com/health` answers
`{"status":"ok","db":true,"redis":true}`. **45 commits undeployed** (the 8 here on
top of the 37 the second-review log reported — that 37 was not re-counted). The
Stage-D deploy checklist above applies unchanged, and note that
`packages/shared/dist` must be rebuilt BEFORE api/worker: MAJOR 1 and MINOR 4
changed shared symbols (`detectOptSignal`, `funnelFlowSpeaks`,
`scanFunnelGraphReach`) that both apps import from `dist`, so a stale dist fails
at build, not at runtime.

**Not verified (cannot be, from here):** nothing in this batch ran against the
live DB, a real Meta call, or a booted Nest container. Specifically unproven:
that a real quick-reply `button` webhook carries `button.payload` where the
parser expects it (the shape is pinned by a shipped test, not by a captured live
payload); that the sweep's `contact: { consentState: "opted_out" }` relation
filter compiles to the intended SQL against the live schema; and that
`reasonText` reaches an operator's eye — it is written to the row and the API
payload, but Activity has no surface today that renders a DECIDED item's outcome,
so today the "why" is visible in the flow run log and the API log, not the queue.

## Stage A — VERIFIED (Fable, 2026-07-24)

Data model + engine core landed. **Choice log** (forks the kickoff didn't
answer, resolved conservatively per its own escalation rule):
- **Naming collision, `Flow`**: the kickoff's Stage A model list (`Flow`,
  `FlowVersion`, `FlowSession`, …) collides with the EXISTING `Flow` Prisma
  model (`flows` table — the n8n workflow registry, used by
  `apps/api/src/flows/flows.service.ts`). Resolved by prefixing every new
  model `FunnelFlow*` (`FunnelFlow`, `FunnelFlowVersion`, `FunnelFlowFolder`,
  `FunnelFlowSession`, `FunnelFlowRunEvent`) — additive, reversible, matches
  this package's own vocabulary ("funnel-engine wiring map", "funnel
  builder"). Mirrored in the shared package (`packages/shared/src/funnel-
  flows/`, distinct from the pre-existing `flows/` questionnaire step-graph
  module) and the API module (`apps/api/src/funnel-flows/`).
- **Rebind vs parallel service**: built as a PARALLEL engine (`FunnelEngine`),
  not a QuestionnaireEngine rebind. The scout's "rebind, don't rebuild" note
  was about REUSING patterns (claim-first mutex, reply-driven advance, gated
  sender), which this does — but QuestionnaireSession's shape (linear
  `currentIndex`/`next`, WA-bound) doesn't fit the node/edge/trigger graph
  Stage A's own model list specifies, and the kickoff explicitly allows this
  fallback when true rebinding is too invasive for one stage.
- **Shadow propose surface**: reused the existing `dm_opener` ReviewKind
  (surface `dm`) rather than adding a new enum value, adding two nullable
  `review_items` columns (`flow_session_id`, `flow_node_id`) instead — Stage
  B (real content blocks) is where the fuller Activity/review UX for flow
  proposals belongs.

**Built:**
- Migration `20260724120000_funnel_flows_stage_a` (5 tables, 3 enums, 2
  nullable `review_items` columns) — additive, the per-contact mutex is a
  partial-unique index on `funnel_flow_sessions(contact_id) WHERE
  status='active'` (mirrors `questionnaire_sessions`' convention).
- `@channels/shared` `funnel-flows/` — `FunnelGraphSchema` zod
  (triggers[]/nodes[]/edges[]/entryNodeId; one real node kind `message`,
  every other kind a typed passthrough stub), `validateFunnelGraph`,
  `matchesFunnelTrigger` (5 keyword modes: is/contains/whole_word/
  begins_with/not_contains; `ai_intent` is a stored, unmatched type slot).
- `apps/api/src/funnel-flows/funnel-engine.service.ts` (`FunnelEngine`):
  `hasActiveFlowSession`, `startFromTrigger` (claim-first via the partial-
  unique index — a P2002 joins the existing session instead of double-
  starting), `advance` (reply-driven only), `expireStale` (releases the
  contact). Message nodes are gated off/shadow/live (shadow → a `dm_opener`
  review item, reusing Stage-1's `sendDmOpener` convention); non-message
  nodes are typed no-op stubs that auto-advance (Stage B implements real
  executors).
- **No-conflict contract wired at the single canonical agent entry point**
  (`AgentService.handleExternalTurn`, channel-agnostic — feeds both the
  WhatsApp path via `BasicFlowEngine.handleViaAgent` and the Instagram DM
  path via `IgDmService.liveTurn`): before any LLM call, resolves the
  contact and checks `hasActiveFlowSession`; if true, the turn is traced
  `skipped` (`decisionSource: trigger`) and returns `{ text: "", skipped:
  true }` — zero LLM spend, zero send. Both delivery callbacks
  (`deliverAgentReply`, `deliverDm`) short-circuit on `turn.skipped` before
  the empty-reply fallback-apology branch, so a skipped turn is silent, not
  an apology.

**Oracles (fresh, this session):**
1. `packages/shared`: 91 files / 1026 tests green (`npx vitest run
   --pool=forks --poolOptions.forks.maxForks=2`).
2. `apps/api`: 139 files / 1513 tests green (same recipe) — includes the new
   `funnel-engine.service.test.ts` (10 tests: mutex, claim-first idempotency,
   off/shadow/live gating, reply-driven advance, flow-end release, expiry)
   and two new invariant tests in `agent.service.test.ts` (agent never calls
   the LLM/sends while a flow session is active; runs normally otherwise).
   All PRE-EXISTING tests (goldens included) pass unchanged.
3. `pnpm typecheck` (7/7 packages) + `pnpm build` (7/7) + `pnpm lint` (0
   errors, pre-existing web warnings only) all clean.
4. Migration applied on the live DB (`prisma migrate deploy`, idempotent
   re-run clean), `prisma generate`, api+worker rebuilt 0 errors, `pm2
   restart`, health green — see HANDOFF for the exact command log.

**Ships gate-off** (every `FunnelFlow.state` defaults `off`; nothing seeded
yet — Stage F seeds real flows). **Deferred to later stages, by design**:
node executors beyond the message stub (Stage B), CRM actions (Stage C),
the trigger-tab UI + AI-intent matching (Stage D), the canvas (Stage E). The
worker-side expiry sweep cron is NOT wired yet (the `expireStale` method
exists and is unit-tested; hooking it to a BullMQ repeat job is Stage B/E
housekeeping, not required by this stage's oracle list).

## Stage 1 — VERIFIED + DEPLOYED (Fable, 2026-07-22 late)

Orchestrator gate: shared 55 (goldens byte-identical) + api 115 pipeline/
invariant tests green in own runs; migration applied on prod (idempotent
re-check clean); api+worker rebuilt 0 errors, restarted, health green; web
published. Seat's internal review MAJOR (reply-off short-circuit killing
the DM leg) fixed + pinned. Real ManyChat rules seeded gate-off (SG,
TORONTO+8 variants, INVITE). n8n verdict: do NOT feed the n8n webhook (our
CRM path writes LeadRat directly — double-create risk); OPERATOR CHECK
before disconnect: does that n8n workflow fan out anywhere else?
REMAINING: operator flips (Stage 3) + witnessed first opener; operator
refreshes stale event texts in the trigger editor.

### Stage C+D — FOURTH review fixes (FIX seat, 2026-07-25)

A fourth independent review, narrowed to the shared opt-signal plumbing, the
sweep and the gated-send outcome, found **2 MAJORs + 9 MINORs + 2 NITs** (it
declared the plumbing and the outcome type themselves sound). Both MAJORs were
INHERITED defects sitting on changed lines, not regressions from the third batch.
All fixed in 11 commits (`f546e16..e9574b5`), each with a test proven to BITE
(mutate → red → restore).

| # | Fix (file:line) | Mutation that proved the test bites |
|---|---|---|
| **M1** | **The n8n-dispatch suppression had NEVER worked in production.** `webhook-processor.service.ts:148` ran `basicFlowEngine.dispatch` and returned VOID, so the `{handled}` `processWhatsAppEnvelope` reads (`inbound-processor.ts:349-360`) was always `undefined` and `flowQueue.add("dispatch")` fired anyway — the legacy n8n bot re-entered every thread the funnel owns, and a `STOP` reached it as an ordinary inbound. Now `return`ed, and asserted at the **API** level (the shared test cannot catch this class; the API harness stubbed `dispatch` to resolve `undefined`, which is why `flowQueue.add` firing was "expected") | drop the `return` → the new API test red · no other result-dropping hook exists in that file (`onTemplateCategoryChange`/`onTemplateQualityChange` return `Promise<void>` by contract) |
| **M2** | **`startFromTrigger` had no consent check**, so an opted-out contact who later texted a non-keyword still got `add_tag` / `set_field` / an `external_request` POST of their data / a LeadRat write before any sweep could heal it. One `isOptedOut` read now refuses BEFORE the session create, recorded as an `abandoned` session + a `refused_opt_out` run event (a run event needs the session FK — that is the only reason a row is written). The same guard is at **`advance`**'s entry for a consent flip mid-session: release + report UNHANDLED, exactly like `flow_off_released` | `if (false)` on each guard → 1 test red each (the entry test asserts NO tag, NO fetch, NO CRM, NO send, and no `trigger_matched`) |
| m1 | **Opt-IN is text-only again** (orchestrator ruling): `detectOptSignal` now takes `{text, payloads}` and payloads count for **opt-OUT only**. Widening opt-in reserved six words (`start`/`subscribe`/`unstop`/`resume`/`opt in`/`optin`) as unusable quick-reply payloads — a button labelled "Continue where we left off" with payload `RESUME` flipped consent, sent a confirmation, reported handled, and the Question node parked until its timeout edge | re-widen `optIn` to payloads → 2 red (shared unit + boundary) |
| m2 | **`detectOptSignal` had no tests at all** — now 7, covering both sources, the deliberate direction asymmetry, nulls, and the both-true case; the precedence (opt-OUT wins) is pinned at the CONSUMER too | reorder `dispatch` to check `optIn` first → 1 red |
| m3 | The previous seat's disclosed near-miss is pinned: a **live send that FAILS still arms** the question clock, with `messages.send` explicitly answering `{ok:false}` instead of relying on a harness that omits `contact.findUnique` | arm only on `sent` → 2 red |
| m4 | **A permanently-undeliverable approval now arms the escape clock.** Past the stale gate the session IS active on that node, so the clock is owed whatever the send answered (`window_closed`, `unsupported_channel`, `contact_gone`, `send_failed`) — it used to arm on success only, leaving `q1` with no timer and the agent muted for the whole TTL. A STALE approval still gets none | `if (!outcome.ok) return` before the arm → 2 red |
| m5 | **The release never overwrites a terminal session.** One guarded write (`markAbandoned` → `updateMany` on id + non-terminal status) behind both `releaseActiveSession` and `endForOptOut`, acting on the count: no run event for a release that did not happen, and the sweep reports what it really ended | drop the status predicate + always return true → 6 red |
| m6 | **One bad row no longer aborts the sweep.** Per-row try/catch in the handover loop + a `handover_resume_failed` run event; a deleted version row is an explicit throw instead of `undefined.graph` | rethrow inside the catch → 1 red (the healthy row stops resuming and the count is lost) |
| m7 | Both sweep stubs still returned the pre-healing 2-field shape, so **`releasedOptedOut` was unverified end to end** and the worker would have logged `undefined` | drop `releasedOptedOut` from the worker log line → 1 red |
| m8 | `contact_gone` added to `UNDELIVERABLE_COPY` — it threw, bounced the item to `waiting`, and the operator retried it forever | remove the line → 1 red |
| m9 | **A funnel timer can be re-armed.** BullMQ drops an `add` whose jobId exists and `removeOnFail:false` keeps a failed id forever, so a retry re-prompt kept the ORIGINAL clock, a second approval was dropped, a graph looping back to a question fired a stale timeout early, and an errored job locked that session+node out for good. Both jobs now **remove-then-add** (dedupe intact, latest arming wins). The scheduler is split out of the Nest provider (`makeFunnelDelayScheduler`) so it has tests at all — it had none | delete the `remove` → 3 red |
| nit | **A rejected message send takes the node's `error` edge** instead of pausing on a message the customer never received — the typed `gatedSend` outcome, finally used. With NO error edge it deliberately still pauses (a reply is owed; completing would drop the rest of the graph) | ignore the outcome → 1 red · drop the no-error-edge pause → 1 red |

**Oracles (fresh, this session):** `packages/shared` 102 files / **1175** ·
`apps/api` 146 / **1777** · `apps/web` 83 / **702** · `apps/worker` 18 / **60**
(per-package, forks pool, `maxForks=2`) · `pnpm typecheck` 7/7 · `pnpm lint` 0
errors (7 pre-existing web warnings) · `pnpm build` 5/5 · **every golden
byte-identical** (`git diff 4155032..HEAD -- "*.snap"` empty).

#### Boarded, deliberately NOT fixed here (with the reason)

- **The IG DM path has NO opt-keyword handling at all** (`ig-dm.service.ts:151-181`).
  `detectOptSignal` is channel-agnostic but only the WhatsApp inbound boundary
  calls it, so an Instagram DM saying "STOP" neither flips consent nor releases
  the funnel session: the contact stays as they were, the flow keeps its thread,
  and the only thing stopping a send is that IG has no wired funnel sender yet.
  That is a **parity gap, not a regression** (it predates every funnel stage), and
  closing it means deciding what an IG opt-out means for consent state, what
  confirmation goes back, and on which surface — an operator/Stage-E question, not
  a fix-round change.
- The `funnel-delay` **resume** job got m9's remove-then-add too (same defect one
  id along: a failed resume job would block a later Smart Delay in the same
  session). Disclosed here because it was not in the review.

#### CONSOLIDATED DEPLOY CHECKLIST — Stage C + Stage D (supersedes the Stage-D list)

Nothing from Stage C or D has ever been deployed: prod still runs `9c005d9`, and
the backlog is whatever `git rev-list --count 9c005d9..HEAD` says (**65 code
commits** when this entry was written). Run in this order, on `root@channels.silveroakglobal.com` (build as `sopserver1`):

1. `git pull` on the VPS checkout (branch `main` since 2026-07-29).
2. **`pnpm --filter @channels/shared build` FIRST.** api/worker import the built
   `dist/`; `packages/shared/src` changed again this round (`contacts/opt-out.ts`
   — `detectOptSignal`'s SIGNATURE changed — and `whatsapp/inbound-processor.ts`),
   so a stale dist fails at BUILD, not at runtime.
3. `pnpm --filter @channels/api build` **and** `pnpm --filter @channels/worker build`.
4. `source .env` → `npx prisma migrate deploy` → `npx prisma generate`
   (**Stage C's migrations**: `20260725120000_review_kind_crm_action`,
   `20260725120100_contact_crm_lead_id`, plus `20260725130000_review_surface_crm`
   and `20260725130100_backfill…` from the Stage-C fix batch. `ReviewSurface.crm`
   must exist before the API can write a CRM Needs-action row. **Stage D and this
   round add NO migration.**)
5. **PRE-RESTART DB CHECK — do not skip:**
   `psql "$DATABASE_URL" -c 'select count(*) from funnel_flows;'` and the same for
   `funnel_flow_versions` — **both must be 0** (nothing was ever seeded). Stage D
   replaced Stage A's `keyword`/`ai_intent` trigger kinds with the new catalog and
   shipped no migration, so a stored old-shape graph now fails
   `FunnelGraphSchema.parse`. If they are NOT 0, inspect `graph->'triggers'` for
   `kind` values outside the new catalog and fix or delete those versions before
   restarting. The blast radius is bounded either way (`armedTriggers` skips a bad
   graph; `advance` records `stale_graph` and RELEASES the contact) but a flow that
   silently stops arming is still worth knowing about.
6. `pm2 restart channels-api channels-worker --update-env`.
7. `curl -s http://localhost:3100/health` → expect `{"status":"ok","db":true}`.
8. **Read the boot log, not just the 200** — unit tests cannot catch a
   cross-module DI break. Confirm resolution of: `FunnelTriggerService` inside
   `FlowsModule` (`BasicFlowEngine`'s `@Optional()` param degrades silently to "no
   funnel on WhatsApp" instead of failing boot), `FunnelTriggersModule` inside the
   Instagram + Facebook modules, and `FunnelEngine` inside `ReviewModule`.
9. Web: `pnpm --filter @channels/web build` then `scripts/publish-web.sh` (the
   Models label + the Activity flow-action block).
10. Post-deploy sanity: `pm2 logs channels-worker --lines 50` should show
    `funnel-flow-sweep: expired=0 resumedFromHandover=0 releasedOptedOut=0` on its
    next cadence — `releasedOptedOut=undefined` means a stale worker build.

**Still NOT DEPLOYED by this seat.** `ssh root@…:22` timed out again — ONE
attempt, per the standing instruction not to loop;
`https://channels.silveroakglobal.com/health` answers
`{"status":"ok","db":true,"redis":true}`. Everything remains gate-off (every flow
`state=off`, nothing seeded), so nothing is firing meanwhile.

**Not verified (cannot be, from here):** nothing in this batch ran against the
live DB, a real Meta call, a real BullMQ/Redis queue, or a booted Nest container.
Specifically unproven: that `Queue.remove(jobId)` behaves as assumed against real
Redis for a DELAYED job (the fake asserts the call order, not BullMQ's semantics)
— worst case it throws, is swallowed, and leaves exactly today's
add-is-silently-dropped behaviour; and that the `refused_opt_out` session row +
run event render sanely in the Runs tab (Stage E owns that surface).

### Stage G — legacy n8n WhatsApp path DELETED (executor, 2026-07-25)

Operator order, verbatim: *"whatsapp is not connected yet. the old whatsapp
system through n8n simply remove. we don't want to accumulate junk throughout
the website."*

**This is MOST of Stage G, not all of it.** Stage G as boarded = retire the
n8n-registry Flow screen/model *behind redirects*. Delivered here: the registry
API + model readers + its dead web client, AND (beyond Stage G's original scope)
the whole inbound→n8n dispatch chain. Still open under Stage G:

- The **`Flow` Prisma model + `flows` table survive** — dropping them is
  destructive. The model now carries a RETIRED docstring saying nothing reads it
  and telling the next seat not to wire one. **Operator call: keep the 26 rows as
  the last inventory of the legacy n8n workflows, or drop the table later.**
- **No redirect was added** for `/api/flows*` — there was no UI behind it to
  redirect (the web client had had no screen since the Automation rebuild), so
  those routes simply 404 now. If any external caller was hitting `/api/flows`,
  it breaks; nothing in this repo did.
- The **open operator question still gates nothing here**: the
  `instagram-client-contact-us` workflow is NOT in the `flows` registry at all
  (the 26 rows are listed below), so retiring our side cannot have orphaned it.
  Whether it feeds anything besides INVITE remains his to answer.

**Two commits:** `1e6c7f1` (registry surface) and `8d53459` (dispatch chain).
Full file list + rationale in those messages.

**Live `flows` rows, read-only, before touching anything (26 total).** Exactly
ONE carried a `trigger_webhook_url`, and it is the legacy conversational entry:

| name | kind | active | trigger_webhook_url |
|---|---|---|---|
| Main Distributer | conversational | t | `https://n8n.silveroakglobal.ae/webhook/main-distributer` |

The other 25 rows (Lead Poller, the four reminder workflows, WA Send + Log, the
LeadRat/CAPI prototypes, `manychat-instagram-qualifier`, the Redis diagnostics,
…) had a NULL `trigger_webhook_url`, so `pickFlow` could never have selected
them: the deleted chain had exactly one possible destination.

**Redis, same check:** `bull:flow-dispatch` had `wait=0 active=0 failed=0
delayed=0` — no pending job was orphaned by removing the processor. Only
completed-job hashes remain; BullMQ's `removeOnComplete` will not touch them now
that nothing writes the queue, so those keys are inert leftovers (harmless; a
`DEL bull:flow-dispatch:*` would clear them if the operator ever wants to).

**Deliberately NOT deleted** (each would have broken something live):
- the **`n8n` connection** — `CallbackAuthGuard` reads its `callbackSecret` to
  authenticate EVERY `/internal/*` call the worker makes. Deleting it kills
  campaign sends, reminders, the funnel delay resume, everything.
- **`FlowWebhookService` + the flow `call_webhook` step** — DECISIONS 2026-07-23:
  External Request still POSTs to n8n webhooks wherever the operator wires them.
- **`N8nClient`** (minus `triggerWebhook`) — read/control-only now, and the only
  consumers left are the build-time scripts, one of which
  (`scripts/audit-legacy-workflows.ts`) is the tool that can answer the operator's
  open n8n fan-out question.
- keyword **FlowRules** and the **QuestionnaireSession** engine — board says
  legacy-frozen, Stage F migrates them; they are the platform's OWN engines, not
  the n8n path.
- **`scripts/{audit-legacy,inspect,migrate}-workflow.ts`** — left as-is and
  FLAGGED: `inspect`/`migrate` are Docker-epoch (`docker exec channels-postgres-1`)
  and cannot run under native PM2, and `migrate-workflow.ts` PUTs workflow JSON.
  They are dead tooling, but they sit outside the product surface the operator
  named. **Recommend deleting `inspect-workflow.ts` + `migrate-workflow.ts` in a
  follow-up; keep `audit-legacy-workflows.ts`.**

**One judgement call worth a second opinion:** `BasicFlowEngine.dispatch` still
returns `{handled}`. Its ONLY external consumer was the n8n suppression, so it is
arguably now vestigial — but inside that file it IS the precedence contract
(opt-keyword > funnel session > agent gate > questionnaire > rules > default
reply) and ~40 tests assert it. Deleting the return would have forced every one
of those to re-express itself through mock call-counts, which is strictly weaker
coverage. Kept, with the docstring rewritten to say what it means now.

**Oracles (own run, this seat):** api **141/1782** · shared **100/1168** · web
**83/702** · worker **17/55** — all green; `pnpm lint` 0 errors, `pnpm typecheck`
and `pnpm build` clean. Test-count accounting, because another seat was
committing into the same tree concurrently: my deletions removed 21 api / 9
shared / 5 worker tests and 5 api + 2 shared test FILES; the api `+5` and shared
`+2` net movements are `c09d54e` + `1a34c0f` + that seat's uncommitted
funnel-crm WIP, verified line by line. **No existing golden's expected value
moved.** Three tests that were pinning behaviour which still exists were
REPOINTED rather than deleted (audit verb branch → `/api/campaigns/:id/pause`;
postJson abort signal → `resumeFunnelFlowDelay`; webhook-processor → the inbound
reaches the native engine with its consent verdict).

### Stage C — LIVE WRITE MANDATE: the CRM contract PROVEN, and the 4th action BUILT (executor, 2026-07-25)

The operator granted a fenced LeadRat write mandate: *"Leadrat write is ok as long
as it is not destructive. if you want to test on a lead, create a lead with name
'Ammar testing' and it will be your sandbox without affecting other actual
leads."* Honoured exactly.

**SANDBOX LEAD: `bd7b4b6c-f037-413a-aabf-0d2ebef64a43`** — name `Ammar testing`,
`contactNo 971500000000`, serial `SI0000043452`. **Exactly one lead was created**
and **every write in this session targeted that id**. Other leads were only ever
READ. Nothing was deleted (LeadRat has no delete endpoint, so the sandbox is
permanent — accepted). It was left restored: status `New`, notes saying what it is
and that it is safe to archive.

The Phase-0 test lead `7d65e5db-…` still exists but is **archived**
(`isArchived:true`, `archivedOn 2026-07-15`) — the operator removed it as asked —
so it was not reused as the sandbox.

#### The write contract, now measured instead of reasoned about

`PUT /api/v1/lead/{id}` — the main lead write:

| fact | evidence |
|---|---|
| **It is a PARTIAL MERGE, not a full replace.** An omitted field is PRESERVED | set `email`+`companyName`, then PUT `{id,name,contactNo,leadStatusId}` → read-back diff was `lastModifiedOn` and nothing else |
| `null` means "no change"; **only `""` clears** a field | `email:null` → value stood; `email:""` → became `""` |
| `Name` + `ContactNo` are validation-required — a REAL 400 | `{"errors":{"Name":["The Name field is required."],"ContactNo":[…]}}`. **This 400 is what the Phase-0 spike read as "full replace"; the field-blanking it inferred does not happen** |
| **`leadStatusId` is required, and its absence is SILENT** | `200 {"succeeded":true,"message":"Lead Status Id is required.","data":"00000000-…"}` and the lead UNCHANGED |
| The status id must be a **LEAF** | childless level-0 → "Lead successfully updated"; level-0 WITH children → "Please provide child status id."; unknown guid → "The Status Id is not valid."; a level-1 child id sets parent AND child |
| The key is `leadStatusId` | `statusId`, `status:"<guid>"`, `status:{id}` are all ignored ("Lead Status Id is required"); `leadStatusId` advanced to the child complaint |
| **A GET-shaped NESTED echo is ACCEPTED and IGNORED** | PUT carrying the GET's `status`, `enquiry`, `assignedUser`, `duplicateLeadVersion`/`parentLeadId`/`childLeadsCount` → 200, nothing corrupted; a nested `status` disagreeing with `leadStatusId` LOSES |
| **A GET-shaped SCALAR echo 400s the whole payload** | `profession` comes back as the number `0`; PUT answers `$.profession: The JSON value could not be converted to System.String`. `address` is not a plain string either. One bad field fails the request |
| `rating:"Hot"` is silently ignored (stays null) | so Phase-0's "temperature has a real field" is true but **not writable as a string** |

`PUT /api/v1/lead/status/{id}` **EXISTS** (`{id, leadStatusId}` → "Lead status
successfully updated") — **and WIPES `notes` to null** unless the same body
re-supplies them. Proven twice: a note written seconds earlier was gone after one
status write; supplying `notes` in the body preserved it. **We do not use it.**

`POST /api/v1/lead` on a phone that already exists (question 3, answered with the
one create): **LeadRat's own duplicate machinery absorbs it.** The new row came
back `duplicateLeadVersion:"D1"`, `parentLeadId`/`rootId` = the pre-existing
lead, and the parent's `childLeadsCount` went 0→1 (its `lastModifiedOn` did NOT
change). Even an ARCHIVED lead counts as the parent. So a contact already among
the 26,588 does NOT get an orphan second lead and is NOT rejected — they get a
linked duplicate VERSION under the original. The disclosed risk is milder than
the Stage-C log feared.

#### What that proved WRONG in the shipped code (all latent — every flow is gate-off)

1. **Every `update_lead` would have written NOTHING and reported `crm_updated`.**
   `updateLeadFields` never sent `leadStatusId`, so LeadRat answered 200 and
   ignored the payload.
2. **HTTP 200 was being read as success.** A refusal is a 200 (and
   `succeeded:false` on a 200 is real too), so `write()`, `createLead()` and
   `reassignLead()` were blind to it.
3. **The full field echo does NOT work.** Stage C's echo would have 400'd on
   EVERY real lead (`profession: 0`). Its *choice* to drop the nested fields was
   harmless, but its *reason* was wrong — nested echoes are accepted and ignored;
   the danger was in the scalars it kept.

#### Fixed + built (3 commits)

- `1a34c0f` shared: `set_status` joins the `leadrat` action —
  `statusId` (leaf) + `statusLabel`, **no seeded default guid**;
  `validateFunnelGraph` rejects a status change that names none.
- `c09d54e` api: `leadRatRefusal` is the single place that decides whether a
  LeadRat write happened. `lead-full-replace.ts` → **`lead-update-payload.ts`**:
  the payload is now the caller's fields plus only
  `name`/`contactNo`/`leadStatusId`, the last taken from the lead's own CURRENT
  leaf status (`status.childType.id` when present, else `status.id`) so an
  ordinary field update cannot move the status. A required field it cannot
  resolve is a refusal, not a doomed write.
- `e6a497c` api: the action. known lead ⇒ `crm_status_changed` · stored id 404 ⇒
  forget + first-contact path once · no lead + `create_then_apply` ⇒ create THEN
  apply (a new lead is always "New") with `crm_created_status_failed` as the
  honest partial success · `skip_and_flag` ⇒ no write + Needs-action ·
  any refusal ⇒ `crm_failed` + Needs-action carrying LeadRat's own words ·
  `off` no CRM calls at all, `shadow` previews with zero LeadRat calls. An action
  with no status chosen never calls LeadRat and creates nothing.

**The 26-status vocabulary** (leaf ids are what `statusId` must carry). Level-0
leaves: DND `9c3852ea-d2bd-45b6-ae2c-12304c1027f6` · Booking Cancel
`b9022e98-1413-4b36-87fc-0d23bc27d976` · Successful/Deal Done
`92aaa58f-9d74-459b-8f86-ca2743a3a185` · New
`17319631-5ef5-4e16-be29-2d03557a40ce` · Pending
`60970cc8-f0ef-49e2-911c-0d70c32c3fe4` · Invoiced
`90d250d1-0872-4b66-ba2e-90fbca27e158`. Parents (NOT usable directly) with their
children: Open → Call Back `3abf6973-4577-4dce-8b65-2355ccc44e0b`, No Answer
`b406f8af-a98b-4e31-b9a7-bacd5f637a44` · RSVP → RDS Attended/Not
Interested/Confirmed/Tentative · Close → 3 · In Progress → 7 (On hold-Qualified,
Secondary, Meeting Done, Meeting Arranged, Follow up, High/Low Potential).
**Stage E's picker must READ these from `GET /api/v1/lead/status`, never hardcode
them** — hence no list in code.

**Oracles (own run, this session):** `apps/api` **141 files green** ·
`packages/shared` 100 / **1168** · `apps/worker` 17 / **55** · `pnpm typecheck`
clean (api + shared) · `pnpm lint` 0 errors (7 pre-existing web warnings) ·
`apps/api` + `packages/shared` build clean · **every golden byte-identical**
(`git diff 7831996..HEAD -- "*.snap"` empty). Mutations, each applied → red →
reverted: envelope ignores `succeeded:false` (1 red — this one SURVIVED the first
attempt because my own test carried BOTH refusal signals, so it proved only the
other branch; the test was narrowed to a real id in `data`) · envelope ignores the
zero guid (2) · leaf status ignores `childType` (3) · echo the read-back scalars
again (6) · drop the `leadStatusId` guard (1) · `changeLeadStatus` uses the
notes-wiping endpoint (2) · `set_status` falls through to the field path (4) · the
no-status guard on a known lead (1) · create-then-apply never applies the status
(2) · a failed post-create status write reported as full success (1) · the status
dropped from the run-log detail (3) · the pre-create no-status guard (SURVIVED →
a test was added: with no status AND no lead it must create nothing; now 1 red).

#### Notes for whoever deploys / reviews next

- **This branch is a SHARED tree this session.** Another seat landed
  `1e6c7f1 refactor: retire the n8n-registry Flow surface` and has more of that
  work UNCOMMITTED in the working copy (deleted `flows/dispatch.service.ts`,
  `automation/pick-flow.ts`, `worker/processors/flow-dispatch.processor.ts`;
  modified `main.ts`, the webhooks module, worker queues). My commits are
  pathspec-atomic and touch none of it, but the shared and worker test-file counts
  DROPPED (102 → 100, 18 → 17) because of their deletions, not a regression here.
  While mutating I briefly reverted a one-word comment edit of theirs in
  `funnel-engine.service.ts` and restored it.
- **Still unverified about LeadRat:** whether an omitted `assignTo` preserves a
  REAL owner (the sandbox's is the zero guid; the absent-means-no-change rule is
  verified on `email`/`companyName`, so this is inference) · what `rating`,
  `address` and `profession` actually want on the PUT (only that a plain string
  is wrong) · whether a duplicate-version lead (`D1`) behaves differently under
  update than a root lead.
