# Phase 1C — Behaviour editor, Knowledge, Playground, Model

> **Kickoff plan. Written by Fable (the senior seat), 2026-07-13.** This file
> replaces an executor-authored draft of the same date: the operating model
> reserves phase plans for the kickoff seat, the executor wrote one anyway, the
> operator caught it, and the plan was re-authored here. The Gate 1C review
> judges the phase against THIS document.
>
> **Status: awaiting operator approval.** No execution until then, and the
> operator must answer the three forks in "Operator decisions needed at
> kickoff" below — two of them gate spend.

## Gate 1B conditions — checked against the live DB, not assumed

The Gate 1B verdict (foot of `plans/rethink/phase-1-plan.md`) allowed a 1C
kickoff "once 1A/1B are live and USED", under three conditions. Checked
2026-07-13 against the production database (read-only, this session):

1. **Instagram wired — HOLDS.** Connection live, dedicated `/webhooks/instagram`
   verified end-to-end, real comments deliver (HANDOFF 2026-07-11).
2. **Shadow usage observed — FAILS in substance.** The live numbers:
   `agent_turns` 42 rows, **all Instagram, all `skipped`** (39 are model-less
   backfill seeds, 2 ran during the dead-LLM 401 era, exactly **1** is a real
   DeepSeek turn from 2026-07-13); `review_items` **0**; `triggers` **0**;
   `post_interpretations` **0**; `posts` 200 synced. The system is live but has
   effectively never been used: the LLM was dead until 2026-07-13, and review
   items are structurally impossible while the comments gate sits in `shadow`
   (shadow = decide + persist, never post, no queue items).
3. **Stage 4 paid eval approved — DISSOLVED by operator decision.** The eval
   (~$3–5) was declined and stays declined; the operator's standing rule is
   that **all evaluation is organic**. Consequences this plan encodes:
   `agentReview.selfReport` stays **OFF permanently**, the free rule floors and
   the comment policy's in-band JSON confidence are the only gates, and no
   stage below may contain an eval harness or paid benchmark.

**Kickoff verdict (Fable): conditional GO.** The letter of condition 2 does not
hold, and this plan says so rather than pretending otherwise. Blocking the
phase would not produce the missing usage — the gap is operator actions and
calendar time, not build work. So the condition is amended, on kickoff
authority: **Stage 0 below generates the usage**, the usage-hungry surface
(Model) is sequenced last so organic data exists by the time it ships, and
Gate 1C hard-requires usage evidence (see the gate bar). If the operator
prefers the strict reading — wait until usage exists before any 1C code — that
is a legitimate call; say so and Stage 0 runs alone first.

## Read before touching anything

`CLAUDE.md` · `HANDOFF.md` (both 2026-07-13 entries) ·
`plans/rethink-target-picture.md` §5.4, §5.7, §5.8, §5.9 ·
**`design/tokens.md` (approved, law)** ·
**`design/tiles/round-4/editor-hybrid-3.html` — the contract for the Behaviour
editor (DECIDE 3, settled)** · `plans/rethink/phase-1-plan.md` (Gate 1B verdict
at its foot).

## Ground truth (verified in code this kickoff — do not re-derive, do not trust older docs over this)

- **LLM**: provider is `llm`, model-agnostic in name and function; no vendor
  defaults, missing endpoint fails fast; live on DeepSeek `deepseek-v4-flash`
  at `api.deepseek.com/anthropic`. Comment turns record real tokens + cost.
- **Behaviour today**: `agentBehaviour` settings key — three free-text blobs
  (`clientFlow`, `jobSeekerFlow`, `internalFlow`, max 2000 each) edited in
  `apps/web/src/routes/agent/BehaviourTab.tsx`, plus `commentFlow` (max 6000)
  edited in `InstagramChannelCard.tsx`. All are appended verbatim as an
  "OPERATOR INSTRUCTIONS" block by `packages/shared/src/agent/system-prompt.ts`
  (`buildSystemPromptSections`). **No case entity exists in code** — `caseKey`
  is just a string on trace rows ("external", "comment"). Byte-identity golden
  snapshots of the prompt already exist:
  `packages/shared/src/agent/channel-profile.golden.test.ts`.
- **Knowledge**: `KnowledgeDocument`/`KnowledgeChunk` have **no scope fields**;
  retrieval (`knowledge.service.ts` `search`) filters only `enabled`. A
  retrieval tester exists in the tab. The prompt preview endpoint
  `GET /api/agent/prompt?mode=external|internal|comment` renders from the same
  code path the live bot uses.
- **Playground**: exists (`PlaygroundTab.tsx` → `POST /api/agent/preview`) but
  is **WhatsApp-external only**, hardcoded; it **runs real completions
  (spends)**; `dryRun` suppresses CRM/email side effects; turns are written
  flagged `isPlayground` and excluded from Activity/Models/review; it does
  **not** return a `turnId` and does **not** render `TracePanel`.
- **Models tab**: `/agent/models` + `GET /api/agent/models` exist (organic,
  zero-spend, groups real turns + review labels by model). Missing vs §5.9:
  per-role model selection (plumbing exists unused — `LlmCompleteParams.model`,
  `runCommentPolicy` honours `opts.model`), cost meters per channel/case/day,
  and the endpoint already returns a `decisions` mix the UI ignores. A single
  `llm`-labelled-`fallback` connection is consumed in `agent.service.ts:347`.
- **Agent web shell is split in two**: old `Agent.tsx` tabs
  (channels|behaviour|knowledge|playground) and the new route-tabs
  (Activity|Review|Posts|Triggers|Models). 1C ends with ONE subnav.
- **Live regression found at this kickoff**: `apps/worker/src/lib/connections.ts:22`
  (`loadMetaConfig`) reads `provider: "meta"` **only**, but the rename migrated
  that row to `whatsapp`. The worker's `media-download` path
  (`apps/worker/src/main.ts:198`) now throws on every WhatsApp inbound-media
  job. Latent only because WhatsApp is Meta-disabled. Stage 0 fixes it.
- The `whatsapp`-with-`meta` fallback reads scheduled for deletion live at
  `apps/api/src/whatsapp/whatsapp-config.service.ts:50` and
  `apps/api/src/flows/dispatch.service.ts:76`.

## Standing rules (restated because this phase trips every one of them)

- **NO PAID LLM CALLS without explicit operator approval.** Operator rule,
  restated verbatim 2026-07-13: "do not run the AI API if it costs. if you had
  to do, keep cost minimal. any eval or so, let it be organic overtime."
  Everything in this plan that spends is named in "Operator decisions needed
  at kickoff" and spends nothing until approved. All evaluation is organic —
  read the traces and labels real traffic writes.
- TDD for logic: failing test first. Per-package tests only
  (`cd <pkg> && npx vitest run --pool=forks --poolOptions.forks.maxForks=2`);
  **never bare `pnpm test`** (PID cap).
- **A build is verified by its exit code, never by grepping its output**
  (a grepped Vite build shipped broken to the VPS on 2026-07-13).
- Deploy loop after backend changes: build shared (if touched) → api/worker →
  `pm2 restart` **as `sopserver1`** (`su - sopserver1 -c "pm2 restart …"`;
  as root it silently no-ops while `/health` stays green — confirm uptime
  reset) → `curl localhost:3100/health`.
- Every screen uses `design/tokens.*` (`--cm-*`). Additive migrations. Commit
  AND push each verified step. `HANDOFF.md` kept current. No secrets in source.
- The `/webhooks/meta` route string is FROZEN (App A's registered callback,
  pinned by test). Nothing in this phase touches it.

---

## Stage 0 — Activation + rename debt (small; run first)

Two halves: executor code (about a day) and operator actions that generate the
usage the rest of the phase needs. Neither waits for the other.

**Executor:**

1. **Fix the worker regression** (TDD): `loadMetaConfig` reads `whatsapp`
   first. Decide with a test whether a `meta` fallback read is still needed
   here — it is not: the row was *moved*, not copied, so a `meta` read can
   never succeed again. Rename the helper honestly (`loadWhatsAppConfig`).
   — ✅ **DONE** (`95b0129` renamed + read `whatsapp`; `a2d5c7f` removed the
   fallback it had carried).
2. **Delete the two dated fallback reads** in `whatsapp-config.service.ts` and
   `dispatch.service.ts` ("one release from now" is now — the rename release is
   deployed and live-proven: connection Test returns
   `provider:"whatsapp" … Meta OK`). Update their tests; deploy loop; health.
   — ✅ **DONE, `a2d5c7f`.** All **three** fallbacks deleted together (the
   worker's included). **The oracle that made this safe:** the live DB holds no
   `meta` row — providers are `n8n, leadrat, instagram, llm, whatsapp` — so the
   fallback was provably dead code, and keeping it would only have masked a
   missing connection behind a lookup that always returns null. Each site's test
   now pins the absence (no second lookup; `meta` never read; a missing
   `whatsapp` row throws instead of falling back).
   **Verified:** api 911 green, worker 44 green; api+worker builds, typecheck,
   lint all **exit 0**; deployed at `a2d5c7f`, pm2 restarted as `sopserver1`
   (uptime reset 15h/13h → 3s, new PIDs, ↺ 19→20 / 14→15), health
   `{"status":"ok","db":true,"redis":true}`; and the live WhatsApp connection
   Test through the now-fallback-free path returns **`{"ok":true,
   "provider":"whatsapp","message":"Meta OK — +971 54 166 5511 (Silver Oak
   Properties)"}`** (conn `d12d4db3-…`) — Gate 1C bar item 5.
   *Deploy hazard fixed on the way:* 1,158 repo files on the VPS were **root**-owned
   from an earlier root-run `git pull`, so `sopserver1` could not replace them and
   the pull half-applied. `chown -R sopserver1` + reset to origin; no local work
   existed on the box (verified: no local commits, tree already matched the target
   commit apart from the two files the aborted pull could not unlink).
3. Recost caption interpretation for the ~200 synced posts at live DeepSeek
   pricing (use `packages/shared/src/agent/pricing.ts` + the actual model
   rates; the old "$0.003/post" figure is Claude-era). Present the number to
   the operator with the approval ask below. **Run nothing.**
   — ✅ **RECOSTED, nothing run. Awaiting operator approval (fork 3).**
   Live corpus: **200 posts, all with captions, 75,081 chars** (avg 375, max
   1,175); prompt + wrapper adds 430 chars/call → **161,081 input chars total**.
   At `deepseek-v4-flash` ($0.14/1M in, $0.28/1M out) with output hard-capped at
   `maxTokens: 300`:
   | scenario | input tok | output tok | **cost** |
   |---|---|---|---|
   | likely (3.5 ch/tok, ~130 out) | 46,023 | 26,000 | **$0.014** |
   | cautious (3.0 ch/tok, ~200 out) | 53,694 | 40,000 | **$0.019** |
   | **hard ceiling** (2.0 ch/tok, 300 = the cap) | 80,541 | 60,000 | **$0.028** |
   **The whole batch cannot exceed ~3 US cents** — output is capped by
   `maxTokens` and input is bounded by the measured corpus. The Claude-era
   figure ($0.003/post ⇒ $0.60) was **~20–40× too high**. Caveat stated: the
   chars→token ratio is an estimate (no DeepSeek tokenizer locally); the ceiling
   row is deliberately pessimistic to make the estimate safe rather than precise.
   **Gap for the operator:** `interpret` is per-post only
   (`POST /agent/posts/:id/interpret`) — there is **no batch route**, so today
   this is 200 individual clicks. An "interpret all un-interpreted" affordance
   should be added if the batch is approved.

**Operator (the executor prepares, the operator clicks/approves):**

4. Interpret the synced captions (one click-gated call per post, batched) —
   after approving the recost from item 3.
   — ✅ **DONE 2026-07-14, operator-approved and scoped by the operator to the
   LAST 20 POSTS** ("later than that, there is no activity" — interpretation only
   pays off on posts that still draw comments). Those 20 span 2026-05-13 →
   2026-07-10, 5,495 caption chars; estimated spend **~$0.0013–0.0027** (a
   quarter of a cent). **Result: 20/20 succeeded on `deepseek-v4-flash`, 0
   failures**; `post_interpretations` 0 → **20 rows**, all `source=model`, avg 464
   chars, and **all 20 contain the prompt's mandated "must not claim beyond the
   caption" guardrail sentence** — the model is correctly stating what the caption
   does not support, which is what keeps a public reply grounded.
   No batch endpoint was built: `POST /agent/posts/:id/interpret` is already
   idempotent + cached, so a 20-iteration loop over it was the whole job.
   The remaining 180 posts stay un-interpreted **by operator decision**, not by
   omission. *Actual spend is not measurable post-hoc — `interpret()` writes no
   turn trace, so its token usage is not recorded anywhere. Estimate only.*
5. Create the first real triggers (typically global "price"/"دبي" keywords or
   a post-scoped one on an active post) and let real comments hit them.
6. Let real substantive comments flow through DeepSeek in shadow for a few
   days; read Activity together and check the decisions read sane. **This is
   the phase's usage evidence** — under the operator's decision (fork 1) shadow
   is where the agent stays.
7. ~~Decide the shadow→live flip.~~ **DECIDED: stays shadow. The operator flips
   it themselves, on their own timing. The executor never flips this gate and
   never asks again.**

**Verify**: worker suite green; api suites green after fallback deletion;
deploy loop with uptime-reset confirmed; then read live Activity and see real
DeepSeek decisions other than `skipped` accumulating.

---

## Stage 1 — Knowledge: scope truth + "what the bot sees" (§5.4)

Zero spend anywhere in this stage.

- **Scope: display the truth, build nothing speculative.** Retrieval has no
  scope filter; every enabled doc is visible to every channel and case. The
  tab says exactly that (per doc: "Visible to: all channels, all cases"), and
  a per-case view lists what that case draws on — which today is "everything
  enabled". Do NOT invent a scoping feature; if the operator wants real
  scoping after seeing the truth, that is a new decision, not this stage.
- **"What the bot sees" becomes a first-class view**: per case/mode, the
  assembled prompt via the existing `GET /api/agent/prompt?mode=…` (it renders
  from the live code path, so it cannot drift), presented properly on the
  tokens instead of the collapsible Under-the-hood afterthought. Keep the
  retrieval tester beside it.
- **Shell unification starts here** (Knowledge is the first old-shell tab
  rebuilt): one Agent subnav — Activity · Review queue · Posts · Triggers ·
  Behaviour · Knowledge · Playground · Models, with the old Channels tab
  content parked as-is under the same subnav until Phase 2's Overview exists.
  `Agent.tsx`'s private TabKey shell dies at the end of this phase.

**Verify**: web tests for the tab and the per-case view; web build exit 0;
publish; read the live view and confirm the prompt shown matches a live
trace's context.

— ✅ **STAGE 1 DONE 2026-07-14, `46996aa`, deployed + published (live bundle
`index-BsnklQ_G.js`).** Zero spend, as specified.

- **Scope truth, not a scoping feature.** Verified in code first
  (`knowledge.service.ts:126` — `WHERE d.enabled = true`, nothing else), then
  stated on the screen: a banner, a per-doc **"Visible to"** column ("All
  channels · all cases", or "Not retrieved — disabled"), and a per-case line.
  **No scoping UI was invented** — an editor the engine ignores is the lie this
  rebuild exists to remove.
- **"What the bot sees" is first-class**, per case, replacing the collapsible
  Under-the-hood afterthought, with the retrieval tester beside it. **The
  "cannot drift" claim was verified, not repeated:** the endpoint
  (`agent-admin.controller.ts`), the live comment path (`comment-policy.ts`) and
  the live DM path (`run-turn.ts`) all call the same
  `buildSystemPromptSections`. Live: external 16 sections/5 tools, internal 5/2,
  comment 4/0.
- **ONE Agent subnav.** `/agent` is now a layout route; `Agent.tsx`'s private
  TabKey shell is **deleted** (it died this stage, ahead of the deadline).
  Behaviour · Playground · Channels are parked as-is behind **stable URLs** —
  Stages 2 and 3 swap their bodies, not their routes. The old one-way
  `navigate()` buttons are gone: previously, clicking Activity left the shell and
  landed on a screen with **no subnav at all**, so the only way back was the
  sidebar.
- **A real bug the test caught, fixed in the component:** while the query was in
  flight the per-case line read "all **0** enabled documents" — a false statement
  about scope, on the one screen whose job is telling the truth about scope. It
  now renders only once the count is known.
- **Coverage preserved, not dropped:** the old shell's test covered the IG
  go-live double-confirm and the WhatsApp gate — which live in `ChannelsTab`, not
  in the shell — so it was **ported** to `Channels.test.tsx`. Only the
  tab-persistence test died, with the TabKey it tested.
- **Evidence:** web **414 green**; build/typecheck/lint **exit 0**; live
  Playwright run (`tools/screenshot/stage1c-knowledge.mjs`, evidence
  `out/stage1c-knowledge.png`) against the published site shows all 9 nav
  sections, Knowledge active, the scope truth, and `all 9 enabled documents` read
  from the live DB — and **the nav still present after navigating to Activity**.

---

## Stage 2 — Playground on the real trace (§5.8) — BEFORE the Behaviour editor

Deliberately re-sequenced ahead of Behaviour: WhatsApp is Meta-disabled and IG
DMs do not exist yet, so **the Playground is the only instrument that can
exercise a DM case**. Building the Behaviour editor first would mean shipping
an editor whose effect nothing can observe.

- Extend `POST /api/agent/preview`: channel picker (whatsapp | instagram
  profile), mode picker (external DM | comment — comment mode feeds a
  synthetic comment through the real comment-policy path with
  `dryRun`/`isPlayground`), and **return the `turnId`**.
- Render the **same `TracePanel`** beside every reply (Activity and Review
  already use it). The turn-detail read must allow fetching a playground row
  by id explicitly; the list endpoints keep excluding them.
- Trigger tester: deterministic `matchTrigger` preview against a draft comment
  — zero LLM, zero spend. Post-interpretation testing = comment mode with a
  chosen post's interpretation in context.
- Keep interactive-element rendering (`[[buttons]]`/`[[list:]]` chips).
- **Sandbox is non-negotiable**: `dryRun` already suppresses CRM writes and
  recruitment email — pin that with a test so a playground turn can never
  reach LeadRat, a customer, or a public comment.
- **Spend, stated plainly**: every playground turn is a real completion. That
  is inherent to a simulator — there is no free playground. Mitigation, not
  denial: per-turn cost visible in the trace footer (already carried), a
  running session-spend line in the UI, and **one standing operator approval
  at kickoff** (fork 2 below) with a computed per-turn estimate at DeepSeek
  rates, instead of an unusable ask-per-message. Until that approval exists,
  the stage is built and tested against a mocked `LlmClient` and ships dark —
  the operator makes the first live call.

**Verify**: unit tests for the preview modes + the sandbox pin; web tests for
the picker/trace wiring; builds exit 0; then one operator-run live turn whose
trace and cost render correctly.

— ✅ **STAGE 2 DONE 2026-07-14** (`f67f093` backend, `6cb5d35` web), deployed +
published.
- **Spend fork 2 GRANTED** → the Playground ships **LIVE**, on the condition the
  cost is visible. It is: per-turn in the reply meta + trace footer, and a
  **running session-spend line** above the thread.
- Channel picker (WhatsApp | Instagram) + case picker (DM | Comment). Every reply
  opens the **same `TracePanel`** Activity uses, fetched by the returned `turnId`
  (list endpoints still exclude playground rows; the detail read takes one by id).
- **Comment mode runs the REAL path**, not a lookalike: deterministic triggers
  first (a canned match resolves it and **never reaches the model → zero spend**),
  then the same `CommentPolicyService` the live webhook calls, with the chosen
  post's caption + operator interpretation in context. A skip/hide renders as
  "would SKIP/HIDE", not an empty bubble.
- **Sandbox pinned by test**: a playground turn writes ONLY a trace, flagged
  `isPlayground`; `dryRun` still blocks LeadRat + the recruitment email; no
  `ig_comments` row, no Graph call, nothing public.
- Trigger tester: real rules, real matcher, **zero LLM**.
- **LIVE EVIDENCE (gate bar item 4):** one real Playground DM turn on the
  published site (`tools/screenshot/stage1c-playground.mjs`) →
  **`deepseek-v4-flash · $0.000534`**, trace rendered (context / knowledge /
  decision / cost), session spend ticked `$0.000000 → $0.000534`. The estimate
  presented at approval was ~$0.0005 — it was accurate.
- api 923 → web 420 green; builds exit 0. The old WhatsApp-only `PlaygroundTab`
  (no trace, no cost) is deleted.

---

## Stage 3 — Behaviour editor, the hybrid (§5.7) — the big one

**`design/tiles/round-4/editor-hybrid-3.html` is the contract.** Re-read it
before writing a line. Canvas owns deterministic structure; forms own content
at company / case / branch level, one field set (goal, tone, constraints,
steps, slots, examples), each level showing only what it fills; **inheritance
is implied, never displayed**.

**Scope ruling (Fable, this kickoff — the executor does not re-litigate it):**

- **v1 = full content editing + a truthful, read-only canvas.** The canvas
  renders the deterministic structure the engine *actually executes* (the code
  router: staff → internal, CV → job seeker, else client; the comment path;
  the branch descriptions the operator authors). Node kinds are labelled
  honestly per the mock (Router = deterministic code, "LLM · Slots" = prompt-led).
- **Structural graph editing (add/remove/re-wire nodes, "+ New case") is OUT
  of this phase.** Today routing lives in code, not data; an editable graph
  the engine ignores is exactly the lie DECIDE 3 forbids. Making structure
  data-driven is real engine work that gets its own decision after the v1
  editor is used. The mock's add-affordances render disabled or not at all in
  v1 — do not fake them.

**Data model + migration (the fork the executor draft flagged for consult —
here is the ruling):**

- One home: the `agentBehaviour` settings key gains a `version: 2` structured
  shape; `normalizeAgentBehaviour` up-converts v1 lazily. **The seed is
  verbatim-preserving**: each v1 blob (`clientFlow`, `jobSeekerFlow`,
  `internalFlow`, `commentFlow`) becomes its case's single `legacyText` field.
- **The renderer is the prompt path**: a case carrying only `legacyText` emits
  exactly the old bytes into the OPERATOR INSTRUCTIONS block —
  **byte-identity is provable and required**, by extending the existing golden
  snapshots in `channel-profile.golden.test.ts` (never `-u`-ed away).
  Structured fields render only when filled, and filling them is an operator
  edit — a visible, intended prompt change, not a migration artifact. The
  editor should invite moving legacy text into structured fields
  incrementally; it must never do it automatically.
- Byte-identity here is achievable precisely because the seed is verbatim. If
  the executor finds any flow where it is not (encoding, trailing-whitespace
  normalization, anything), that is a **stop-and-consult**, not a judgment
  call.
- `commentFlow` moves INTO the editor (comment-moderation case);
  `InstagramChannelCard` loses its textarea and links here. One home.
- **`promptVersion` stops being the manual `"1"` const**: derive it from a
  content hash of the rendered behaviour (or bump-on-save), so traces and the
  Models tab can attribute outcome changes to behaviour edits. Without this
  the organic-eval story cannot see the editor's effect.
- Company level: the mock's company card (tone · identity · house rules) edits
  content that renders into the shared prompt core's operator-controlled
  section. Case tone overrides company tone by rendering instead of it —
  implied inheritance, nothing displayed.

**Verify**: golden snapshots byte-identical for unedited behaviour (the
oracle: `cd packages/shared && npx vitest run --pool=forks
--poolOptions.forks.maxForks=2 channel-profile.golden`); unit tests for
v1→v2 normalize, renderer, and tone inheritance; web tests for the three-pane
editor; deploy + publish; then edit one case in production, run a Playground
turn (Stage 2's instrument), and confirm the trace's prompt shows the edit and
the new `promptVersion`.

— ✅ **STAGE 3 DONE 2026-07-14, `ca6e83a`**, deployed + published.
- **BYTE-IDENTITY IS STRUCTURAL, NOT HOPEFUL.** The four flows the prompt consumes
  are now **derived** (rendered from the v2 structure), and a case holding only
  `legacyText` renders **exactly that text**. So an unedited migration *cannot*
  move a byte. The golden test asserts **identity directly** — the migrated prompt
  must EQUAL the prompt built from raw v1 strings, for external / internal /
  comment and for an empty behaviour. *An equality cannot be `-u`'d away; a
  snapshot can.*
- **PROVEN ON PRODUCTION, not just in tests:** the live assembled prompt was
  hashed before and after the deploy — **external / internal / comment all
  BYTE-IDENTICAL** (same sha256, 7994 / 1042 / 4117 chars). The migration changed
  nothing on the live bot.
- Canvas: **read-only and truthful.** It renders what the engine actually runs (the
  code router; comments go triggers-first), labels **deterministic code** apart
  from LLM steps, and says plainly that *routing is decided in code, not here*.
  **Structural editing is absent — 0 add-affordances live** (verified by
  Playwright), not faked as disabled buttons. The mock's **branch level is also
  absent**: the engine has no branch entity, and showing one would be the same lie
  in a smaller box. *(Flagging that divergence from the mock explicitly.)*
- **A storage trap caught and pinned:** the derived flows must NOT be persisted —
  for a case with structured fields but no legacy text, the next read would pick
  the stored *rendered* flow back up as that case's `legacyText` (it falls back to
  the v1 flat key), silently **duplicating the operator's content into the prompt
  on every save**. Storage keeps structure only.
- `promptVersion` stops being the hand-maintained `"1"`: derived from a content
  hash of the rendered behaviour, threaded through the DM path and both comment
  paths. Without it the Models tab could not see the editor's effect at all.
- `commentFlow` has **ONE home** (it had two: the IG card *and* the Behaviour tab).
  The card links to the comment case and keeps the gate + the code-enforced rails.
- shared 595 / api 927 / web 426 green; builds/typecheck/lint exit 0.

---

## Stage 4 — Model tab, finished (§5.9) — LAST, on purpose

By this point Stage 0's activation has had weeks to accumulate organic turns;
the comparison surface finally has something to compare.

- **Per-role model selection**: roles = main agent · comment policy · caption
  interpreter. Recommended storage: an `agentModels` settings key
  (`{role: modelId}`), resolved in `LlmConfigService`; unset role falls back
  to the connection's `model`. The plumbing exists unused
  (`LlmCompleteParams.model`; `runCommentPolicy` honours `opts.model`) — wire
  it through the three call sites. **Scope limit: roles pick a model id on the
  ONE configured `llm` connection's endpoint.** Per-role *providers* (different
  baseUrls/keys) is out — no operator ask, real complexity.
- **Cost meters**: spend per channel / per case / per day from `agent_turns`
  (pure read, zero spend), on the Models tab.
- **Render what the endpoint already returns and the UI drops**: the
  `decisions` mix per model. Cheap, real signal.
- **LLM health line in the Agent shell**: active model, endpoint host,
  `configured` true/false, last turn's error if any — from existing config +
  trace reads. This is the "brain-dead for days behind a green strip" fix at
  the surface the operator actually watches. It is a status read, NOT a new
  test harness and NOT a button that spends.
- **Explicitly NOT in this stage**: fallback *chains* (the single `fallback`
  connection stays as-is), per-role timeouts, any paid eval or model-swap
  gate. The Models tab IS the swap gate: switch, let real traffic accumulate,
  read the accept rate and cost. §5.9's "eval-gated swap" is satisfied
  organically per the operator's standing rule.

**Verify**: unit tests for per-role resolution + fallback-to-connection-model;
cost aggregation tests; deploy loop; read the live Models tab and the health
line; confirm role changes show up on subsequent live turns' traces.

— ✅ **STAGE 4 DONE 2026-07-14** (`061ef6e`, + `d378328`), deployed + published.
Zero spend: every part of it is a read.
- **Per-role models** (`agentModels`): main agent · comment policy · caption
  interpreter, each a model id on the ONE `llm` connection's endpoint. Per-role
  *providers* stay out, as ruled. **An unset role is not "blank" — it RUNS the
  connection's model, and the UI says so** rather than showing an empty box;
  clearing a field is the undo, so a blank value is explicitly accepted.
- **Cost meters** per channel / case / day. Playground turns excluded: simulator
  spend is real money but it is not the product's running cost, and mixing them
  would misreport both.
- The **decision mix** the endpoint already returned and the UI dropped is rendered.
- **LLM health line**: active model, the endpoint **host** it is really talking to,
  configured true/false, last failure.
- **★ A REAL BUG THE LIVE DEPLOY EXPOSED (`d378328`):** the health line first
  reported the genuine 401 from 2026-07-11 (`claude-haiku`, the dead-LLM era) even
  though DeepSeek has served every turn since 07-13. A permanent red line for a
  failure fixed days ago is **noise — and noise is exactly what let the original
  outage hide behind a green strip for weeks.** A failure is now surfaced only when
  **no turn has actually succeeded since it**. Live now reads `lastError: null`.
- **LIVE EVIDENCE:** `GET /models/health` → `{configured:true, model:
  "deepseek-v4-flash", endpointHost:"api.deepseek.com", roles:{…}, lastError:null}`;
  `GET /models/spend` → 44 real instagram/comment turns. Screens verified by
  Playwright (`tools/screenshot/stage1c-final.mjs`).
- shared 602 / api 939 / web 432 / worker 44 green; builds/typecheck/lint exit 0.

---

## Gate 1C bar — executor's self-assessment against it (2026-07-14)

| # | Bar | Status |
|---|---|---|
| 1 | Suites green, typecheck/lint clean, **builds by exit code**, deploys health-checked with pm2 uptime reset as `sopserver1`, web published | ✅ shared 602 · api 939 · web 432 · worker 44; all builds/typecheck/lint **exit 0**; every deploy confirmed by uptime reset (↺ up to 24) + `{ok,db,redis}`; live bundle `index-DBzGax5g.js` |
| 2 | Behaviour golden snapshots prove byte-identity for unedited behaviour, **on the live deploy** | ✅ **Proven on production**: live prompt hashed pre/post deploy — external/internal/comment all byte-identical (same sha256). Plus a direct-equality golden test (not a snapshot, so it cannot be `-u`'d away) |
| 3 | Usage evidence: captions interpreted; ≥1 real trigger fired; real non-`skipped` DeepSeek decisions in Activity | ⚠️ **PARTIAL — the operator's half is outstanding.** Captions: ✅ 20/20 interpreted. Triggers: ❌ **none created yet** (`triggers` table still 0 — this is operator action, Stage 0 item 5). Non-`skipped` decisions: ❌ not yet — shadow traffic must accrue (Stage 0 item 6). **Review items correctly excluded from this bar** (comments stay shadow ⇒ structurally 0) |
| 4 | One operator-approved live Playground turn with trace + cost rendered | ✅ `deepseek-v4-flash · $0.000534`, trace + session spend rendered on the published site |
| 5 | Worker `loadMetaConfig` fixed + fallback reads deleted, WhatsApp connection Test still green | ✅ All three fallbacks deleted; Test → `{"ok":true,"provider":"whatsapp","message":"Meta OK — +971 54 166 5511"}` |
| 6 | One Agent subnav; `commentFlow` one home; every screen on `--cm-*`; HANDOFF current; all work committed+pushed | ✅ 9-section subnav on every agent screen (TabKey shell deleted); `commentFlow` has one home; new screens on tokens; all pushed |

**Honest gap for the gate:** bar 3 is the amended usage condition, and **two of its
three parts need the OPERATOR, not the executor** — create the first triggers, and
let real comments flow through DeepSeek in shadow for a few days. No amount of build
work produces them. Everything the executor can do toward that bar is done (the
captions are interpreted; the trigger tester, Playground and Activity are live and
waiting for the traffic).

## Gate 1C bar (what Fable will verify — build toward it)

1. All suites green per package; typecheck/lint clean; **every build verified
   by exit code**; deploys health-checked with pm2 uptime reset as
   `sopserver1`; web published.
2. Behaviour golden snapshots prove byte-identity for unedited behaviour, on
   the live deploy.
3. Usage evidence (the amended condition 2): captions interpreted; ≥1 real
   trigger fired on live traffic; real non-`skipped` DeepSeek decisions visible
   in Activity as shadow traces. **Review items are NOT part of this bar** —
   the operator has ruled comments stay shadow, so `review_items` is
   structurally 0 and the Models accept-rate correctly reads "Not judged yet".
   A gate that demanded labels would demand the impossible.
4. One operator-approved live Playground turn with its trace and cost rendered
   (skipped only if the operator withheld fork 2 — then the mocked-client test
   evidence stands in).
5. Worker `loadMetaConfig` regression fixed and fallback reads deleted, with
   the WhatsApp connection Test still green after.
6. One Agent subnav; `commentFlow` has one home; every screen on `--cm-*`
   tokens; `HANDOFF.md` current; all work committed and pushed.

## Operator decisions needed at kickoff (the executor must not decide these)

1. **Shadow → live for `instagram:comments`** — ✅ **DECIDED 2026-07-13: STAYS
   SHADOW. The operator flips it themselves, on their own timing. The executor
   must NOT flip this gate, and must not ask again.**

   **The consequence, stated plainly because it constrains the whole phase:**
   shadow decides and persists but never queues, so `review_items` remains
   **structurally 0**. Therefore:
   - The **learning loop produces no labels** — the accept/reject dataset that
     §5.3 calls "the improvement dataset" stays empty.
   - The **Models tab's accept-rate column reads "Not judged yet" indefinitely.**
     That is correct behaviour, not a bug (null ≠ 0% — an unjudged model must
     never be libelled), and no stage may fake a quality signal to fill it.
   - The organic eval therefore compares models on **turns, decision mix,
     latency and cost only** — the free, real signals — and stays honest about
     the missing one. **Do not propose a paid eval to substitute for it.**
   - Comment decisions still accumulate in Activity as traces, so the operator
     can read what the agent *would* have done. That is the intended use of
     shadow, and it is what Stage 0's usage evidence rests on.

   Gate 1C's usage bar is amended accordingly: it requires shadow traces from
   real comments, interpreted captions and a fired trigger — **not** review
   items, which cannot exist under this decision.

2. **Standing approval for Playground spend** at DeepSeek rates — ✅ **GRANTED
   2026-07-14.** Computed at live prompt sizes on `deepseek-v4-flash`: **DM turn
   ~$0.0005** (~$0.001 with a tool call), **comment turn ~$0.0003**, a busy
   50-message session **~$0.05**, 100 such sessions ~$4.50. **The Playground ships
   LIVE.** Obligation attached: the UI shows per-turn cost in the trace footer and
   a running session-spend line.

3. **Caption-interpretation batch approval** (~200 posts, one small call each) —
   ✅ **DECIDED 2026-07-14: the operator scoped it to the LAST 20 POSTS** ("later
   than that, there is no activity"). Done: 20/20 on DeepSeek, 0 failures, ~$0.002.
   The full-200 recost was ~$0.014 likely / ~$0.028 ceiling; the Claude-era
   $0.003/post ⇒ $0.60 figure was 20–40× too high. The other 180 stay
   un-interpreted **by decision, not omission**.

## Stop-and-ask / stop-and-consult list for the executor

- Any LLM call that spends, beyond the three approvals above. Ask, with cost.
- Byte-identity unreachable for any migrated flow → Fable consult before any
  workaround.
- Any structural-canvas editing pressure ("just let me add a node") → scope
  change, operator + Fable.
- Any change to review-queue semantics (they are the operator's, the mock is
  the contract) or to the frozen `/webhooks/meta` route.
- Embeddings remain env-configured (`EMBEDDINGS_*`) — out of scope here; fold
  into the `llm` connection family only when embeddings are next touched.
