# Agent scope — work-package 4: Situations & Modules (the engine)

> Authored by the orchestrator (Fable), 2026-07-18. This is the operator's
> core design thread made a package. **Authority:** `docs/DECISIONS.md`
> 2026-07-17 (round-12 module model) + 2026-07-18 (round-13 all-7,
> rounds-14/15/16, round-15b FINAL). **Mock lineage:** `design/tiles/round-13/`
> (the stack + catalogue + world) and `design/tiles/round-15b/tile-a` (THE
> control spec). Engine-gap tables live in those rounds' READMEs — read them;
> they are honest and current. The gate judges against THIS document.

## Design locks (ALL ruled — nothing here is open)

1. **A situation = an ordered, add/remove-able stack of typed modules.** A
   module's TYPE selects its editor and its compile-into-prompt function.
   Module custom name = optional secondary "label"; TYPE name is primary.
2. **Situations key off the surface the webhook names** (no runtime guessing):
   Comment moderation · Comment replies · DMs (Website later). **Two comment
   situations, ordered: moderation first; the reply situation sees only
   comments that pass cleanly.** The routing.ts predicate router is obviated
   by this — see Stage 6.
3. **Inheritance: Global → situation → channel variant.** The control is the
   round-15b two-gesture design, verbatim:
   - Write in the channel box beneath the visible inherited text = **append**.
   - **Click the inherited text** (armed by click, completed by a small
     inline confirm) = strike it for this channel = **override**; your text
     stands alone. Restore un-strikes.
   - Crossed + empty box = the block honestly **contributes nothing** (tag +
     struck text is the cue; no modal). There is no "off" state and no
     switch control anywhere.
   - Kept-text: what you wrote survives every state change.
4. **Never-forget (operator requirement):** a replaced block stays loud —
   struck parent visible in the block, "Replaced here" tag persistent, and
   replaced blocks surfaced in the situation overview AND the final-prompt
   view. An old override must be impossible to forget.
5. **Rails (Boundary type): add-only on channels** — no strike affordance
   exists on it; a variant can add a rail, never remove/replace one. Global
   rail prose edits keep the typed-confirm (2026-07-17 reversal).
6. **Provenance:** per-block state tags (Inherited / Added to / Replaced
   here / Nothing) + a "View final prompt" panel on demand showing the
   assembled prompt with per-chunk source. No line-level blame in v1.
7. **Catalogue:** the 12 everyday types (round-13 tile-b) accepted; ship a
   starter subset and grow. **Both novels are IN scope:**
   Examples-fed-by-moderation-labels and Test/eval replay-gate.
8. **Style law:** no pills; icons never emojis; `--cm-*` tokens; no AI-isms.
9. **Nav end-state** (coordinates with appshell-scope-3's grouped sidebar):
   Situations absorbs Behaviour + Knowledge + Triggers; Agent slims to
   Overview · Activity · Situations · Playground · Posts (Models → Settings,
   appshell-scope-3 owns that move).

## Standing rules

TDD red-first; per-package capped-fork suites; additive migrations only;
Zod at boundaries; data over hardcoded; NO gate flips; no LLM spend beyond
standing approvals (replay estimates shown; >500 refuses); pathspec commits,
push per verified stage, pushed state must build; deploy/publish is the
orchestrator's lane. Spec conflict or ambiguity → STOP and report the fork.

## Stages

### Stage 0 — seeded-equivalence golden pin (the safety net)

Before any refactor: a golden test matrix pinning the BYTE-IDENTICAL system
prompt for every (mode/situation, channel, seeded-config) combination that
exists today — the same discipline agent-scope-2 Stage 6 used. This pin must
hold through Stages 1–3 for untouched configs. Red-first: the pin exists and
passes on HEAD before the first engine commit.

### Stage 1 — the module primitive + registry (shared)

- `packages/shared/src/agent/`: module type registry — type discriminator,
  per-type Zod schema, per-type `compile(moduleValue) → prompt chunk`.
  Editor kind is web's concern; the registry carries type metadata.
- Migrate today's seeded sections/dynamic fields into modules keyed by
  situation (the dynamic-fields engine is the seed of this — generalize
  field → typed module; keep the REMOVED-state semantics).
- Golden pin holds byte-identical.

### Stage 2 — the situation object + Global parent (shared + api)

- Situation registry (comment-moderation · comment-replies · dms) bound to
  the surfaces/gates that exist; Global as a real shared parent layer
  (today faked by the `external` scaffold).
- Resolver: Global → situation, deepest-set wins per module.
- API: GET/PUT situations (Zod, additive storage — new tables/columns, no
  destructive change), `agent.service` consumes the resolver.
- Golden pin holds for seeded configs; moderation-first → reply-sees-clean
  ordering pinned by an integration test (it matches today's two-pass).

### Stage 3 — the channel-variant axis + two-gesture semantics

- Storage + resolver gain the `(situation, channel, module)` axis (additive
  migration). Append/override/kept-text semantics exactly per lock 3;
  compile paths for append (parent + yours) and override (yours alone;
  empty = omit).
- Never-forget data: a replaced block is queryable (the overview and
  final-prompt panel need the list).
- Rails: Boundary type rejects override at the schema level (server-side,
  not just UI).

### Stage 4 — the Situations screen (web)

- `/agent/situations`: situation list → stack editor per round-13 tile-a
  shape with the round-15b tile-a control (click-to-strike + inline confirm,
  write-below-to-append, restore, tags, kept-text). "View final prompt"
  panel. Replaced-blocks surfacing per lock 4.
- Behaviour/Knowledge/Triggers CONTENT progressively folds in (Knowledge and
  Trigger/keyword as module types); the old screens stay routable until
  Stage 6.
- Screenshot evidence at 1440 + 375 against the reacted tiles.

### Stage 5 — the novels

- **Examples module:** store fed by the moderation console's
  `decision_labels` (a labeled right/wrong case flows in as a few-shot
  example); compile-in path; visible in the stack like any module.
- **Test/eval replay-gate module:** bind a replay set (labeled turns) to a
  situation; a module edit is replay-checked via the existing
  `ReplayService`/playground path before it can go live; estimate shown,
  >500 refuses; isPlayground-excluded from metrics.

### Stage 6 — retirements (the crammed-section payoff)

- Behaviour, Knowledge, Triggers screens redirect into Situations; their
  Agent sub-nav entries die (coordinate with the shipped grouped sidebar).
- routing.ts predicate router retired: seeded cases map to situations;
  custom cases get an explicit migration inventory (each one either becomes
  situation content or is listed to the operator for a kill decision —
  nothing dropped silently).
- Trigger/keyword modules become the ManyChat-import target (import itself
  stays backlog).

## Gate bar (final gate = all bars)

1. Suites green per package (capped forks), typecheck/lint/build exit 0,
   additive migrations apply cleanly; deploy loop + health (orchestrator).
2. **Stage-0 golden pin held byte-identical** through Stages 1–3 for
   untouched configs; deliberate golden changes (if any) named per-commit.
3. No gate flips; zero unapproved LLM spend (replay estimates pinned).
4. Live e2e on prod (orchestrator-run): edit a module → playground turn
   reflects it → revert → byte-identical config; a channel append and a
   channel override both witnessed in the final-prompt panel.
5. Never-forget provable: a replaced block visibly struck + tagged + listed
   in the overview and final-prompt panel.
6. Rails: server-side rejection of Boundary override pinned by test.
7. Examples loop closes: label a decision in the console → it appears as an
   example in the situation → a playground turn shows the compile-in.
8. Replay-gate: a module edit with a bound replay set cannot save-live
   without a green run (pinned by test; one live witnessed run).
9. UI matches the reacted tiles (screenshots at gate); style law holds.
10. Retirements complete with redirects; custom-case migration inventory
    recorded; every deferral written to the board before the gate closes;
    HANDOFF + board current.

## Out of scope (recorded)

- Flow-node → situation binding (operator: parked; future AI-node vision
  recorded in DECISIONS round-13 entry).
- Activity end-state package (one-list Feed/Needs-action) — separate agent
  package; do not touch ModerationView/Activity here beyond what Stage 5's
  Examples wiring strictly needs.
- ManyChat import execution; Website situation; Schedule module beyond the
  catalogue stub.

## Stage log

*(executor appends; gate verdict recorded here)*

> **Orchestrator rulings (2026-07-20, post-Stage-4):** ① Non-seeded modules
> (tappable-choices, knowledge, output-contract, live) rendered read-only
> Engine-fixed/Runtime-data — ACCEPTED for v1 as a faithful narrowing
> (canvas-honesty: never offer a gesture the engine ignores). Channel-
> appendable tappable-choices = a boarded engine follow-up (list-concat
> compile path); surfaced to the operator with the Stage-4 screenshots.
> ② Stage-6 custom-case inventory CLOSED by live enumeration: `agentBehaviour`
> routing carries **0 non-builtin cases** (orchestrator-run query,
> 2026-07-20) — only seeded jobSeeker/client exist and they map to
> situations. No operator kill/keep ruling needed; gate bar 10's inventory
> is satisfied by this record. Stage 6 may proceed, guarded by the golden
> pin + existing DM-path tests.
>
> **Orchestrator ratification (2026-07-20):** the Stage-2 storage fork is
> RATIFIED — situations config lives in a `Setting` key (`agentSituations`),
> no Prisma migration. Grounds: the whole agent-config subsystem already
> stores this way (`agentBehaviour`/`agentReview`/`agentModels`/
> `channelGates`), a Setting row is maximally additive, and the override
> tree is a document, not relational rows. The kickoff's "new tables/
> columns" phrasing described additivity, not a table mandate. Gate bar 1's
> "migrations apply cleanly" is satisfied vacuously for Stages 2–3.

### Stage 0 — seeded-equivalence golden pin · DONE `807b1df`

The safety net. New file `packages/shared/src/agent/situations-golden.test.ts`
(+ its `__snapshots__/situations-golden.test.ts.snap`, 26 snapshots) pins the
BYTE-IDENTICAL system prompt for the full matrix that exists on HEAD, targeting
`buildSystemPrompt`/`buildSystemPromptSections` directly (the surface the engine
refactor replaces — exact `SystemPromptOptions` shape `run-turn.ts` passes):

- pure-seed prompt for every `(mode/situation × channel)` — 3 modes ×
  {whatsapp, instagram, facebook, web} = 12;
- external config variations: full live inputs + edited behaviour (wa + ig),
  job-seeker disabled, a REMOVED seeded section, an operator clientFlow append,
  and the safety invariant that a REMOVED marker on a non-removable rail
  (boundaries) reverts to seed;
- comment variations: operator commentFlow (ig + fb), safety-rail REMOVED →
  reverts to seed;
- moderation variations: edited taxonomy, fb seed, taxonomy REMOVED → seed;
- section-id ORDER per mode (reorder fails distinctly from content);
- `splitSystemPrompt` cache boundary + the static+dynamic == full invariant.

Red-first note: golden baselines — created, ran, snapshots written, green; the
pin now guards Stages 1–3. Deliberate golden changes (if any) will be named
per-commit; an un-ruled snapshot move = the refactor changed live behaviour.

**Oracles (verbatim tails):**
- `cd packages/shared && npx vitest run --pool=forks --poolOptions.forks.maxForks=2 src/agent/situations-golden.test.ts`
  → `Snapshots 26 written · Test Files 1 passed (1) · Tests 30 passed (30)`;
  re-run (determinism) → `Tests 30 passed (30)` (no snapshots written).
- Full shared agent suite `… src/agent` → `Test Files 35 passed (35) · Tests 384 passed (384)`.
- `corepack pnpm --filter @channels/shared build` → `tsc` exit 0.

Deviation: running the two pre-existing golden suites rewrote their `.snap`
files with LF→CRLF EOL only (content-identical per `git diff`); discarded that
churn with `git checkout --`, committed ONLY the two new files (pathspec).
No collision with the concurrent seat (comment-policy.ts / moderation-policy.ts
/ posts-interpretation are untouched; this pins `system-prompt.ts` +
`prompt-seed.ts` output, which don't import those files).

### Stage 1 — module primitive + registry (shared) · DONE `44fde14`

A situation is now an ordered stack of TYPED modules. New
`packages/shared/src/agent/modules.ts`:

- **The registry** — `MODULE_TYPES` (12-type starter subset of the round-13
  catalogue: identity · guidance · tone · language · boundary ·
  moderation-policy · tappable-choices · knowledge · output-contract ·
  job-seekers · operator-steering · live). `MODULE_REGISTRY[type] =
  { schema, compile }`: a per-type Zod schema (the stored module shape) and a
  per-type `compile(module, opts) → PromptSection | null`. `moduleSchema` is
  the discriminated union over all types. Editor kind stays web's concern.
- **The migration** — the three situations that exist today are seeded module
  stacks (`SITUATIONS`: `dms` · `comment-replies` · `comment-moderation`),
  ordered exactly as the old inline section list. `situationIdForMode` bridges
  the legacy `mode`; `assembleSituation(id, opts)` iterates the stack, compiles
  each, drops nulls. The REMOVED-state + dynamic-field semantics are preserved
  (a REMOVED marker drops a removable section, a rail reverts to seed).
- **Re-wiring** — `buildSystemPromptSections` now delegates to
  `assembleSituation`; the inline `externalSections`/`commentSections`/
  `moderationSections` + their private helpers (`seeded`, `seededText`,
  `jobSeekersSection`, `operatorSection`, `contactSection`, `leadProfileSection`)
  are DELETED from `system-prompt.ts` (209 lines → the stacks are the truth).
  `modules.js` added to the package barrel for Stage-2 (api) consumption.

Type-specific compile behaviour (append vs override per type, the channel-
variant axis) is deliberately NOT here — that is Stage 3. Today every prose-ish
type shares one seeded-prose compile; the type is metadata + the extensibility
seam (block architecture: a new type drops in without reworking the shell).

**Oracles (verbatim tails):**
- Golden pin HOLDS byte-identical: `… src/agent/situations-golden.test.ts
  src/agent/modules.test.ts` → `Test Files 2 passed (2) · Tests 42 passed (42)`,
  **0 snapshots written** (the refactor changed no assembled byte).
- New registry unit tests `modules.test.ts` → 12 tests green (schema
  round-trips every seeded module, per-type compile, REMOVED/rail/live/choices
  edge cases, ordered assembly).
- Full shared agent suite → `Test Files 36 passed (36) · Tests 403 passed (403)`.
- `corepack pnpm --filter @channels/shared build` (tsc) + `… typecheck`
  (tsc --noEmit) exit 0; `… @channels/api build` (nest build) exit 0.
- Downstream byte-identity: `apps/api … src/agent src/instagram/comment-policy.service.test.ts`
  → `Test Files 15 passed (15) · Tests 183 passed (183)`.
- Re-verified on the REBASED HEAD (concurrent seat's interpretation/token
  commits landed under me, no file overlap): shared build + golden re-run green.

Deviation: none of substance. Pathspec commit of the 4 files
(modules.ts/modules.test.ts/system-prompt.ts/index.ts); rebased cleanly onto the
concurrent seat's pushes (comment-policy/moderation/posts-interpretation lanes —
no collision). Shared has no `lint` script (only api/web lint), so DoD for a
shared-only change = build + typecheck + tests.

### Stage 2 — Global parent layer + resolver + GET/PUT situations · DONE `43532ce`

The inheritance spine, made real. A situation stack now inherits a **Global
parent**: `Global → situation`, resolved **deepest-set wins per module** (the
channel-variant axis is Stage 3). New `packages/shared/src/agent/situations.ts`:

- **The config** — `SituationsConfig = { version, global:{modules}, situations:{
  [id]:{modules} } }`; a `ModuleOverride` is replacement `text` or a `removed`
  marker. `normalizeSituationsConfig` drops blank overrides, unknown situation
  ids, and collapses empty layers (self-rectifying: absent ⇒ the empty seed).
- **The resolver** — `resolveModuleOverride` (situation beats global);
  `situationSectionOverrides` PROJECTS the resolved overrides into the very
  `(mode, sectionId)` `sectionText` keyspace the Stage-1 compile already reads —
  so the engine is untouched, only its INPUT is now layered.
  `resolveSituationBehaviour` folds the config into a prompt `behaviour`
  (precedence, deepest wins: legacy `behaviour.sectionText` › situation layer ›
  global layer — existing directive-2 edits never regress; Global fills every gap).
  `describeSituation`/`describeSituations` = the overview read model (seed text +
  resolved override + `seed|global|situation` provenance + rail flag).
- **Consumers resolve Global→situation** — `run-turn.ts` (DM turn),
  `comment-policy.service` (BOTH comment + moderation passes),
  `agent-admin` prompt view (what-you-see = what-runs). The two live
  interpretation files (`comment-policy.ts`/`moderation-policy.ts`) were NOT
  touched — resolution happens at the service layer, feeding the existing
  `behaviour.sectionText` param.
- **API** — `SituationsController` (`GET`/`PUT /api/agent/situations`, Zod strict
  schema, admin-guarded): read model + validated persist.

**Oracles (verbatim tails):**
- Golden pin HOLDS byte-identical: `… src/agent` (shared) →
  `Test Files 37 passed (37) · Tests 424 passed (424)`, **0 snapshots written**
  (21 new `situations.test.ts` — normalize, deepest-wins resolver, cascade,
  byte-identity, overview provenance).
- `corepack pnpm --filter @channels/shared build` (tsc) + `… typecheck` exit 0.
- API: `apps/api … src/agent src/instagram` → `Test Files 23 passed (23) ·
  Tests 341 passed (341)`; `… @channels/api build` (nest build) + `… typecheck`
  exit 0. **Two-pass ordering** (moderation-first → reply-sees-clean) stays
  pinned by `ig-comment.service.test.ts` (66 tests, incl. "LIVE moderation
  escalate/delete: reply pass never runs" + "LIVE moderation leave: reply runs").

**DEVIATION / OPEN FORK (needs orchestrator ratification):** the kickoff Stage 2
says "additive storage MIGRATION — new tables/columns". I stored the config in a
**Setting key/value row** (`agentSituations`), **no Prisma migration**. Rationale:
the ENTIRE agent-config subsystem lives in `Setting` rows (`agentBehaviour`,
`agentReview`, `agentModels`, `channelGates`), and `settings.ts:14` documents the
directive-2 `sectionText` override — the exact override mechanism situations
extend — as "additive, no migration". Routing-as-data (Stage 6 precedent) is a
Setting key with no migration too. A Setting row is maximally additive (no schema
change, nothing destructive, self-rectifying) and the override tree is a nested
document, not relational rows. If the orchestrator wants a table, wrapping the
same JSON in a `situations` column is a small follow-up. Flagged, not silently taken.

Deviation (process): pathspec commit of 9 files; discarded the recurring
LF→CRLF EOL churn on the two pre-existing golden `.snap` files (content-identical
per `git diff --ignore-all-space`), as Stage 0 did. Concurrent seats' uncommitted
web + DECISIONS/appshell-kickoff edits left untouched.

### Stage 3 — channel-variant axis + two-gesture semantics · DONE `019df22`

The `(situation, channel, module)` axis, additive onto the Stage-2 config
(`situations[id].channels[channel][moduleId]` — no migration; a new optional
branch of the same Setting JSON). The round-15b **two-gesture control** made
engine-real, exactly per lock 3:

- `ChannelModuleOverride = { text?, struck? }` — ONE kept-text + one flag.
  **Not struck ⇒ APPEND** (parent stands, `text` beneath it, `\n\n`-joined);
  **struck ⇒ OVERRIDE** (`text` alone; empty ⇒ the block OMITS). **Kept-text**:
  `text` survives every state change — toggling `struck` only recomposes (pinned
  by a test that flips struck and asserts the wording is retained). Channel
  composes on top of Global→situation (the append base is the resolved parent,
  else the seed for that channel), projected into the same `sectionText` keyspace
  the Stage-1 compile reads — **the engine is untouched**.
- **Rails add-only (lock 5)**: `isRailModule` = Boundary type. `normalize` strips
  a rail's `struck` (degrades to a plain append — a stray write can never strike a
  guardrail); the controller ALSO rejects it with a loud **400** (`rejectRailOverride`,
  mirrors `validateRoutingPatch`) — server-side, not just UI, as required.
- **Never-forget (lock 4)**: `listReplacedBlocks(config)` → every struck block in
  `situation → channel → module` order (rails never appear — their struck is
  stripped). Exposed on `GET`/`PUT /agent/situations` as `replaced[]` for the
  overview + final-prompt panel.

**Oracles (verbatim tails):**
- Golden pin HOLDS byte-identical: `situations-golden.test.ts.snap` untouched
  (`git status` clean for it; the two OTHER golden snaps show EOL-only churn per
  `git diff --ignore-all-space` → 0 content lines, discarded). Shared
  `… src/agent` → `Test Files 37 passed (37) · Tests 435 passed (435)` (32 in
  `situations.test.ts` — append/override/omit/kept-text/channel-scope/rail-append/
  never-forget). `… @channels/shared build` + `typecheck` exit 0.
- API: `apps/api … situations.controller.test.ts comment-policy.service.test.ts
  ig-comment.service.test.ts agent-admin.controller.test.ts` →
  `Test Files 4 passed (4) · Tests 135 passed (135)` (rail-override 400 pinned,
  channel append/override round-trip, replaced-blocks list). `… @channels/api
  build` (nest) + `typecheck` exit 0. Two-pass ordering still pinned.

Deviation: none of substance. Same Setting-key storage as Stage 2 (the open fork
above stands — channels are a branch of the same JSON). Comment passes resolve
under channel "instagram" to match the hardcoded comment-prompt channel
(byte-identity of the append base). Pathspec commit of 7 files; EOL churn on the
two pre-existing golden snaps discarded again.

### Stage 4 — the Situations screen (web) · DONE `b9056b9`

`/agent/situations`: the engine's editing surface. New
`apps/web/src/routes/agent/Situations.tsx` (+ `situations.css`, pure `--cm-*`
tokens per the style law — no legacy styling extended) + `Situations.test.tsx`.
`agent-api.ts` gains `getSituations`/`putSituations` typed off the shared
`SituationsView` (config + resolved overview + `replaced[]`) — no drift, the
same shapes the API resolves against.

- **Round-13 tile-a shape × round-15b two-gesture control.** A situation opens
  as its ordered module stack; a channel picker (the variant axis) sits under a
  Global→situation→channel-variant layer rail ("you are here"). Each SEEDED
  module carries the control, exactly per lock 3: click the inherited text (or
  the "Replace on <channel>" affordance) ARMS a strike; a small **inline
  confirm** row completes it (override); write in the box beneath = **append**;
  **Restore** un-strikes; **crossed + empty = contributes nothing** (tag + struck
  text, no modal). Kept-text survives every state change (the box is its own
  field). The state tag (Inherited / Added to / Replaced here / Contributes
  nothing) is a **read-out**, `deriveBlockState(rail,struck,hasText)` — never a
  control.
- **Rails (Boundary) add-only (lock 5):** no strike affordance exists — the
  parent body is non-clickable, no "Replace" button; a rail is always
  Inherited/Added-to. Adding a rail's channel text gates Save behind a typed
  CONFIRM (mirrors the Behaviour rail-prose gate).
- **Honest engine fidelity:** only seeded prose modules have a `sectionText`
  seam, so only they take the channel control. Non-seeded modules (knowledge,
  output-contract, tappable-choices, live) render as read-only **Engine-fixed /
  Runtime-data** blocks — the screen never offers a gesture the engine ignores
  (the canvas-honesty binding). This is a faithful narrowing of the aspirational
  tile (which drew Tappable-choices as channel-editable).
- **Never-forget (lock 4):** a "View final prompt" slide-over composes the
  resolved stack per block with per-chunk source tags (inherited / + channel /
  crossed-out / omitted) via `composeFinalPrompt` (mirrors
  `situationSectionOverrides`); a struck parent stays VISIBLE crossed-out in the
  panel, and an "Overridden on <channel>" overview banner lists every
  replaced/nothing block above the stack. Both surfaces carry the loud record.
- **Nav:** `situations` route registered (Behaviour/Knowledge/Triggers stay
  routable until Stage 6); "Situations" added to the grouped-sidebar sub-nav
  (`AppShell.tsx`) after Posts. Route pin + AppShell sub-nav fixtures updated.

**Oracles (verbatim tails):**
- `apps/web … src/routes/agent/Situations.test.tsx` → `Test Files 1 passed (1) ·
  Tests 12 passed (12)` (deriveBlockState 4-state + rail; parentTextOf; compose
  append/override/nothing/fixed; render: inherited-at-load, append→Added,
  arm+confirm→Replaced, clear→Nothing, restore→Inherited, rail no-strike, save
  payload `channels.whatsapp.tone`, final-prompt panel surfaces the replaced
  block with the struck parent visible).
- `apps/web … src/App.routes.test.tsx src/components/AppShell.test.tsx` →
  `Test Files 2 passed (2) · Tests 61 passed (61)` (/agent/situations pins
  no-404; sub-nav order pin includes Situations).
- `corepack pnpm --filter @channels/web typecheck` (tsc --noEmit) + `… build`
  (vite) + `… lint` (eslint, 0 errors) exit 0.
- **Screenshot evidence** (real component, Vite demo harness with a pre-seeded
  query cache — no backend/auth; harness deleted, uncommitted):
  `plans/rethink/evidence/agent-scope-4-situations-{1440,375}.png` +
  `agent-scope-4-final-prompt-1440.png`. All four states, engine-fixed blocks,
  rail append-only, the never-forget overview + panel all witnessed.

Deviation: none of substance. Screenshots captured via a throwaway demo mount
(the live app needs admin auth + backend; the orchestrator runs the live e2e per
gate bar 4). Pathspec commit of 8 source files + 3 PNGs; concurrent seats'
automation/alerts/app.module/scope-board edits left untouched (push
d66fb31..b9056b9 fast-forwarded cleanly; the follow-up `pull --rebase` reported
the OTHER seats' unstaged changes, not mine).

### Stage 5 — the novels (Examples + replay-gate) · DONE `9a03d6f` (shared) · `f55bd45` (api) · `9e08cc8` (web)

Both novels, engine → API → screen. Byte-identical when unfed (golden pin held,
0 snapshots written) — the two modules are inert seeds until labels flow in.

- **Examples (few-shot):** new `examples` module type (registry + Zod + compile),
  seeded INERT into both comment stacks (NOT DMs). A labeled right/wrong decision
  (`decision_labels`) flows in as a graded case: `ExamplesService.forSituation`
  loads labels joined to their turn, partitioned by kind (`moderationAction` ⇒
  comment-moderation, else comment-replies), most-recent-first, capped
  (`DEFAULT_LIMIT=8` — a seeded default, **flagged** as a candidate to lift into a
  Setting if the operator wants to tune it). `SystemPromptOptions.examples` threads
  them through `runCommentPolicy`/`runModerationPolicy`; `CommentPolicyService`
  loads + passes them; the screen renders them read-only on the module row; the
  API view enriches the descriptor. Loop: label in console → appears on the module
  → compiles into the comment prompt.
- **Replay-gate:** `situations[id].replayGate = { filter }` (a situation-level
  binding — a gate, not prompt content: canvas-honesty). A MODULE edit to a gated
  situation refuses on `PUT` without `?runReplay` (409 `replay_required` + the
  estimate), and with it reruns the bound set over the **PROPOSED** config
  (`ReplayService.run(filter, { situationsOverride })` → `previewComment` →
  `moderate/decide` with `skipExamples:true` so the labeled set can't leak its
  answer) and refuses unless GREEN — `ran` (not over MAX_LABELS) AND no
  right-labeled regression. isPlayground-excluded already. NO LLM spend from this
  seat — all tests mock the client; the one live witnessed run is the gate's
  (orchestrator lane). The screen: a per-situation toggle (comment-only) + the
  gated-save flow (409 → estimate banner → "Run replay and save" → retry with
  `runReplay`).
- **Module wiring:** `ReplayService` re-homed to `AgentModule` (provide+export)
  so `SituationsController` injects it with NO cycle (ModerationModule imports
  AgentModule); `ModerationModule` consumes the export. `ExamplesService`
  provided+exported.

**Oracles (verbatim tails):**
- shared `… src/agent` → `Test Files 38 passed (38) · Tests 447 passed (447)`;
  `situations-golden` 30 passed, **0 snapshots written** (byte-identical). shared
  build + typecheck exit 0.
- api `… src/agent src/instagram src/moderation` → `Test Files 30 passed (30) ·
  Tests 404 passed (404)`; api `nest build` + typecheck exit 0.
- web `… Situations.test.tsx` → `Test Files 1 passed (1) · Tests 18 passed (18)`;
  web typecheck + build + lint (0 errors) exit 0.

**DEVIATION (concurrency incident — flagged):** the concurrent automation-scope-1
seat, sharing the working tree, swept my `index.ts` `export ./agent/examples.js`
line into ITS commit `c9b07a1` WITHOUT the file (a dangling reference at HEAD);
my shared commit `9a03d6f` landed `examples.ts` and closed it. Separately, my
api-stage `git commit` (no pathspec) swept the OTHER seat's already-staged
`Reminders.tsx`/`Reminders.test.tsx` DELETIONS into `f55bd45` — nothing imports
them (migrated to `AutomationReminders.tsx`), so HEAD still builds, but attribution
is muddied. Corrected process: strict `git commit -- <pathspec>` thereafter
(Stage-5c `9e08cc8` used it). Live DI/health is the orchestrator's restart loop
(gate bar 1) — vitest can't compile the Nest graph (decorator-metadata emit
differs); `CommentPolicyService`+`ExamplesService` proven resolvable in a probe.

### Stage 6 — retirements · NOT STARTED (scoped + forks flagged — needs an orchestrator ruling)

Clean stop at the Stage-5 boundary. Stage 6 as written is **not a tail-end
redirect** — it is a feature-build gated on a precondition, plus a deep
live-engine refactor. The executor's honest read, with evidence, so the
orchestrator can gate/kick it off:

1. **The three screens can't just redirect — their content is not yet
   representable in Situations, and redirecting first would ORPHAN live features:**
   - **Triggers** — `Triggers.tsx` is the ONLY trigger CRUD UI (`listTriggers`/
     `createTrigger`/`updateTrigger`/`deleteTrigger`, `agent-api.ts:705`). Triggers
     run LIVE (comment path `matchTrigger`, `PlaygroundService.rules()`). A redirect
     with no Trigger/keyword module editor in Situations = operators lose trigger
     management while triggers keep firing. The kickoff wants "Trigger/keyword as a
     module TYPE" first — the registry has the seam but no type/editor exists yet.
   - **Knowledge** — `Knowledge.tsx` + `AgentKnowledgeDoc` are the ONLY corpus CRUD
     (`listKnowledge`/`createKnowledgeDoc`/`deleteKnowledgeDoc`, doc editor at
     `knowledge/new` + `knowledge/:id`). This manages the `search_knowledge` corpus —
     distinct from the existing `knowledge` MODULE (the fixed lookup INSTRUCTION).
     Redirecting orphans corpus management. Making it "representable" means building
     corpus management into Situations, a real editor.
   - **Behaviour** — its content (sectionText / clientFlow) IS already representable
     (seeded modules + operator-steering). Its only vestigial part is the routing
     editor (0 custom cases). Behaviour → Situations is the LEAST risky of the three,
     but still orphans the routing UI.
   The kickoff phrasing is explicit: "content representable in Situations … **then**
   the three screens redirect." The precondition (build Knowledge + Trigger/keyword
   module editors into the Situations screen) is the bulk of the work and was not
   safe to rush at a degraded boundary.

2. **routing.ts predicate-router retirement is a deep live-engine refactor, not
   golden-covered.** The router is threaded into `run-turn.ts` (`renderFlowFor`
   selects the operator flow text; the jobSeeker-enabled check at line 257), the
   TRACE schema (`caseKey`/`branchKey`, `run-turn.ts:345`), `agent.service`
   (`resolveRoutedCase` + `loadPresentSlots` + `usesPredicate`), `settings.ts`
   (`AgentBehaviour.routing`/`cases`/`renderCaseText`), `agent-admin.controller`, and
   the web Behaviour editor. The Stage-0 golden pin covers **system-prompt bytes
   only** — NOT `run-turn.ts`'s flow rendering or the trace path. `agent.service.test.ts`
   has **0** routing tests. With 0 custom cases the router is a byte-identical no-op
   (DMs always resolve `client` → `clientFlow`, already flowing via the DM stack's
   operator-steering module), so there is no behavioural urgency — but deleting it
   cascades through the trace schema + settings + the live DM path, and I cannot
   observe the live DM turn from this seat (needs the backend). It deserves its own
   guarded stage that first EXTENDS the golden/DM-path coverage to `run-turn` flow
   rendering, then deletes.
   - **Inventory bar (gate bar 10) is already satisfied** by the orchestrator's
     0-custom-cases enumeration (recorded above) — nothing is dropped silently.

3. **ManyChat import** — explicitly backlog (kickoff "import itself stays backlog").
   Trigger/keyword modules are its future target; boarded, not built.

**Recommended split for the next kick:** (6a) build Trigger/keyword + Knowledge
module TYPES with editors in the Situations screen (make content representable);
(6b) redirect the three screens + kill their sub-nav (safe once 6a lands);
(6c) a guarded routing.ts retirement stage (extend coverage first). All three are
boarded deferrals; the inventory bar is closed. HANDOFF + board updated.

### Stage 6a — trigger + knowledge module editors in Situations · DONE `35359da` (shared) · `416bc86` (web)

The precondition for the redirects: two managed-data module TYPES, edited inside
the Situations stack as a **lens over the EXISTING stores** (no forked storage).

- **Engine (`35359da`)** — `trigger` + `knowledge-corpus` added to `MODULE_TYPES`
  / `moduleSchema` / `MODULE_REGISTRY`, both **compile → null**. Seeded where
  their live effect runs: `trigger` in the comment-replies stack (`matchTrigger`
  runs BEFORE the LLM in the comment path), `knowledge-corpus` in the DMs stack
  beside the fixed `knowledge` lookup (the store `search_knowledge` retrieves at
  runtime). Because they contribute NO prompt prose the **golden pin holds
  byte-identical** (0 snapshots). `describeSituation` surfaces them as non-seeded
  rows with human labels ("Trigger rules" / "Knowledge corpus"). NO API change —
  the new rows flow through `describeSituations` automatically. The trigger module
  is the **future ManyChat-import target** (import itself stays backlog).
- **Web (`416bc86`)** — `TriggerModuleEditor` (list/create/enable-disable/delete
  over `/api/agent/triggers`) + `KnowledgeModuleEditor` (corpus library with
  filter/category/reindex/toggle/delete, scope-truth line, retrieval tester, over
  `/api/agent/knowledge`; the rich doc editor stays at `/agent/knowledge/{new,:id}`,
  linked). `Situations.tsx` renders them in the stack. **Canvas-honesty:** both
  are managed-data blocks that run OUTSIDE the prompt — no channel-variant
  strike/append gesture, tagged "Runs before the AI" / "Runtime data", and the
  final-prompt panel says "runs outside the prompt, no text sent" (never "sent
  as-is"). "What the bot sees" NOT duplicated (Situations' final-prompt panel owns
  it — one concept, one surface). Pure `--cm-*` (reuses the token-pure
  triggers.css/knowledge.css).

**Oracles (verbatim tails):**
- shared `… src/agent/modules.test.ts situations.test.ts situations-golden.test.ts`
  → `Test Files 3 passed (3) · Tests 84 passed (84)`; full `… src/agent`
  → `Test Files 38 passed (38) · Tests 452 passed (452)`; golden 30, **0 snapshots
  written** (byte-identical). shared build + typecheck exit 0; api nest build +
  `situations.controller.test` 15 passed.
- web `… Situations.test.tsx` → `Test Files 1 passed (1) · Tests 22 passed (22)`
  (4 new: corpus lens + no gesture, toggle via the existing API, trigger lens +
  create, final-prompt honesty). web typecheck + build exit 0; lint 0 errors.
- **Screenshot evidence** (throwaway demo mount, pre-seeded query cache — no
  backend/auth; harness deleted, uncommitted):
  `plans/rethink/evidence/agent-scope-4-6a-{dms-knowledge-1440,comments-triggers-1440,comments-triggers-375}.png`
  — the DM stack with the Knowledge-corpus module (RUNTIME DATA, full corpus CRUD
  + retrieval tester), the comment-replies stack with the Trigger-rules module
  (RUNS BEFORE THE AI, full trigger CRUD), and the 375 reflow. Seeded prose modules
  keep their two-gesture control; managed-data modules host real CRUD, honestly.

### Stage 6b — retirements (partial: Triggers + Knowledge; Behaviour STOPPED) · DONE `a65ac86`

Two of the three screens redirect into Situations (safe now 6a landed); the third
is **STOPPED on an orphan finding** per the kickoff's explicit guard.

- **Redirects live now:** `/agent/triggers` → `/agent/situations`;
  `/agent/knowledge` → `/agent/situations` (the doc editor `/agent/knowledge/new`
  + `/:id` stays routable, linked from the corpus module). Their Agent sub-nav
  entries removed from the grouped sidebar. Deleted: `Triggers.tsx/.test`,
  `Knowledge.tsx/.test` (coverage migrated to the 6a module-editor tests), and the
  already-dead in-page `AgentNav.tsx/.test` (retired to the sidebar in `f72d49f`;
  its own note deferred the slim to agent-scope-4). Route pins + AppShell sub-nav
  fixtures updated deliberately.
- **⚠ Behaviour NOT redirected — ORPHANED-CAPABILITY FINDING (needs an
  orchestrator ruling).** The kickoff said Behaviour's content is representable via
  "seeded modules + operator-steering"; the code disagrees. Situations renders the
  **operator-steering** module (clientFlow/commentFlow) as a **read-only** non-
  seeded "Engine-fixed" block (`describeSituation`: `"seed" in m` is false for it),
  so the client/comment/jobSeeker **FLOW text** is NOT editable in Situations —
  Behaviour is its only surface. On top of that, Behaviour owns the **directive-1
  dynamic per-node fields** + the fixed-schema case blocks (identity/tone/
  house-rules/goal/steps/slots/constraints/examples) that `renderCaseText` folds
  into `clientFlow`/`commentFlow`/`jobSeekerFlow` (`settings.ts:441-443`) — live,
  prompt-affecting authoring with no home in Situations. This is exactly the
  "Behaviour-only editing capability beyond routing cases + what Situations already
  covers" the kickoff said to STOP on. Redirecting Behaviour would orphan it, so it
  stays routable + in the sub-nav. **Recommended follow-up:** either build a
  flow/operator-steering editor (and the dynamic-fields surface) into Situations,
  THEN redirect Behaviour — or the operator rules the flow-authoring lives on in
  Behaviour. routing.ts retirement (Stage 6c) stays deferred regardless.

**Oracles (verbatim tails):**
- web `… src/App.routes.test.tsx src/components/AppShell.test.tsx
  src/routes/agent/Situations.test.tsx` → `Test Files 3 passed (3) · Tests 83
  passed (83)` (route pins: /agent/triggers + /agent/knowledge redirect to
  /agent/situations; /agent/knowledge/new + /agent/behaviour still render; sub-nav
  drops Triggers + Knowledge).
- **Full web suite** `apps/web … (capped forks)` → `Test Files 80 passed (80) ·
  Tests 629 passed (629)`. web typecheck + build exit 0; lint 0 errors.

**Honest state vs the gate bar (bar 10, retirements):** partial. Triggers +
Knowledge retirements COMPLETE with redirects; their capability preserved in the
Situations module editors (no orphan). Behaviour redirect BLOCKED by the flow/
fields orphan finding above (reported, not improvised). routing.ts (6c) deferred.
Live deploy/publish + the live e2e stay the orchestrator's lane (no deploy from
this seat).

### Stage 6b-2 — the Behaviour fold + redirect · DONE `8dd68bc` (content fold) · `65ff5f8` (redirect + retire)

The orphan the prior seat STOPPED on is closed, and Behaviour redirects. Per the
ORCHESTRATOR RULING (the fold happens; Behaviour redirects ONLY after its editing
capability lives in Situations), the content editing is now a LENS in Situations.

- **Content fold (`8dd68bc`).** New `apps/web/src/routes/agent/content-editors.tsx`
  extracts Behaviour's reusable content editors verbatim (FieldListEditor,
  BaseSectionField, caseSeedFields/companySeedFields, sectionTextPatch,
  railProseChanged, MODE_CHANNEL, RAIL_PROSE_IDS) — routing deliberately NOT
  extracted (stays 6c). Situations gains a **"Behaviour content" layer** (a toggle
  beside "Channel variants"): the operator-steering module is now EDITABLE (its
  case flow — client for DMs, comment for comment-replies — with the directive-1
  dynamic fields + fixed-schema case blocks), the job-seekers module edits its base
  section + the jobSeeker flow, every seeded prose module edits its base section
  (directive 2), and the DMs situation leads with the Global house-rules (company
  fields). Saved through the EXISTING `patchAgentConfig` endpoint — the SAME
  `agentBehaviour` data (company/cases/sectionText), no forked storage, no
  migration. Rail-prose edits keep the typed CONFIRM. The **engine is untouched**
  (no `packages/shared` change) → the golden pin holds byte-identical, 0 snapshot
  writes (gate bar 2). Channel variants (putSituations) unchanged. Routing surfaced
  read-only via a note (0 custom cases; 6c owns the retirement).
- **Redirect + retire (`65ff5f8`).** `/agent/behaviour` → `/agent/situations`
  (`<Navigate replace>`); its grouped-sidebar sub-nav entry removed; `Behaviour.tsx`
  + `Behaviour.test.tsx` DELETED. Agent sub-nav end-state = Overview · Activity ·
  Review queue · Posts · Situations · Playground (Review-queue entry left as-is —
  the Activity end-state package owns its retirement). Content coverage migrated to
  `Situations.test.tsx` (8 tests); routing tests intentionally NOT migrated (they
  belong with routing.ts's 6c retirement).

**Editing-parity checklist (every non-routing Behaviour capability → its Situations home):**
- sectionText base overrides (directive 2, all seeded sections incl. rails) → each
  seeded module's `BaseSectionField` in the content layer (revert / remove / restore /
  rail typed-confirm all ported).
- company (house rules + custom dynamic fields, directive 1) → DMs "Global house
  rules" block (`fieldlist-sit-company`).
- cases.client / .comment content (fixed-schema + dynamic fields) → operator-steering
  module's flow editor (`fieldlist-sit-flow-{client,comment}`).
- cases.jobSeeker content → job-seekers module's flow editor (`fieldlist-sit-flow-jobSeeker`).
- routing cases + branches → EXCLUDED (6c); 0 custom cases live, seeded routing noted
  read-only. leadAssignee was Behaviour**Tab** (dead/unmounted), never Behaviour.tsx —
  not a live capability, no regression.

**Oracles (verbatim tails):**
- `apps/web … src/routes/agent/Situations.test.tsx` → `Test Files 1 passed (1) ·
  Tests 30 passed (30)` (8 new: operator-steering editable, client-flow field
  materialize → cases.client, base Tone → sectionText, rail typed-confirm, company
  add field, remove/restore REMOVED marker, comment flow, job-seeker flow).
- `apps/web … src/App.routes.test.tsx src/components/AppShell.test.tsx
  src/routes/agent/Situations.test.tsx` → `Test Files 3 passed (3) · Tests 91
  passed (91)` (/agent/behaviour → /agent/situations redirect pinned; sub-nav drops
  Behaviour).
- Full web suite `apps/web … (capped forks)` → `Test Files 79 passed (79) · Tests
  619 passed (619)`. web typecheck + build + lint (0 errors) exit 0.
- **Screenshot evidence** (throwaway demo mount, pre-seeded query cache — no
  backend/auth; harness deleted, uncommitted):
  `plans/rethink/evidence/agent-scope-4-6b2-content-{1440,375}.png` — the Behaviour
  content layer: Global house rules, base sections (real seed prose), the boundary
  rail (safety, no remove), the job-seeker base + flow, and the operator-steering
  Client/lead flow with its fixed-schema + free-text fields, all editable.

**Honest state vs gate bar 10:** Behaviour retirement COMPLETE with redirect;
full editing parity preserved (checklist above, no orphan). routing.ts (6c) remains
the only Agent-scope deferral. Live deploy/publish + the live e2e (edit a flow →
playground reflects it → revert byte-identical) stay the orchestrator's lane.

### Stage 6c — routing.ts predicate-router retirement · DONE `10738af`

The last Agent-scope deferral closed. The situations/module engine owns DM flow
selection now, so the routing-as-data predicate ROUTER is obviated (0 custom
cases live — inventory-closed). A **byte-identical no-op**: the golden pins prove
it (situations-golden 30, routing-equivalence 9, channel-profile 6 — held
byte-identical before/after, **0 snapshots written**). Guarded per the kickoff:
coverage extended to run-turn flow rendering FIRST, then deleted.

- **What was retired (shared).** `routing.ts` — removed `resolveRoutedCase`,
  `renderFlowFor`, `predicateHolds`, `caseMatches`, `overlay`, `RoutingContext`
  (the predicate router). `run-turn.ts:254` — the DM turn renders
  `behaviour.clientFlow` directly (was `renderFlowFor(behaviour,"external",null)`,
  which returned `clientFlow` — byte-identical input to `resolveSituationBehaviour`);
  dropped the `routedCase` option; trace records `caseKey "external"`/`branchKey null`
  (byte-identical to the old client→"external" mapping). `jobSeekerEnabled` still
  honoured (reads the dormant `behaviour.routing` flag).
- **What was retired (api).** `agent.service.ts` — `handleTurn` no longer resolves
  a routed case; removed `loadPresentSlots` + `usesPredicate` + their imports.
- **SCOPE — fence-bound to the ENGINE, not the stored data.** The routing DATA
  shape (`AgentRouting` + `normalizeAgentRouting`, `SEEDED_ROUTING`/`isSeededRouting`
  which `behaviourPromptVersion` reads for prompt-version byte-stability) is KEPT
  as dormant stored config — still carried on `agentBehaviour`, still mirrored by
  the API GET view + `agent-admin.controller` routing schema + web's `agent-api.ts`
  (which defines its OWN routing types). Deleting the data shape would cascade into
  the API view that web's runtime `BehaviourConfig.routing` consumes — a **web
  change**, which this stage's fence forbids ("no web changes expected"; the web
  seat owns apps/web). **Boarded follow-up:** if the operator wants the dormant
  routing config fully excised (shared type + API schema/view + web mirror), that
  is a deliberate cross-seat change for a future kick — nothing live reads it.
- **Coverage (guard-first).** Added a `run-turn.test.ts` guard pinning the DM
  client-flow rendering into the system prompt + trace `caseKey "external"` (green
  before AND after — the byte-identity oracle). `routing.test.ts` trimmed to the
  surviving data-shape tests (seed + normalizer). `structural-behaviour.test.ts`,
  `routing-equivalence.golden.test.ts`, `settings.ts`, `agent-admin.controller.ts`
  untouched (data shape kept).

**Oracles (verbatim tails):**
- Baseline (pre-change) shared `… src/agent` → `Test Files 38 · Tests 460`, golden
  pins green, 0 snapshots. After: shared `… src/agent` → `Test Files 38 passed (38) ·
  Tests 451 passed (451)` (−10 dead router tests, +1 guard), golden
  `situations-golden 30 · routing-equivalence 9 · channel-profile 6` all pass,
  **0 snapshots written**. shared build + typecheck exit 0.
- api `… src/agent src/instagram src/moderation` → `Test Files 29 passed · Tests
  418 passed`; the ONE failed file (`agent-admin.controller.test.ts`, 3 `exampleLimit`
  failures) is a **pre-existing** Stage-5 test drift (proven by `git stash` on clean
  HEAD — unrelated to routing, another seat's to fix). `agent.service.test.ts` 22/22
  green. api nest build + worker build + typecheck exit 0.
- **Deployed** (pre-authorized): VPS pull + build shared/api/worker as sopserver1,
  `pm2 restart channels-api channels-worker --update-env`, health
  `{"status":"ok","db":true,"redis":true}`.

Deviation: strict-pathspec commit of 5 files (shared routing/run-turn ±tests,
api agent.service) — the working tree is shared with several concurrent seats
(automation/feed/campaigns/reminders/leadrat/web/worker all had uncommitted edits);
none swept in. Golden `.snap` files show only git-autocrlf EOL churn (content diff
empty), not committed. NOT verified from this seat: the live DM turn observed
end-to-end (needs a real inbound; the guard test + pins stand in) — the live e2e
stays the orchestrator's lane.
