# agent-scope-13 — stage log

Work order: `agent-scope-13-kickoff.md`. Acceptance bar:
`situations-rebuild-mockup-annotations.md` § BINDING + § Absences.

---

## Stage 0 — the migration (DONE)

### 0a · `KnowledgeDocument.channels`

- `packages/db/prisma/schema.prisma` — `channels String[] @default([])` on
  `KnowledgeDocument`. Empty = applies to every channel, which is what all nine
  live rows keep.
- `packages/db/prisma/migrations/20260725160000_knowledge_document_channels/migration.sql`
  — `ALTER TABLE "knowledge_documents" ADD COLUMN IF NOT EXISTS "channels" TEXT[]
  NOT NULL DEFAULT '{}'`. Additive, idempotent, nothing rewritten.
- **Verified against a throwaway Postgres 16 container, NOT the live DB**
  (`docker run postgres:16-alpine`, port 55433, destroyed after): the full
  migration history plus this one applied clean ("All migrations have been
  successfully applied"), and `prisma migrate diff --from-url <shadow>
  --to-schema-datamodel` reports **no difference on this column** (the two
  differences it does report — the raw-SQL `tsv` generated column/indexes and
  `updated_at`'s Prisma-managed default — are pre-existing drift, present before
  this change).
- `prisma generate` + `@channels/db typecheck` green.
- Commit `f1d174c`. **NOT deployed** — `prisma migrate deploy` against production
  is the operator's.

### 0b · the one-way v1 → v2 converter

`packages/shared/src/agent/situations-v2.ts` (new). Shape, for `global` and every
situation: the five `INSTRUCTION_HEADINGS` entries, each
`{ text: string, channels: { <channel>: { text: string } } }`, plus the
situation's `channels` list. **Nothing else** — a test asserts the serialised v2
carries no `modules`, `added`, `restoredParked`, `replayGate`, `struck` or
`removed` key, from a v1 input that carried all of them.

- `toSituationsConfigV2(v1, { channel, behaviour, jobSeekerEnabled })` — seeds each
  heading's `text` from that heading's non-`live` members **in current stack
  order**, resolved through v1's precedence (behaviour › situation › global ›
  seed), joined as markdown under `## <productLabelFor(id, type)>`.
  - `live` modules are excluded from the field text (they are runtime-fed).
  - `added` with no channel targeting appends to its heading's base text;
    channel-targeted ones go to that heading's channel entry.
  - v1 channel appends (`entry.channels[ch][moduleId].text`) file under that
    module's own heading, in stack order, append-only.
  - v1's Global layer produces **no** v2 Global text: it was an override SOURCE,
    already resolved into each situation's field, never prose of its own — and
    `prompt-seed.ts` seeds Global nothing (kickoff ruling 4).
- `resolveHeadingFields(v2, situationId, channel)` — the read seam. Composition
  top to bottom: **Global · situation · channel append**.
- `normalizeSituationsConfigV2`, `seedSituationsConfigV2`, `SITUATION_CHANNELS`
  (dms → all four; the two comment situations → instagram + facebook).

**Oracle — no prose lost, by character accounting.**
`situations-v2.test.ts` converts a realistic v1 fixture exercising every v1
feature that can carry prose (Global override, situation override beating it, a
removal, per-channel appends on two channels, a rail append, custom instructions
both everywhere and channel-targeted, across two situations, plus a replay gate),
then for each situation:

1. reconstructs the expected prose **independently of the converter's grouping**,
   at the module level (`situationSectionOverrides` → `compileModule`);
2. removes each expected string from the concatenated v2 output exactly once,
   asserting it was found and never found twice;
3. asserts the residue holds nothing but `## label` lines and whitespace;
4. asserts `proseChars(v2 output) === Σ proseChars(expected)` — scaffolding and
   whitespace stripped from both sides, so one character more is duplication and
   one fewer is loss;
5. asserts `#{## labels} === #{compiled non-live modules}`.

It does NOT re-verify `situationSectionOverrides` itself — `situations.test.ts`
owns that.

---

## Stage 1 — assembly, and the goldens re-pinned (DONE)

Commit `8763a8b`.

### What assembly does now

`assembleSituation(id, opts)`: for each heading in `INSTRUCTION_HEADINGS` order,
the operator's field (`SystemPromptOptions.headings[heading]`, else
`seededHeadingText`), then — after every field — the situation's `live` runtime
nodes, ordered among themselves by heading.

**Decision, flagged: the `live` nodes TRAIL every field rather than sitting
immediately under their own heading.** All four file under `Knowledge`, which is
4th of five, so interleaving them would put the volatile block *before* the
static Guardrails field. That breaks two standing engine properties at once: the
documented volatile-last rule that makes the static prefix byte-identical across
contacts and cacheable org-wide (`splitSystemPrompt`), and the golden invariant
that `staticPrompt + dynamicPrompt === buildSystemPrompt(opts)` — i.e.
`buildSystemPrompt` would stop describing what is actually sent. Acceptance
criterion 2 still holds: they appear under their heading's field, after it, still
`volatile`, pinned by two tests.

Section ids are now the heading slugs: `role` · `goal` · `tone` · `knowledge` ·
`guardrails`, then `contact` · `returning` · `after-hours` · `lead-profile`.

### Deleted

| Symbol | Where | Note |
|---|---|---|
| `NON_REMOVABLE_SECTION_IDS` | `prompt-seed.ts:285` | `isSectionRemoved` drops its `id` arg; a REMOVED marker now really removes a rail |
| `spliceInstructions` | `modules.ts:452` | replaced by heading grouping |
| `PARKED_FOR_FLOWS`, `isParkedForFlows` | `modules.ts` | see the flag below |
| `restoredParked`, `normalizeRestoredParked`, `situationRestoredParked` | `situations.ts`, `system-prompt.ts` | the restore gesture is a listed Absence |
| `struck` (type, normalize, compose) | `situations.ts` | channel axis is append-only; a stored `struck` degrades to an append |
| `ReplacedBlock`, `listReplacedBlocks` | `situations.ts` | the never-forget list existed only for replacement |
| `isRailModule`, `moduleTypeIn` | `situations.ts` | nothing to guard once replacement is gone |
| `situationCustomInstructions`, `customInstructions` opt | `situations.ts`, `system-prompt.ts` | `added` folds into field text at conversion |
| `resolveSituationBehaviour` | `situations.ts` | replaced by `toSituationsConfigV2` + `resolveHeadingFields` |
| `ModuleDescriptor.parked` | `situations.ts` | no Parked box |

`situationSectionOverrides` lost its `channel` argument — it is the BASE layer
only (Global → situation), because folding channel appends in there as well would
double them once the converter files them under the heading's channel entry.

Kept deliberately: the `instruction` module TYPE in `MODULE_REGISTRY` (unused now
that nothing constructs one, but its removal cascades into `MODULE_TYPE_LABELS`,
the leak-guard test and the API DTOs — a follow-up for Stage 2/3, not silent
scope creep here) and `replayGate` on the v1 type (Stage 2 deletes the gate).

### ⚠ FLAG — Parked-for-Flows content is back in the DM prompt

Deleting `PARKED_FOR_FLOWS` (kickoff Stage 1, explicit) puts its seven modules —
`services` · `job-seekers` · `lead-capture` · `lead-temperature` ·
`qualification-slots` · `extra-contact` · `offering-choices` — back into the
composed Conversations prompt. That is roughly +5 000 characters, and it is the
single largest golden move. **Stage 5 must relocate this content to its markdown
file before any deploy**, or the live DM prompt regains prose that was
deliberately parked on 2026-07-24. Not fixed here: removing those entries from
`DMS_STACK` is Stage 5's work order, not mine.

### ⚠ FLAG — conversion bakes one channel into the base text

Several seeds are channel-shaped (`identity` names the channel; `lead-capture`,
`extra-contact`, `tone`, `offering-choices` read the channel profile). Once the
operator owns ONE text per heading, that text cannot vary by channel any more —
per-channel differences have to be typed as channel appends. Mitigations in place:
`assembleSituation` still computes the seeded default **per channel** whenever no
stored field exists, and `run-turn.ts` converts at read time on the turn's real
channel rather than reading a config frozen at one. The freeze only bites once
Stage 2 persists v2. Worth a sentence to the operator at gate.

### ⚠ FLAG — `clientFlow` / `commentFlow` / `jobSeekerFlow` fold into the field

`operator-steering` and `job-seekers` are ordinary members of the Goal field, so
their prose is part of the seeded default. Once the operator edits the Goal field,
his Content-screen steering text no longer re-injects itself — his own words are
now the single source. Consistent with "the module stack stops existing as an
operator-facing concept", but it is a behaviour change nobody has said out loud.

### Golden moves — one row per moved snapshot

35 snapshots moved. Three ruled reasons, abbreviated:

- **R1 — one field per heading.** Section ids become heading slugs; module prose
  regroups by heading instead of interleaving by stack order; each module's prose
  gains a `## <plain-words label>` sub-heading.
- **R2 — Parked-for-Flows deleted.** The named modules compose again (see the flag
  above). Only the DM/external cases carry this.
- **R3 — nothing is fixed.** A REMOVED marker on a rail now removes it. This one
  moved no *snapshot* — it rewrote three equality tests (listed after the table).

| Snapshot | chars | Why it moved |
|---|---|---|
| channel-profile · full external prompt (name/returning/afterHours/leadState/behaviour) | 4261→9516 | R1 + R2 (Services, Job applicants, Saving a lead, Rating the lead, What it asks for, Callback number and time, Tappable buttons and lists) |
| channel-profile · minimal external prompt | 3270→8360 | R1 + R2 (all seven) |
| routing-equivalence · comment prompt | 1497→1576 | R1 only |
| routing-equivalence · moderation prompt | 1907→1960 | R1 only |
| routing-equivalence · instagram external (v2 behaviour) | 3612→8058 | R1 + R2 (six; no Tappable buttons — Instagram has no interactive markers) |
| routing-equivalence · whatsapp full (v2 behaviour + name/returning/afterHours/leadState) | 4473→9849 | R1 + R2 (all seven) |
| routing-equivalence · whatsapp minimal (empty behaviour) | 3270→8360 | R1 + R2 (all seven) |
| routing-equivalence · comment / moderation section ids | 184→130 | R1 only (ids → role/goal/guardrails) |
| routing-equivalence · external (full) section ids | 196→127 | R1 only |
| situations · facebook comment with operator commentFlow | 1381→1460 | R1 only |
| situations · instagram comment with operator commentFlow | 1382→1461 | R1 only |
| situations · instagram full (name/returning/afterHours/leadState + edited behaviour) | 3626→7951 | R1 + R2 (six) |
| situations · whatsapp full (name/returning/afterHours/leadState + edited behaviour) | 3625→8880 | R1 + R2 (all seven) |
| situations · whatsapp job-seeker section DISABLED | 3270→8186 | R1 + R2 (six — Job applicants correctly still absent, the F2 hook still works) |
| situations · whatsapp with a REMOVED seeded section (pivots) | 2949→8011 | R1 + R2 (all seven); `pivots` still absent |
| situations · whatsapp with an operator clientFlow append | 3398→8515 | R1 + R2 (all seven) |
| situations · facebook moderation (seed) | 1906→1959 | R1 only |
| situations · instagram moderation with an edited taxonomy | 726→779 | R1 only |
| situations · facebook / comment (seed) | 1244→1296 | R1 only |
| situations · facebook / external (seed) | 3270→7430 | R1 + R2 (six) |
| situations · facebook / moderation (seed) | 1906→1959 | R1 only |
| situations · instagram / comment (seed) | 1245→1297 | R1 only |
| situations · instagram / external (seed) | 3272→7432 | R1 + R2 (six) |
| situations · instagram / moderation (seed) | 1907→1960 | R1 only |
| situations · web / comment (seed) | 1239→1291 | R1 only |
| situations · web / external (seed) | 3260→8350 | R1 + R2 (all seven) |
| situations · web / moderation (seed) | 1901→1954 | R1 only |
| situations · whatsapp / comment (seed) | 1244→1296 | R1 only |
| situations · whatsapp / external (seed) | 3270→8360 | R1 + R2 (all seven) |
| situations · whatsapp / moderation (seed) | 1906→1959 | R1 only |
| situations · comment / moderation section ids | 159→130 | R1 only |
| situations · external (full, all live nodes + operator section) section ids | 196→127 | R1 only |
| situations · external (minimal seed) section ids | 110→64 | R1 only |
| situations · external on instagram (no interactive-choices section) section ids | 110→64 | R1 only |
| situations · full external split (static prefix + volatile suffix) | 3669→8924 | R1 + R2 (all seven). The boundary itself did NOT shift: all five fields are static, all four live nodes volatile |

Classification was produced mechanically (old vs new snapshot body, per name), not
by eye. Three snapshots did **not** move and were left alone: the two
`behaviourPromptVersion` pins in `routing-equivalence` and the tool-definitions
pin in `channel-profile` — none of them touches prompt prose, which is the
expected result.

**Non-snapshot golden assertions rewritten (R3).** Three `toBe(buildSystemPrompt(…))`
equality tests asserted a REMOVED marker on a rail reverts to seed. They now assert
the opposite, with the reversal named in the test title:
`situations-golden.test.ts` × 3 (boundaries · comment safety-rail · moderation
taxonomy), plus the same reversal in `prompt-seed.test.ts`, `modules.test.ts` and
`situations.test.ts`.

`graded-examples-never-in-prompt.test.ts` (4 tests) is **untouched and green**.

### Other test surgery (not goldens)

- `prompt-inventory.test.ts` — the "nothing hidden" pin was keyed on emitted
  *section* ids, which are now headings. Retargeted at the MODULE ids that
  actually contribute (`SITUATIONS[…].filter(m => compileModule(m, opts) !== null)`),
  which is where module identity still exists. Same guarantee, right level.
- `structural-behaviour.test.ts` — same retarget for the F2 job-seeker hook.
- `system-prompt.test.ts`, `situations.test.ts`, `modules.test.ts`,
  `prompt-seed.test.ts` — parked/struck/splice tests replaced by the v2 equivalents.

### Mutations — every new test seen to fail

| # | Mutation | Observed |
|---|---|---|
| 1 | `seededHeadingText` iterates `staticHeadingModules(...).slice(0, -1)` | 9 red. `LOST from dms: You are Silver Oak's one voice…: expected -1 to be >= 0`; char count `4885` vs `5685` |
| 2 | converter drops the channel filter on targeted `added` | 4 red. `DUPLICATED in dms: Never name a competitor agency.`; char count `5944` vs `5685` |
| 3 | `assembleSituation` stops emitting `live` modules | 2 red, in both suites. `contact must still assemble: expected [ 'role', 'goal', 'tone', …(2) ] to include 'contact'` |
| 4 | converter files channel appends without the heading filter | 4 red. `DUPLICATED in dms: On Instagram keep it to two lines.`; char count `6029` vs `5685` |
| 5 | `resolveHeadingFields` drops the Global layer | 2 red. `expected 'SITUATION role.\n\nIG addendum.' to be 'GLOBAL role.\n\nSITUATION role.\n\nIG…'` |
| 6 | `??` → `\|\|` on the field fallback (an emptied field silently re-seeds) | 2 red. `expected [ 'role', 'goal', 'tone', …(2) ] to not include 'guardrails'` |

Every mutation was restored and the suite re-run green after each.

### Verification

- `cd packages/shared && npx vitest run --pool=forks --poolOptions.forks.maxForks=2`
  → **101 files / 1193 tests, all green** (baseline before the stage: 100 / 1176).
- `corepack pnpm --filter @channels/shared typecheck` green.
- `rm -rf packages/shared/dist && corepack pnpm --filter @channels/shared build` green.
- `corepack pnpm --filter @channels/worker typecheck` green;
  `corepack pnpm --filter @channels/db typecheck` green.

### API + web breaks handed to Stages 2 and 3

`apps/api` — 11 errors, all in three files:

- `src/agent/agent-admin.controller.ts` — imports `resolveSituationBehaviour`,
  `situationCustomInstructions`, `situationRestoredParked` (all deleted); passes
  `customInstructions` at `:307`.
- `src/agent/situations.controller.ts` — imports `isRailModule`,
  `listReplacedBlocks`, type `ReplacedBlock` (all deleted). Also still carries the
  `struck` Zod field, the rail 400-guard and `restoredParked` in its DTO, which
  Stage 2 deletes anyway.
- `src/instagram/comment-policy.service.ts` — imports
  `resolveSituationBehaviour` + `situationCustomInstructions`; passes
  `customInstructions` at `:124` and `:178` (the option is now `headings`).

`apps/web` — 24 errors in `src/lib/agent-api.ts`, `src/routes/agent/Situations.tsx`
and `Situations.test.tsx`, all on `ReplacedBlock` / `struck` / `parked` /
`restoredParked`. Stage 3 rebuilds that screen from scratch, so none needs fixing
in place.

### Not verified

- The oracle ran against a **hand-authored** realistic v1 fixture, not the live
  production `Setting` row: this machine has no `~/.ssh/sop_vps` key, so the prod
  row could not be read. The kickoff's "run the converter once against the prod
  row and capture the before/after prompt diff" is still outstanding.
- The migration was proven on a shadow container only. Not applied to production.
- Nothing was deployed, restarted or published.
- `apps/web` was not built (it cannot be, until Stage 3).

---

## Stage 5 — the parked content removed, and its machinery with it (DONE)

Stage 1's ⚠ FLAG above is closed. The seven modules parked on 2026-07-24 —
`services` · `job-seekers` · `lead-capture` · `lead-temperature` ·
`qualification-slots` · `extra-contact` · `offering-choices` — are removed from
`DMS_STACK` outright, not re-parked behind a flag. Their prose is preserved in
`docs/archive/parked-for-flows-content.md`. Commit `c6ad6c9`.

### The oracle — assembled prompt size

Measured with a probe against the built `dist`, on the pure seed
(`{ cases:{}, company:{}, sectionText:{} }`), at three points:

| mode/channel | deployed (`f1d174c`) | after Stage 1 | after Stage 5 |
|---|---|---|---|
| external/whatsapp | 3260 | 8330 | **3413** |
| external/instagram | 3262 | 7402 | **3415** |
| external/facebook | 3260 | 7400 | **3413** |
| comment/whatsapp | 1242 | 1294 | **1294** |
| comment/instagram | 1243 | 1295 | **1295** |
| comment/facebook | 1242 | 1294 | **1294** |
| moderation/whatsapp | 1902 | 1955 | **1955** |
| moderation/instagram | 1903 | 1956 | **1956** |
| moderation/facebook | 1902 | 1955 | **1955** |

The "deployed" column is `f1d174c` — the last commit before Stage 1, i.e. the
code actually running in production — built and probed in this session, not
recalled.

**The residual +153 on external is not leakage, it is the `##` headings.** A
`diff` of the two full prompts shows the ONLY difference is eight added
`## <plain-words label>` lines plus Stage 1's heading regrouping (tone moves
above knowledge/boundaries). Not one character of prose differs from what is
deployed today. Heading overhead is per situation, not a flat 52: external
carries 8 sub-headings (+153), comment 3 (+52), moderation 3 (+53). The
kickoff's "≈3,300 / anything above ~3,400 is leakage" bar assumed external also
paid 52; the byte diff is the stronger evidence and it says 3,413 is correct.

The dead-machinery deletions below moved **zero** prompt bytes (prompts
re-dumped and `diff`ed before/after: identical).

### Dead machinery — removed

| Symbol | Where | Why it was dead |
|---|---|---|
| 6 `PROMPT_SEED` entries (`services`, `job-seekers`, `lead-capture`, `lead-temperature`, `qualification-slots`, `extra-contact`) | `prompt-seed.ts` | no module compiles them; an orphan seed silently resurrects prose the moment an id reappears |
| `job-seekers` module TYPE | `modules.ts` (`MODULE_TYPES`, `MODULE_SCHEMAS`, union, `MODULE_REGISTRY`), `situations.ts` (`MODULE_TYPE_LABELS`), `instruction-headings.ts` | nothing constructs one |
| `tappable-choices` module TYPE + its `[[buttons]]`/`[[list:]]` compile | same set | ditto |
| `SystemPromptOptions.jobSeekerEnabled` | `system-prompt.ts` | its only reader was the `job-seekers` compile |
| `ConvertOptions.jobSeekerEnabled` + its spread | `situations-v2.ts` | threaded the above |
| the `jobSeekerEnabled` derivation off `routing.cases` | `run-turn.ts` | nothing left to omit |
| `SystemPromptOptions.behaviour.jobSeekerFlow` | `system-prompt.ts`, `run-turn.ts` | read only by the `job-seekers` compile |
| `ChannelProfile.identityNote` / `.altContactNote` / `.interactiveInstructions` | `channel-profile.ts` | readers were the `lead-capture` / `extra-contact` seeds and the `tappable-choices` compile |
| 7 `HEADING_BY_ID` rows + 7 `PRODUCT_LABEL_BY_ID` rows + 2 per-TYPE rows | `instruction-headings.ts` | no module resolves those ids/types |
| the `offering-choices` inventory row | `prompt-inventory.ts` | the "no stale entries" pin was already red on it — it was doing its job |
| `"offering-choices"` in `NON_SEEDED_EMITTED` | `prompt-inventory.test.ts` | an exemption for a section nothing emits |

### Dead machinery — deliberately KEPT

- **`SERVICE_LINES` (the 10 service lines)** — nothing in `src/` imports it any
  more, but it is a DOMAIN table, not machinery: it is the canonical arm list the
  funnel flows (channels-scope-5, the named successor owner) and lead routing
  need, it is re-exported from the package barrel, and deleting it would destroy
  a fact rather than remove a stub. Its doc comment now says it no longer feeds
  the prompt.
- **`AgentBehaviour.jobSeekerFlow` (the stored setting)** — see the flag below.
  It is operator DATA with its own screen, its own Zod schema, its own v1→v2
  case model and its own API DTO; deleting it deletes operator content, which is
  not Stage 5's call.
- **The `[[buttons]]` / `[[list:]]` marker mechanic** — very much alive. The
  `triage` seed still asks for a tappable `[[list: ...]]`, and `interactive-scrub`
  + the outbound adapters still parse both. What went is only the prompt block
  that documented the syntax.
- **the `instruction` module type** — unchanged from Stage 1's ruling.

### ⚠ FLAG — the operator's "Job-seeker instructions" field is now inert

`AgentBehaviour.jobSeekerFlow` is still stored, still editable on the Behaviour
tab (`BehaviourTab.tsx`), still validated by the API — and since this stage it
reaches NO prompt. Whatever the operator types there is silently ignored. Same
for the job-seeker case's `enabled` toggle in `routing.cases`, which no longer
omits anything. Stage 2/3 own that surface; either the field is deleted from the
screen + the settings blob, or its text is re-homed into the Goal heading field.
It must not stay as a control that does nothing.

### Test decisions — one row per failing test

| Test | Verdict | Why |
|---|---|---|
| `system-prompt` · lists every service line, under the Goal field | **inverted** → "carries NO service catalogue" | the `services` seed is gone; asserts the catalogue heading is absent and that exactly ONE line name survives (`triage` says "lead with Real Estate Investment Advisory") |
| `system-prompt` · carries the slot-collection guidance under Goal | **inverted** → "carries NONE of the seven Parked-for-Flows modules" | one pin now covers all seven: 8 prose patterns + 7 `## label` sub-headings, all asserted absent |
| `system-prompt` · carries the lead-temperature rubric under Goal | **deleted** | folded into the pin above |
| `system-prompt` · extends the job-seekers paragraph with operator instructions | **deleted** | the input it exercised (`behaviour.jobSeekerFlow`) no longer exists on `SystemPromptOptions` |
| `modules` · the previously parked flow content is back in the Goal field | **inverted** → "…are GONE from the Goal field" | plus a positive half: the four surviving Goal members must still be there |
| `modules` · jobSeekerEnabled=false drops that paragraph | **deleted** → replaced by "their module ids are gone from the DM stack" | the option is gone; the replacement pins the STACK, so a re-add is caught even if labels change |
| `modules` · the tappable-choices module drops on a channel with no interactive markers | **deleted** | the type is gone; there is no module to compile |
| `modules` · joins a heading's members in stack order | **fixed** | used `## Services it talks about` as the ordering probe; now `## How it opens a chat` |
| `structural-behaviour` · omits the job-seekers prose when disabled | **deleted** | was passing VACUOUSLY after the removal — the worst possible state for a pin |
| `structural-behaviour` · default (undefined) keeps it | **deleted** | asserted the module is present |
| `structural-behaviour` · disabling changes only that paragraph | **deleted** | ditto; the three are replaced by one "emits no job-seekers module on any DM turn" |
| `channel-profile` · WhatsApp includes the [[buttons]] block + phone identity | **inverted** → "carries no prompt-side [[buttons]] usage block on WhatsApp either" | also pins that the `[[list: ...]]` marker in `triage` SURVIVES, so the deletion is scoped to the syntax block |
| `channel-profile` · Instagram omits the interactive block and WhatsApp phrasing | **inverted** → "leaks NO WhatsApp-ism into the Instagram prompt" + a new "names the live channel in identity and tone" | the loud divergence is gone with the sections that carried it; what still diverges is the channel NAME, which now has its own pin |
| `prompt-seed` · has a seed entry for every editable section id | **fixed** (list narrowed) + new "has NO seed entry left for the six" | the absence needs pinning too, or an orphan seed creeps back |
| `prompt-seed` · extra-contact — interactive syntax is channel-conditional (3 tests) | **deleted** → replaced by "no surviving seed emits a literal `[[buttons]]` block on any channel" | the gate had no section left to gate; the replacement is stronger (it sweeps EVERY seed × EVERY channel) |
| `prompt-inventory` · has no stale entries | **kept, code fixed** | a real catch — it named `offering-choices`; the inventory row was deleted |
| `situations` · a `removed` override projects the REMOVED marker | **fixed** | used `services` as the vehicle module; now `pivots` |
| `situations` · a REMOVED base-section marker surfaces as removed | **fixed** | same swap |
| `situations-golden` · whatsapp job-seeker section DISABLED | **deleted** (test + snapshot) | pinned a variation that no longer exists |

`editedBehaviour` in `situations-golden.test.ts` lost its `jobSeekerFlow` key
(the shape `run-turn` hands the assembler no longer has one), and
`"external on instagram (no interactive-choices section) ids are stable"` was
renamed — its name asserted a distinction that no longer exists.

### Snapshot moves — 15 (14 re-pinned, 1 deleted)

All 15 moved for ONE reason: **R2 — the seven Parked-for-Flows modules no longer
compose**. No snapshot moved for any other cause; comment- and moderation-mode
snapshots did not move at all, which is the proof the change is scoped to DMs.

| Snapshot | chars | Note |
|---|---|---|
| channel-profile · full external prompt | 9516→4441 | |
| channel-profile · minimal external prompt | 8360→3423 | |
| routing-equivalence · instagram external (v2 behaviour) | 8058→3792 | six (IG never had `offering-choices`) |
| routing-equivalence · whatsapp full | 9849→4653 | |
| routing-equivalence · whatsapp minimal | 8360→3423 | |
| situations · instagram full | 7951→3806 | six |
| situations · whatsapp full | 8880→3805 | |
| situations · whatsapp REMOVED section (pivots) | 8011→3074 | `pivots` still absent |
| situations · whatsapp operator clientFlow append | 8515→3578 | |
| situations · whatsapp / external (seed) | 8360→3423 | |
| situations · instagram / external (seed) | 7432→3425 | six |
| situations · facebook / external (seed) | 7430→3423 | six |
| situations · web / external (seed) | 8350→3413 | |
| situations · splitSystemPrompt full external split | 8924→3849 | |
| situations · whatsapp job-seeker section DISABLED | 8186→**deleted** | the variation no longer exists |

(Lengths are the escaped snapshot bodies, +2 vs the raw prompt.)

### Mutations — every new/inverted test seen to fail

| Mutation | Observed red |
|---|---|
| **M1** — re-add the `services` seed + a `guidance` module for it to `DMS_STACK` + its product label | `system-prompt` · carries NO service catalogue; `system-prompt` · carries NONE of the seven; `modules` · GONE from the Goal field; `modules` · ids gone from the DM stack; `prompt-seed` · NO seed entry left (5 red) |
| **M2** — re-add the `job-seekers` seed + a guidance module for it | `structural-behaviour` · emits no job-seekers module; `system-prompt` · carries NONE of the seven (2 red) |
| **M3** — append `[[buttons]]` to the surviving `triage` seed | `prompt-seed` · no surviving seed emits a literal `[[buttons]]`; `channel-profile` · leaks NO WhatsApp-ism into Instagram; `channel-profile` · carries no prompt-side `[[buttons]]` block on WhatsApp (3 red) |
| **M4** — hardcode `whatsapp` in the `identity` seed instead of the channel | `channel-profile` · names the live channel in identity and tone (1 red) |

Each mutation was reverted from a byte-for-byte backup and the suite re-run
green afterwards.

### Verification

- `cd packages/shared && npx vitest run --pool=forks --poolOptions.forks.maxForks=2`
  → **101 files / 1187 tests, all green** (1193 before: 12 tests deleted, 6 added).
- `corepack pnpm --filter @channels/shared typecheck` green;
  `rm -rf dist && corepack pnpm --filter @channels/shared build` green.
- `corepack pnpm --filter @channels/worker typecheck` green.
- Probe run against the freshly built `dist` — table above.

### NEW api/web breaks this stage adds

`apps/api` — **none**. Still exactly the 11 errors Stage 1 handed over, same
files, same symbols.

`apps/web` — **2 new** (24 → 26), both from deleting the `job-seekers` module
TYPE, both in the screen Stage 3 rebuilds from scratch:

- `src/routes/agent/Situations.tsx:595` — `if (m.type === "job-seekers")` no
  longer overlaps `ModuleType`.
- `src/routes/agent/Situations.test.tsx:862` — a fixture module with
  `type: "job-seekers"`.

Not a type error but dead all the same, for whoever touches that file:
`src/routes/agent/content-editors.tsx:127` — `const removable = !isRail && id !== "job-seekers"`
now guards an id that can never appear.

### Not verified

- **Nothing was deployed, restarted or published.** The built `dist` is local.
- The probe uses the pure SEED (empty behaviour). The live production `Setting`
  row was not read — this machine has no `~/.ssh/sop_vps` key — so the prompt an
  operator with stored overrides actually gets is inferred, not measured. Stage
  1's identical caveat still stands.
- `apps/web` was not built (it cannot be until Stage 3).
- No prisma command was run against any database.

---

## Stage 2 — api (DONE)

Commits `0983414` (the v2 situations API) and `e317b9b` (knowledge channels).

### The shape of the fix

The 11 compile errors were not patched at the import line — all three files
moved to the v2 API.

| File | Was | Now |
|---|---|---|
| `src/agent/situations-store.ts` (**new**) | — | `readSituationsV2(settings)` — the ONE v2 read seam |
| `src/agent/situations.controller.ts` | v1 modules + the replay GATE | v2 fields + an explicit, priced replay ACTION |
| `src/agent/agent-admin.controller.ts` | `resolveSituationBehaviour` + `customInstructions` + `restoredParked` | `resolveHeadingFields(...)` → `headings` |
| `src/instagram/comment-policy.service.ts` | same three | same |
| `src/moderation/replay.service.ts` | `situationsOverride?: SituationsConfig` | `SituationsConfigV2` |
| `src/knowledge/knowledge-admin.controller.ts` + `.service.ts` | — | `channels` on create/patch/list |

### Where v2 is STORED, and the one call that had to be made

There is still ONE row (`agentSituations`). A row in v1 shape is converted **on
read** (`readSituationsV2`), so the operator opens the screen and his own prose
is already in the five fields; the row is only rewritten as v2 when he **saves**.
That is the one-way migration, and it is his click rather than a deploy step.
No second key, no dual store — one fact, one home.

Conversion runs once per situation at that situation's `SITUATION_DISPLAY_CHANNEL`
(dms → whatsapp, both comment situations → instagram), because
`toSituationsConfigV2` bakes one channel's wording into the base text.
`commentFlow` is resolved through `resolveFlow` before conversion — passing the
raw (unset) value would convert the seeded comment flow to a BLANK field and
silently delete that prose from the comment prompt.

### The replay gate — deleted, and what replaced it

`PUT` no longer previews, runs, gates or 409s. Saving is free and is never
refused (`replay.preview`/`replay.run` are asserted never called on a save).

`POST /api/agent/situations/replay` is the replacement:

- `{ filter?, config? }` — prices the run (`{ ran:false, count, estimateUsd }`)
  and sends nothing to a model.
- `{ ..., confirm: true }` — runs the labelled-set comparison against the DRAFT
  config the operator is editing (never persisting it) and returns the per-turn
  diff plus `regressions`, the previously-RIGHT decisions this config changes.

Also gone: the rail 400-guard (`rejectRailOverride` / `isRailModule`), the
`struck` Zod field, the `restoredParked` DTO, `replayGate`, `modules` and
`added`. v2 cannot EXPRESS replacement, so there is nothing left for a
server-side guard to reject — the strict schema refuses the whole v1 vocabulary
at the boundary instead (one `it.each` row per deleted key).

### BLOCKER — the DM runtime cannot read a v2 row (frozen `packages/shared`)

`run-turn.ts:241` still reads the row through `normalizeSituationsConfig` (v1)
and converts. The moment a v2 save lands, the DM prompt loses the operator's
text. Measured, not inferred — probe against the built `dist`:

```
run-turn sees v1 = {"version":1,"global":{"modules":{}},"situations":{}}
operator text survives? false
Role field it would use: "## Who the bot is\nYou are the assistant for Silver Oak Prope"
```

(`normalizeChannels` drops v2's `channels: ChannelId[]` — it expects a record —
and `normalizeLayer` finds no `modules`, so the whole entry collapses.)

The fix is one seam in `run-turn.ts`: read the row the way
`apps/api/src/agent/situations-store.ts` does (a `version: 2` tag ⇒
`normalizeSituationsConfigV2`, else convert). **`packages/shared` is frozen for
this seat, so it was NOT made.** Nothing in this stage is deployed, so nothing
is live yet — but **this must land before any deploy**, or the first save on the
Situations screen silently reverts the DM prompt to the bare seed. The comment
paths are already correct (they read through `situations-store.ts`).

Second-order: `situations-store.ts` should collapse into that shared seam once it
exists, rather than staying a parallel implementation.

### FLAG — the API view no longer carries module rows

`describeSituations` / `SituationDescriptor` / `MODULE_TYPE_LABELS` are now
unused by `apps/api`. Left in `packages/shared` (frozen); a follow-up should
delete them together with the `instruction` module type Stage 1 deliberately
kept.

Stage 5's flag about `AgentBehaviour.jobSeekerFlow` being an inert control is
**not closed** — it is still stored, still validated by `behaviourSchema`, still
on the Behaviour tab, and still reaches no prompt. That is Stage 3's surface.

### Mutations — every new/changed test seen to fail

Each backed up byte-for-byte, mutated, run, restored, re-run green.

| # | Mutation | Observed red |
|---|---|---|
| M1 | `readSituationsV2` skips the v1 conversion (returns blank v2 fields) | **12 red.** `expected '' to contain 'Silver Oak'`; 7 of them in `comment-policy.service.test.ts` — the conversion is what keeps the comment prompt whole |
| M2 | the read path ignores the `version: 2` tag (always re-converts) | **7 red.** `expected '## Who the bot is…' to be 'MY GOAL.'` — a stored v2 row re-seeding over the operator |
| M3 | `PUT` calls `replay.preview` before persisting (the gate, reinstated) | **1 red.** `PUT never spends: expected "preview" to not be called` |
| M4 | `replayLabelled` ignores `confirm` and always runs | **1 red.** `without confirm it prices the run and spends nothing` |
| M5 | the `regressions` filter drops the `verdict === "right"` clause | **1 red.** `expected [ 'a', 'b' ] to deeply equal [ 'b' ]` |
| M6 | the append channel hardcoded to `instagram` in `agent-admin.prompt` + `decide` | **3 red.** `FB-ONLY-LINE` visible on IG and absent on FB, in both suites |
| M7 | `channels` dropped from the knowledge create/patch/list DTO | **4 red.** |
| M8 | `situationEntrySchema` accepts `modules` / `restoredParked` / `replayGate` / `added` again | **4 red**, one per deleted key |

### PROBE — prompt bytes, before and after

Identical, and `git status packages/` is clean (shared was never touched):

| | external wa/ig/fb | comment wa/ig/fb | moderation wa/ig/fb |
|---|---|---|---|
| before | 3413 / 3415 / 3413 | 1294 / 1295 / 1294 | 1955 / 1956 / 1955 |
| after | 3413 / 3415 / 3413 | 1294 / 1295 / 1294 | 1955 / 1956 / 1955 |

### Verification

- `corepack pnpm --filter @channels/api build` → **green, 0 errors** (11 before).
- `cd apps/api && npx vitest run --pool=forks --poolOptions.forks.maxForks=2`
  → **143 files / 1821 tests, 0 failures** (baseline: 40 failed / 1773 passed).
  Net +48 tests: `situations.controller.test.ts` rewritten 20 → 24, a new
  `knowledge-admin.controller.test.ts` (5), and one `agent-admin` prompt
  assertion retargeted from module ids to heading slugs.
- `comment-policy.service.test.ts` passes **unchanged** — the v1→v2 conversion
  preserves channel targeting end to end, which is the strongest evidence
  available here that the converter is faithful.

### Not verified

- **Nothing was deployed, restarted or published.** No pm2, no rsync.
- No prisma command was run against any database; the Stage 0 migration is still
  unapplied in production.
- The live production `Setting` row was still not read (no `~/.ssh/sop_vps` on
  this machine), so conversion was exercised against fixtures only. The
  kickoff's "run the converter once against the prod row" is still outstanding.
- `apps/web` was not built or typechecked — Stage 3's 26 errors are untouched by
  design.
- `@channels/api` has no `lint` script; the typecheck is the `nest build` above.

---

## Stage 3 — the Situations screen, rebuilt (DONE)

Commits `e4c9efb` (API client + primitives + the Job-seeker deletion) and
`b5e72bb` (the screen). Both pushed.

Rebuilt, not refactored. `Situations.tsx` went 1713 lines / 17 components →
**four files, 1,192 lines total**, and `situations.css` 603 -> 412. The old
module-editing machinery (`content-editors.tsx`) is deleted.

| File | What it owns |
|---|---|
| `Situations.tsx` | tabs · strip · five heading cards · one Edit/Save · save bar · prompt panel |
| `situations-ui.tsx` | Markdown reader · ChannelChip · Icon set · Menu · SidePanel |
| `situations-knowledge.tsx` | the library: rows, the `⋯` menu, the view/edit panel |
| `situation-copy.ts` | tab names + strip copy, as DATA |

### BINDING — every row

| Row | Verdict |
|---|---|
| Tab bar | **built** — four tabs, three underline (`accent-ink` 600 + 2px `accent`), the strip scrolls sideways inside itself, the page never does |
| Global keeps its treatment | **built** — `.cm-sit__tab--global` values reproduced unchanged (accent-soft/accent-ink, top `radius-sm`, accent fill when active) |
| Global content shape | **built** — one card, five hairline-separated headed rows; a test asserts ZERO `.cm-sit__stack > .cm-sit__card` on that tab |
| The situation strip stays | **built** — every tab, name + channel chips + one line. Only the `SITUATIONS` label and its separator are gone |
| Page action row | **built** — exactly two, in order; a test pins the row's button list |
| Recessive vs dominant | **built** — `cm-btn--quiet` (transparent / `muted` / `fw-label`) then `cm-btn--primary` |
| One Edit, one Save, per tab | **built** — one draft per tab, one PUT; Knowledge saves on its own |
| Tabs lock during edit | **built** — `aria-disabled` on the other three; the message renders only after a locked-tab click |
| Five heading blocks | **built** — read from `INSTRUCTION_HEADINGS`; no commentary, no note |
| Guardrails is an ordinary field | **built** — a test asserts its card `className` is byte-identical to Role's and its text content is exactly `Guardrails` + the prose |
| Nothing is fixed | **built** — no badge, no overlay, no confirm; a test rewrites the rail and asserts the PUT lands with no dialog |
| Read mode renders the markdown | **built** — `##`→`h4` at `fs-md`/600 `ink`; body `fs-md`/400 `ink-2` at 68ch; bullets and inline code; four tests assert no raw marker survives |
| Edit mode is the raw markdown | **built** — one `textarea.cm-input` per heading, mono at `fs-sm` (kickoff OPEN 5), one hint |
| Every box is the same border | **built** — cards / field boxes / KB boxes / channel segments all `surface` + 1px `line` + radius 6; live controls keep the `cm-input` spec. Conformance ratchet green |
| Per-channel additions are one box | **built** — segment inside `.cm-sit__fbox`, hairline only, chip is the whole marker; nothing renders when a channel has nothing |
| Per-channel selector | **built** — a plain menu, edit mode only, unused channels only; each segment carries its own Remove |
| Append-only | **built** — the segment renders after the base in DOM order (pinned), the placeholder says "added after the text above", and a sweep asserts no Replace / Override / Instead-of / Turn-off wording anywhere |
| Four channels | **built** — the four approved chip utilities, `web` renders as "Website" |
| Knowledge is rows only | **built** — no textbox under Knowledge on any tab; title / `Updated …` / chips / `⋯`; one **Add Knowledge** under the list |
| The row menu | **built** — View (eye) · Edit (pencil) · hairline · Delete (trash, `bad`) |
| Knowledge opens a side panel | **built** — right, over `--cm-scrim`, `min(440px, 100%)`, full height; a test asserts the tablist, the strip and the page title are still on screen; Escape, the scrim and ✕ all dismiss |
| Knowledge view panel | **built** — title, Updated, channel scope, scrolling body, Delete left / Edit right |
| Knowledge edit panel | **built** — "What's this about?", content, "Where it is used", Delete · Cancel · Save; independent of page Edit mode |
| Text knowledge only | **built** — no kind / link choice; a test asserts the panel never says "link" |
| Save bar | **built** — sticky, Cancel + Save, `textContent === "CancelSave"` |
| Empty heading state | **built** — card + name + one muted line in read mode, a real empty textarea + placeholder in edit; no em dash |
| Primitives | **built** — `cm-btn` / `cm-input` / `cm-field` / `cm-chip` throughout; no bespoke control |
| Every colour, size, radius, space | **built** — the conformance scan reports zero markers in all five new/changed files |
| Responsive | **built** — `container-type: inline-size` on `.cm-sit`, `@container (max-width: 420px)` for the chip stack and the KB chips. Only the tab strip and the panel body scroll sideways. ⚠ *verified from the CSS, NOT in a browser — see NOT VERIFIED* |
| Focus | **built** — visible 2px `accent-ink` on tabs, buttons, the KB row (inset), the `⋯`, menu items and panel controls; the row is `div role="button" tabindex="0"` with a real nested `⋯` button (pinned) |
| Light theme only | **built** — no dark tokens, no `prefers-color-scheme` |

**Two deviations, both deliberate:**

1. **Strip copy lives in `situation-copy.ts` (web), not beside the headings in
   `@channels/shared`.** The sheet asks for the latter; `packages/shared` is
   frozen for this seat. It is still DATA in its own file, never a screen
   literal — one move when shared reopens.
2. **"Undo my change" reverts to the SAVED text, not to the seeded default.**
   The sheet's ghost is "put back the original wording", which needs the seed;
   the v2 GET contract carries no seeded text and `apps/api` is another seat's.
   The action is real and per-field, and it appears only when the field differs
   from what is stored.

### Absences — each confirmed gone

One test sweeps `document.body.textContent` across all four tabs in read mode
AND in edit mode against a single banned list: `Parked for Flows` · `Add an
instruction under` · `EDITED` · `SEE CHANGE` · `Default | Now` · `Restore` ·
`Move back` · `Replaced here` · `Contributes nothing` · `Read all of it` ·
`FIXED`/`Fixed` · `Learned examples` · `Who the bot is` · `What it is trying to
do` · `How it sounds` · `Who they are` · `Returning contact` · `After hours` ·
`Lead progress` · `SITUATIONS`.

Pinned separately: no green tab dot and no separator element in the tab bar; no
replacement vocabulary in edit mode; no dialog on a rail save. Automatic values
and learned examples have no code path at all — `live` nodes never reach the
screen (they assemble server-side, Stage 1).

### JOB-SEEKER DECISION — **delete the control**

`AgentBehaviour.jobSeekerFlow` is removed from `BehaviourTab`'s `FLOW_FIELDS`,
from its dirty check, from its PATCH body, and from `AgentBehaviourPatch`.

**Not re-homed into Goal**, because Stage 5 deleted the `job-seekers` module
outright: there is no job-seeker prose left for the text to extend, and pasting
it into the Goal field would inject wording into the live prompt that the
operator never wrote there. The stored value survives server-side (harmless
data; `apps/api` is out of scope) but nothing on the surface can write it.

**Finding while doing it: `BehaviourTab.tsx` is itself unrouted** — nothing in
`apps/web/src` renders it. So the inert box was already invisible; deleting it
stops it shipping the moment anyone mounts that screen.

### MUTATIONS — 24, every one seen red

Applied one at a time to the implementation, run against `Situations.test.tsx`,
then reverted byte-for-byte from a backup.

| # | Mutation | Red |
|---|---|---|
| M1 | Markdown drops the `##` branch | 3 |
| M2 | `TAB_ORDER` puts Global last | 2 |
| M3 | Global loses `cm-sit__tab--global` | 1 |
| M4 | tab switch ignores the edit guard | 2 |
| M5 | the lock message becomes standing chrome | 1 |
| M6 | a situation save also writes the global layer | 2 |
| M7 | channel segments render ABOVE the base | 1 |
| M8 | add-a-channel offers every channel | 1 |
| M9 | a channel with nothing added still renders | 4 |
| M10 | the row menu grows a fourth item | 1 |
| M11 | Undo restores the current text, not the saved one | 1 |
| M12b | the KB row loses `role="button"` + `tabindex` | 1 |
| M13 | the panel hides the page headers | 3 |
| M14 | Save sends only the Role field | 5 |
| M15 | the field renders raw text instead of markdown | 1 |
| M16 | Knowledge grows a prose field | 2 |
| M17 | the empty heading renders nothing | 1 |
| M18 | the action row gains a third action | 1 |
| M19 | Global renders as five module cards | 1 |
| M20b | the KB edit panel drops the channel picker | 1 |
| M21 | Escape no longer closes the panel | 1 |
| M22 | the save bar regains a caption | 1 |
| M23 | the strip shows every channel on every tab | 1 |
| M24 | the prompt panel falls back to the external prompt | 1 |

Two first attempts were rejected as INVALID rather than recorded as survivors: a
CSS-`order` swap (M7 v1 — jsdom cannot observe it; the DOM-order mutation above
replaced it) and a `<div>`→`<button>` tag swap that broke the parse (M12 v1).

### BUILD + TEST

- `corepack pnpm --filter @channels/web build` — **green** (425 modules, down
  from 429 with the deleted editors). The 26 pre-existing typecheck errors are
  gone.
- `corepack pnpm --filter @channels/web typecheck` — **green, 0 errors**.
- `cd apps/web && npx vitest run --pool=forks --poolOptions.forks.maxForks=2` —
  **83 files / 713 tests green**. `Situations.test.tsx` is **62 tests** (was 50).
- `npx vitest run src/styles/` — **11 green**; the conformance ratchet reports
  zero markers in every file this stage touched and no baseline moved.
- `corepack pnpm --filter @channels/web lint` — 0 errors (7 pre-existing warnings).
- All four re-run after rebasing onto the concurrent seat's `fc659c9`, with
  `@channels/shared` rebuilt first.

### ⚠ FLAG — two surfaces lost their door

The rebuilt screen has no module cards, so the two managed-data editors it used
to host are now unmounted. Kept in the tree with a header note, NOT deleted:

- **`TriggerModuleEditor`** — the trigger store has **no editing surface at all**
  now. `/agent/triggers` still redirects to `/agent/situations`, where nothing
  renders it. The data and `/api/agent/triggers` are live; only the door is
  missing. Where triggers belong is an operator call, not a side effect of one
  screen's rebuild.
- **`KnowledgeModuleEditor`** — its CRUD is fully replaced by the new rows +
  panel, but **Reindex** and **corpus search** have no home. The rich doc editor
  at `/agent/knowledge/:id` is still routed and reachable by URL.

### ⚠ FLAG — the prompt endpoint serves only two of the four tabs

`GET /api/agent/prompt` takes `mode=external|comment` and coerces anything else
to `external` (`agent-admin.controller.ts:271`). So **Moderation** has no
assembled prompt to show, and **Global** has none by nature. Rather than render
another tab's prompt as if it were this one, the panel says so in one line.
Stage 2 (or a follow-up) should add `mode=moderation`.

### The API contract this screen assumes (Stage 2 owns it)

`GET /api/agent/situations` → `{ config: SituationsConfigV2 }`; `PUT` takes the
same config and returns the same shape. Deliberately minimal — no descriptors,
no seeded text, no replaced list — so the two seats can only disagree about one
object. The screen normalizes defensively (`fiveFields`): a missing situation or
a missing heading still reads as a whole config.

Knowledge DTOs are assumed to gain `channels: string[]` on list / get / create /
patch. The web types mark it **optional** and the screen reads `?? []`, so the
screen works against today's API and lights up when Stage 2 ships the column.
A new document is created with `category: "general"` — the approved form has no
category control and `category` is already free text in production.

### NOT VERIFIED

- **No browser.** 320 / 375 / 768 were never opened. The layout is
  container-query-driven and was reviewed in the CSS, but "clean at three
  widths" is a claim from construction, not from an eye or a screenshot.
- **Focus rings, hover states, the scrim and the shadow** are asserted as CSS
  rules and class names, never rendered — jsdom does no layout and no paint.
- **Nothing was deployed, restarted or published.** No pm2, no rsync, no prisma.
- The screen has never run against a real API: every test mocks `agent-api.js`,
  and `apps/api` still serves the v1 shape while the concurrent seat works.
- `typecheck` / `lint` were run for `@channels/web` only, not the workspace —
  another seat owns `apps/api`, which was mid-flight.
- Deleting a knowledge document takes effect immediately, with no confirm. That
  follows the mockup (the menu is exactly three items) but it is destructive and
  irreversible; worth an operator glance.

---

## Stage 7 — the two doors, reopened (DONE)

Closes the Stage 3 FLAG "two surfaces lost their door". Nothing was added to
Situations: the approved mockup fixes that screen ("One **Add Knowledge** button
under the list", "no note beside a heading"), so corpus-wide tools do not belong
on it. Each surface got its route back instead.

| Surface | Route | How the operator reaches it |
|---|---|---|
| Triggers | `/agent/triggers` — was a `<Navigate>` to Situations | Agent sub-nav → **Triggers** (sidebar + mobile drawer); the Instagram card's "comment triggers" link now lands on a screen instead of bouncing |
| Reindex + corpus search | `/agent/knowledge` — was a `<Navigate>` to Situations | Agent sub-nav → **Knowledge search** |

`TriggerModuleEditor.tsx` → `Triggers.tsx` and `KnowledgeModuleEditor.tsx` →
`KnowledgeSearch.tsx` (`git mv`, history kept). The "module editor" name died
with the module cards.

### What each screen is now

- **Triggers** — the same editor, unchanged in behaviour, under a `PageHeader`
  ("Keyword rules that answer a comment before the bot thinks about it"). Its
  three bespoke buttons (`cm-triggers__add` / `__save` / `__btn`) became `Button`
  primitives, so every control carries the sheet's 2px `accent-ink` focus ring
  instead of a hand-rolled one; 33 lines of duplicated button CSS deleted. The
  `<select>`s keep `className="cm-input"` — the house pattern, there is no Select
  primitive.
- **Knowledge search** — search + Reindex only. The document library it used to
  carry is gone: Situations owns rows, add, edit, delete and channel scope, and
  duplicating them is exactly the rule the rebuild was enforcing. What remains is
  what has nowhere else to live: the retrieval the `search_knowledge` tool runs
  mid-turn, and the re-chunk. It states the index counts (`N documents · N
  enabled · N chunks`) and links back to Situations → Knowledge for editing.
  `knowledge.css` shed 228 → 78 lines with the library chrome.

### MUTATIONS (each seen red before the fix, and again after)

| # | Mutation | Observed red |
|---|---|---|
| M1 | `/agent/triggers` back to `<Navigate to="/agent/situations">` | 2 failed — "`/agent/triggers` renders (no 404)" and "renders the trigger editor, not a redirect" |
| M2 | `/agent/knowledge` back to `<Navigate to="/agent/situations">` | 2 failed — the route pin and "renders Reindex and the corpus search" |
| M3 | `data-testid="kb-reindex"` renamed on the Reindex button | 1 failed — `Unable to find an element by: [data-testid="kb-reindex"]` |
| M4 | "Knowledge search" removed from `AGENT_SUBNAV` | 3 failed — the desktop order pin, the sub-nav link list, and the mobile drawer pin |

The pins assert the working control, not just "not a 404" — the whole Stage 3
failure was a route that resolved somewhere the editor was never rendered.

### BUILD + TEST

- `corepack pnpm --filter @channels/web build` — **green** (429 modules).
- `corepack pnpm --filter @channels/web typecheck` — **green, 0 errors**.
- `cd apps/web && npx vitest run --pool=forks --poolOptions.forks.maxForks=2` —
  **83 files / 715 tests green** (713 + the two new route pins).
- `npx vitest run src/styles/` — **11 green**, no baseline moved. Neither new
  screen is baselined, so both are held at zero legacy markers.
- `Situations.test.tsx` — **62 green**, untouched.
- `corepack pnpm --filter @channels/web lint` — 0 errors (7 pre-existing warnings).

### Left orphaned, deliberately

- `filterDocs` (`lib/agent-ui.ts`) — its only caller was the deleted library
  filter. Exported and covered by `agent-ui.test.ts`; deleting it would delete a
  green test for a helper the next list screen will want. Named here rather than
  removed silently.

### Fixed in passing

The Instagram channel card pointed at "Knowledge → what the bot sees", a section
that stopped existing when Stage 3 moved prompt inspection onto Situations. The
sentence now says the prompt is on the same screen as the moderation case.

### NOT VERIFIED

- **No browser, again.** 320 / 375 / 768 were not opened. Both screens reuse
  `PageHeader` (wrapping action row), `Table` (`.cm-table-scroll`, so a wide
  table scrolls in place) and a search row that now wraps at `flex: 1 1 220px` —
  reviewed in the CSS, not seen rendered.
- Focus rings are inherited from `cm-btn` / `cm-input`, asserted as class names,
  never painted — jsdom does no layout.
- Neither screen has run against a real API; `agent-api.js` is mocked everywhere.
- Nothing deployed, restarted or published.
