# Agent scope — work-package 3: the unified Moderation console

> **Kickoff plan. Written by the Fable seat, 2026-07-17.**
> Contents operator-locked: `docs/DECISIONS.md` § *2026-07-17 — moderation
> console grill* (ALL EIGHT v1 capabilities, two batches, multi-select) under
> the standing order § *2026-07-17 — operator: moderation+reply UX/capability
> (IG + FB) = TOP priority*. Gate(s) judge the package against THIS document.
>
> **WIP-rule note (header, so no session re-asks):** the Agent scope already
> has agent-scope-2 in flight (Stages 5–6, Behaviour structural editing).
> This package runs BESIDE it by the operator's standing top-priority order —
> the grill entry says so explicitly ("Runs beside agent-scope-2 Stage 6…
> file overlap is minimal"). The coexistence is the operator's call, recorded;
> the fences below make it safe.
>
> The package's spine: **the operator becomes a first-class moderator.** Today
> the ONLY place a human can act on a comment is the live-mode Review queue
> (`ReviewQueue.tsx:324-391`) — reachable solely through review items that
> shadow mode never creates (`comment-pipeline.ts:306-315`). Meanwhile the
> backend already carries a full moderation log with status×decision counts
> (`ig-comment-admin.service.ts:74-115`) that **no web code consumes**, and
> the `agentReview.*` thresholds steer both passes while being **surfaced
> nowhere**. The console unifies decision-surface, thresholds, alerts, and
> action into one screen per channel and kills the Activity/Posts/Queue
> triangulation.
>
> **The key framing (operator-locked, batch 1):** the operator's click is a
> HUMAN action. Gates (`off|shadow|live`) govern only the BOT. A shadow row is
> not "inactive" — it is a decision waiting for a human actuator, and the
> console lets the human act on it directly. Every stage below builds on that
> line: human acts run through the same Graph enforcement paths but are
> attributed as human in the audit trail, never as bot turns.

## Read before touching anything

`CLAUDE.md` · `HANDOFF.md` · `plans/rethink/scope-board.md` ·
`plans/rethink-execution.md` (you are the executor seat) ·
`docs/DECISIONS.md` (the two 2026-07-17 entries above — the locked scope) ·
`plans/rethink/agent-scope-2-kickoff.md` (Stages 0–4 delivered the vetting
surface + Overview this console builds beside; its Gate A verdict is ground
truth) · `plans/rethink/channels-scope-2-kickoff.md` (the shared-table +
pipeline-adapter design this console rides) · `design/tokens.md` (law) ·
`plans/rethink/web-adopt-audit-2026-07-17.md` (WCAG/contrast findings — the
console must not repeat them).

## Ground truth (verified at kickoff, 2026-07-17 — trust this over older docs)

> ⚠ **Stale-cite correction.** The orient audit and the DECISIONS grill entry
> cite `ig-comment.service.ts:195/:264-274/:420-488`. Those lines moved:
> channels-scope-2 Stage 2 extracted the pipeline into
> `apps/api/src/instagram/comment-pipeline.ts` (IG service is now a 124-line
> adapter wrapper). Every claim was re-verified against the CURRENT tree; the
> cites below are the live ones.

- **The only human action surface is the live-mode Review queue.**
  `ReviewQueue.tsx:324-391`: `approve` items get edit-before-send +
  approve/reject; `moderate` items get delete-permanently/restore. All of it
  reachable ONLY via review items — which exist only on live-mode paths
  (held reply, failed send, hidden, failed hide/delete) plus low-confidence
  `label` sampling. The action plumbing behind it is
  `ReviewItemsService.decide` (`review-items.service.ts:120-206`): claim-first
  (`updateMany WHERE status='waiting'` — double-click safe), Graph side
  effect, re-open on failure, human attribution via `decidedByUserId`. It is
  **IG-hardcoded** (`igConfig.getClient()`, :171) — FB rows in the queue
  would fail to act today.
- **Shadow creates zero review items.** `comment-pipeline.ts:306-315`: in
  shadow the reply decision writes a trace (`shadow: would reply/hide`) and
  returns — no row action, no queue item. Shadow moderation likewise:
  the moderation block rides the trace only (:229-243). agent-scope-2 Stage 2
  made shadow decisions *visible* (Activity moderation filter + summary
  strip); nothing makes them *actionable*. The nasty comment a shadow
  "would delete" flags sits public until someone finds it on Instagram.
- **The moderation log is a dark backend capability.**
  `GET /api/instagram/comments` (`ig-comment-admin.service.ts:74-115`) pages
  the comment table with status/decision/mediaId filters + whole-table
  status×decision counts. The web client function exists
  (`apps/web/src/lib/instagram-api.ts:72-77`) and **zero components call it**
  (verified: only the settings functions are imported, by
  `InstagramChannelCard.tsx:12`). `list()` has **no `channel` filter** even
  though the table now carries `channel` (migration
  `20260717120000_ig_comment_channel`, channels-scope-2 ruling 1) — additive
  param needed for the per-channel console.
- **The `agentReview.*` knobs steer everything and are surfaced nowhere.**
  Shape (pinned in `packages/shared/src/agent/settings.test.ts:115-129`):
  `enabled`, `selfReport`, `threshold` (held-reply bar), `deleteThreshold`
  (delete→escalate downgrade guardrail, `moderation-policy.ts:92-107`),
  `skipReviewThreshold` (label sampling) — all defaulting 0.8. Read at four
  sites: both comment passes (`comment-pipeline.ts:251-255`,
  `comment-policy.service.ts:108-113`), the restore pass
  (`comment-pipeline.ts:464-466`), and the **DM path**
  (`agent.service.ts:225`). No GET/PUT endpoint, no UI. An editor is a pure
  exposure — but its copy must be honest that these knobs are cross-surface
  (comments IG+FB *and* DMs), not a comments-only dial.
- **Escalations reach only the 08:30 digest.** The alert evaluator's red tier
  (`alert-evaluator.service.ts:81-111`) is the right delivery spine — dedup
  key + cooldown → notification row + email enqueue (`notifyAndDeliver`,
  :274, severity critical, carries an `href`) — but no red signal covers
  escalations; waiting review items are only counted into the daily digest
  (:226-232). And the evaluator is a **5-minute repeatable tick**
  (`apps/worker/src/main.ts:301-337`) — "real-time" cannot ride the tick; it
  needs an on-creation hook into the same delivery path (Stage 3 design).
- **`replyAfterRestore` posts sight-unseen.** On operator restore,
  `review-items.service.ts:190-205` unhides and then best-effort runs the
  reply pass (`comment-pipeline.ts:431-498`): in live mode a confident
  decision **posts immediately** (:487) — the operator who clicked "Restore
  comment" never sees the reply text before it is public. Locked capability 7
  ends that (Stage 4).
- **The shared table means FB rows are already console-shaped.** IgComment
  carries `channel` (default `"instagram"`); traces, review flow, and the
  admin list are channel-blind by design (channels-scope-2 ruling 1). FB rows
  appear in the console for free once FB shadow turns flow (their Stage 2 —
  merged; a real FB comment is still awaited at their gate). **FB *actions*
  are not free:** the FB Graph client is their Stage 3 (dark, live-blocked on
  the operator's Meta rescope), and FB hide has read-back-verify semantics +
  "hidden ≠ gone". The console shows FB rows with actions honestly disabled
  until an FB client exists — never a button that silently no-ops.
- **Review-queue semantics must not be polluted.** `label` items feed the
  Models `acceptRate`; agent-scope-2's Stage 2 ruling stands: informational
  or high-volume classes do NOT become review items. The console's
  right/wrong labeling therefore gets its **own additive table** (Stage 4),
  not `ReviewItem` rows.
- **Overview/Channels cards are free; Behaviour is not.** agent-scope-2's
  remaining work (Stages 5–6) owns `Behaviour.tsx`, the canvas, and
  `packages/shared/src/agent` engine files. Its Stages 0–4 (Overview,
  Activity vetting, gate controls, md export) are DELIVERED and gated
  (Gate A PASS) — this package may build beside/into those surfaces.
- **Mock round-10 is being built NOW, in parallel** (`design/tiles/round-10/`
  exists). This kickoff's UI-shape decisions stay OPEN until the operator's
  reaction (the appshell-scope-2 pattern: the round decides the pattern, the
  kickoff fixes the bar). Backend stages (1–4) are shape-independent and
  start immediately.

## Standing rules

All of `CLAUDE.md` + the rethink standing rules. In particular: **zero
unapproved LLM spend** (Stages 1–6 spend nothing — read/act surfaces only;
Stage 7 replay rides the standing shadow-rate approval with a per-run
estimate, below); TDD red-first; per-package suites capped
(`cd <pkg> && npx vitest run --pool=forks --poolOptions.forks.maxForks=2` —
never bare `pnpm test`); deploy loop (shared→api/worker builds,
`pm2 restart … --update-env`, health `{ok,db:true}`); web built before
publish, operator publishes; additive migrations only; commit+push per
verified step. **No gate flips by the executor, ever** — both IG gates and
both FB gates end this package in whatever mode the operator last set.

### UX/UI law (operator steer, 2026-07-17: "don't forget ux… it also includes ui")

These are **gate-bar items, not polish notes**:

- **One-screen core loop.** See decision → judge → act happens on ONE screen
  with zero navigation. Click budget: acting on a row costs **≤ 2
  interactions** (focus/select + action; destructive actions add exactly one
  confirm); a bulk action over N rows costs **≤ 3 interactions + confirm**.
- **Keyboard-first vetting.** The console is a queue-processing tool, not a
  report: row navigation (arrows or j/k) + action hotkeys are v1 scope, with
  visible affordances (the hints render; no invisible-shortcut UI).
- **Worst-first default ordering.** Rows default-sort by severity —
  `delete > escalate > held > leave`, then confidence descending — so the
  most negative surfaces first (the operator's motive: "FB comments are the
  most negative").
- **Tokens are law.** The console is authored on `components/ui` primitives +
  `--cm-*` tokens — no new entries in `routes/agent/styles.ts`, no bare
  navy/gold. WCAG contrast per the adopt-audit (no gold-on-white text, focus
  ring included). Mobile-usable at 390px — the shell is responsive now
  (appshell-scope-2 gate) and the console must not regress it: no horizontal
  document scroll; wide tables in their own scroll container.
- **UX walkthrough evidence at gate** — see gate bar 9.

### Parallel-execution fences

- **This package OWNS**: the console route + screen (new files under
  `apps/web/src/routes/agent/`), the threshold-editor API + UI, the
  moderation-action/label/post-mute migrations + services + controllers
  (new files, suggested `apps/api/src/moderation/`), the escalation-alert
  hook, `ig-comment-admin.service.ts` (additive params), Stage 4's pipeline
  touchpoints listed below, `instagram-api.ts` additions.
- **agent-scope-2 (in flight, Stages 5–6) owns**: `Behaviour.tsx` + canvas
  files, `packages/shared/src/agent` engine files (router/cases/prompt
  inventory). Do not touch. `agentRoutes.tsx` is shared ground — this package
  adds its route line only; conflict → pull/rebase, not parallel edits.
- **channels-scope-2 (in flight) owns**: `comment-pipeline.ts`,
  `packages/shared/src/facebook/*`, `classify.ts`, `channel-gates.ts`,
  `webhook-processor/status`, the FB channel card. **This package's pipeline
  touchpoints (bot action-history writes, per-post mute check, restore-preview
  semantics) land in `comment-pipeline.ts` — a named COORDINATION POINT:**
  their Stages 0–2 are merged (the file is stable today) but pull/rebase
  before every edit, and a real conflict → consult, don't race. The console
  adds FB filter/labels only where it needs them; the FB pipeline itself and
  the FB card stay theirs.
- **appshell-scope-2 residue**: `Settings.tsx`/`Settings.test.tsx` are
  modified in the working tree right now — do not touch Settings, AppShell,
  Home, or global styles.

---

## Design rulings (Fable, at kickoff — the executor builds these, the round-10 reaction may overrule UI shape only)

1. **Human-attributed action endpoints — one new controller, not a
   ReviewItems extension.** `POST /api/moderation/actions` (JWT + admin
   guard, Zod-discriminated body):
   `{ igCommentId, action: "hide"|"unhide"|"delete"|"reply", text?,
   expectedStatus }` and a bulk variant
   `{ igCommentIds[], action, … }` returning per-row outcomes. Semantics:
   - **Channel-resolved enforcement**: the row's `channel` picks the comment
     ops client (IG today via `IgConfigService`; FB when channels-scope-2
     Stage 3 lands its client — until then FB actions return
     `{ ok:false, error:"facebook actions need the FB client (rescope pending)" }`
     as **data, not a 500**, and the UI disables them).
     The Graph calls are the SAME enforcement paths the bot uses
     (`CommentOpsClient.replyToComment/setCommentHidden/deleteComment`) — no
     second Meta integration.
   - **Human attribution is structural, not inferred.** Every act writes a
     `ModerationAction` row (ruling 2) with `actorType:"human"` +
     `actorUserId` from the JWT — and **never writes an agent turn trace**.
     Bot enforcement keeps writing traces; the two audit spines stay
     distinct, and the console's history column can say "you · deleted ·
     14:02" vs "bot · hidden · 13:55" without heuristics.
   - **Gates are never consulted.** A human act proceeds in off/shadow/live
     alike — the operator's click IS the authorization (the locked framing).
     The endpoint acts on shadow rows exactly as on live rows.
   - **Double-act safety**: claim-first `updateMany` on `ig_comments` guarded
     by `expectedStatus` (optimistic concurrency — acting on a row whose
     status changed since render returns a conflict the UI surfaces as a
     refreshed row, mirroring ReviewItems' claim discipline). If a `waiting`
     review item references the comment, the action resolves it in the same
     transaction (`decidedByUserId` = the actor) so the queue and the console
     can never double-post.
2. **`ModerationAction` is the action history AND the undo spine** (additive
   migration): `id, igCommentId (FK), channel, action, actorType
   ("human"|"bot"), actorUserId?, turnId?, source ("console"|"review_queue"|
   "pipeline"), requestedAt, outcome ("ok"|"failed"), error?,
   resultExternalId? (e.g. the posted reply's Graph id),
   undoesActionId?, undoneByActionId?`. Undo policy, platform-honest:
   `hide ⇄ unhide` undoable; `reply` undoable by deleting our own posted
   reply (its Graph id is stored at act time); `delete` is **permanent and
   marked so** in UI and data (no undo affordance rendered — an undoable
   delete would be a lie). Bot enforcement paths also append rows
   (`actorType:"bot"`, with `turnId`) so one table answers "what happened to
   this comment, by whom, in what order".
3. **Labels get their own table** (additive): `DecisionLabel`:
   `id, turnId (FK), igCommentId?, verdict ("right"|"wrong"), note?,
   labeledByUserId, createdAt, @@unique([turnId, labeledByUserId])`. Labels
   attach to the DECISION (the turn), not the comment, so a re-moderated
   edit gets its own label. They never touch `ReviewItem` and never feed
   `acceptRate` (pinned by test). They are Stage 7's replay set.
4. **Real-time escalation alerts are an on-creation hook, not a tick
   signal.** Extract the evaluator's `notifyAndDeliver` into an injectable
   `AlertDeliveryService` (notification row + email enqueue + dedupKey +
   cooldown — behaviour byte-identical, evaluator suite pinned). A new
   `EscalationNotifier` calls it at creation time of an escalation event:
   `moderate` review items (live: hidden, failed hide/delete) AND — per the
   recommendation in operator fork 4 — shadow `would delete`/`escalate`
   decisions at/above `deleteThreshold` (in shadow the human IS the actuator;
   a nasty comment sitting public is exactly what "real-time" is for).
   Severity critical, `dedupKey: escalation:<igCommentId>`, red-tier cooldown
   honored, `href` deep-links to the console row. The 08:30 digest is
   untouched.
5. **Per-post mute is keyed by `(channel, externalPostId)`, not a SocialPost
   FK** (additive table `PostModerationSetting`): FB has no posts sync
   (`contextForMedia` returns null there), so a post-row FK could never mute
   an FB post. Pipeline touchpoint: `processComment` checks the mute early
   (after dedup, before triggers/LLM) → muted = named skip trace
   (`skipped: post muted by operator`), zero LLM spend on muted posts.
   Human console actions IGNORE the mute (it governs the bot, same framing
   as gates).
6. **Preview-before-send scope (capability 7), interpreted:** every
   HUMAN-initiated reply path previews — console reply (composer is
   preview), queue approve (already previews, `ReviewQueue.tsx:326-350`),
   and **restore**: `replyAfterRestore` in live mode stops auto-posting;
   the reply pass runs, and a confident draft lands as a preview the
   operator sends/edits/discards from the console (shadow behaviour
   unchanged — trace only). The BOT's webhook-triggered live replies remain
   gate-governed automation (previewing those would abolish the automation
   the gates exist to govern). This interpretation is restated as operator
   fork 5 — if he means literally-every-path, that is a gate-mode redesign
   and stops the package for a re-grill.

---

## Stage 0 — Round-10 mock reaction intake (design stays open)

Round-10 mocks are being built in parallel RIGHT NOW. This stage is the
intake of the operator's reaction, the appshell-scope-2 pattern:

1. The reaction is collected against `design/tiles/round-10/` and recorded in
   the round dir + `docs/DECISIONS.md`.
2. It rules the UI forks (operator decisions 1–3 below: replace-vs-beside,
   reply-edit surface, nav placement) and reacts to the interaction design
   (hotkey map, ordering, bulk selection pattern, click budgets shown in the
   mock).
3. **Stages 1–4 are shape-independent and do NOT wait.** Stage 5+ (the
   console UI) must not start before the reaction is recorded and the forks
   ruled.

**Oracle:** reaction + rulings recorded (round dir + DECISIONS entry); the
stage log names which mock variant won.

## Stage 1 — Threshold editor (S — dark capability № 1 exposed)

1. **API, additive**: `GET/PUT /api/agent/review-settings` (suggested home:
   the agent admin controller family) — Zod at the boundary, body =
   the normalized shape (`enabled, selfReport, threshold, deleteThreshold,
   skipReviewThreshold`, each 0..1 where numeric); PUT round-trips through
   `normalizeAgentReviewSettings` so a malformed write can never store an
   abnormal shape. No new semantics: the four read sites keep reading the
   same key, next decision uses the new values (matches the platform's
   "next turn uses current config" convention).
2. **UI**: a compact ThresholdEditor panel on `components/ui` primitives.
   Initial mount: Agent → Overview beside the channel/gate cards (the
   surface agent-scope-2 Stage 3 delivered — free ground). It re-homes into
   the console in Stage 5 (one component, two mounts, zero logic change).
   Copy is honest about blast radius: "applies to comment moderation and
   replies (Instagram + Facebook) and DM review — everywhere confidence is
   judged." Each knob explains itself in one line ("below this, replies are
   held for review", "below this, deletes downgrade to escalate", "below
   this, skips get sampled for labeling").
3. Guardrails in the UI, honesty over cleverness: 0–1 range enforced,
   current effective values always visible, no "apply to one channel"
   pretense (the backend has one knob set — the UI must not imply more).

**Verify**: TDD api (GET returns normalized defaults; PUT validates,
persists, round-trips; malformed → 400) + web (renders current values, edit
→ PUT → refreshed). Suites capped from `apps/api` + `apps/web`; deploy loop
+ health; live GET shows the stored values.

## Stage 2 — Moderation log surfaced (S — dark capability № 2 exposed; the console's read spine)

1. **API, additive**: `ig-comment-admin.service.list()` gains `channel`
   (`instagram|facebook`) and `decision`-independent `confidence`/severity
   inputs stay OUT (confidence lives on turns — Stage 5 joins it; keep this
   stage S). The summary gains per-channel counts (`groupBy channel` beside
   status/decision). Zod in the controller, existing params untouched.
2. **Web**: the console route mounts at `/agent/moderation` (nav placement
   pending fork 3 — the route can exist before its nav entry) rendering the
   **read-only log v0**: the dark endpoint's rows + status×decision summary
   header, channel filter tabs (IG / FB / all), URL-backed filters (the
   Campaigns/Activity `useSearchParams` pattern — shareable, reloadable).
   Worst-first default ordering within what the row carries today
   (status severity: hidden/held/failed first, then recency; full
   severity ordering completes in Stage 5 with the turn join).
3. FB rows render honestly: channel chip, and — until FB actions exist — no
   dead buttons (v0 is read-only anyway; the disabled-actions rule bites in
   Stage 5).

**Verify**: TDD api (channel filter, per-channel summary, legacy params
unchanged) + web (log renders live data, filters round-trip the URL,
390px no horizontal document scroll). Suites capped; deploy loop; live read
of `/agent/moderation` showing real rows both channels (FB rows exist when
channels-scope-2's test comment lands — absence is their gate item, not a
blocker here: assert IG rows + the FB filter's honest empty state).

## Stage 3 — Real-time escalation alerts (S — dark capability № 3 exposed)

1. Extract `AlertDeliveryService` per ruling 4 — evaluator behaviour pinned
   byte-identical (its suite must pass with assertions unmodified; DI wiring
   may change).
2. `EscalationNotifier` fires on: creation of a `moderate` review item
   (live escalations + failed hide/delete), and shadow
   `delete`/`escalate` decisions with confidence ≥ `deleteThreshold`
   (pending fork 4's ruling — build behind a small config flag defaulting
   ON so the ruling is a toggle, not a rebuild).
   Wire-up sites: `comment-pipeline.ts` (coordination point — pull/rebase)
   and the review-item creation helper. Dedup per comment; cooldown honored;
   email + notification row; `href` → `/agent/moderation?focus=<rowId>`.
3. Alert copy is severity-honest: shadow alerts say "bot WOULD delete —
   nothing was done; act in the console", live alerts say what already
   happened.

**Verify**: TDD red-first (notifier fires per matrix: live moderate item /
shadow high-confidence delete / NOT on leave / NOT below threshold / dedup
suppresses repeat; evaluator pinned). Suites capped; deploy loop; one live
witnessed alert (email + notification row) from a real or replayed shadow
delete decision — recorded in the stage log with the notification id.

---

## ★ GATE A — dark capabilities exposed (Stages 0–3, independently gateable)

Bars 1–5 of the gate bar, scoped to Stages 1–3. On PASS the operator can
deploy + publish: thresholds editable, the moderation log visible per
channel, escalations paging him in real time — while the console build
(Stages 5–6) follows the round-10 reaction. Recommend deploying at Gate A
for the same reason agent-scope-2 did: the vetting/alert window should open
as early as possible.

---

## Stage 4 — The action spine: migrations + human-attributed endpoints

1. **Migrations (additive, three tables)**: `ModerationAction` (ruling 2),
   `DecisionLabel` (ruling 3), `PostModerationSetting` (ruling 5). Apply via
   `source .env && npx prisma migrate deploy` + `prisma generate`;
   pull/rebase first (channels-scope-2 also migrates).
2. **`POST /api/moderation/actions`** per ruling 1 (single + bulk), with the
   claim/conflict discipline, review-item reconciliation, channel-resolved
   clients, FB honest-failure data. Label endpoint:
   `PUT /api/moderation/labels` upserting a `DecisionLabel` per ruling 3.
3. **Bot paths append `ModerationAction` rows** (`actorType:"bot"`,
   `turnId`): live reply post, hide, delete, restore-reply — the pipeline
   coordination point again (pull/rebase; conflict → consult).
4. **Restore-preview semantics** (ruling 6): `replyAfterRestore` live mode
   produces a previewable draft instead of auto-posting; the draft surfaces
   as a console preview (and resolves the old flow's review item shape
   honestly). Shadow path unchanged. Red-first pin: live+confident restore
   creates NO Graph reply call and ONE draft; the old auto-post test flips
   to the new semantics deliberately (named in the commit).
5. Attribution proof, pinned by test: a human act writes zero `agent_turns`
   rows and one `ModerationAction` `actorType:"human"` row; a bot act writes
   one trace and one `actorType:"bot"` row.

**Verify**: TDD red-first across the matrix (act on shadow row acts for
real; gates never read on the human path — spy pins zero gate reads;
expectedStatus conflict → 409-as-data; FB row → honest failure; bulk =
per-row outcomes, partial failure never aborts the batch; undo matrix per
ruling 2; labels never touch ReviewItem/acceptRate). Suites capped; deploy
loop + health; migrations applied cleanly against live.

## Stage 5 — The console (round-10 reacted shape; capabilities 1, 4-label, 6-history, 7-preview UI)

Built ONLY after Stage 0's reaction + forks 1–3 ruled. Expected shape,
subordinate to the reaction:

1. **One decision surface per channel** at `/agent/moderation`: the Stage 2
   log upgraded with the turn join — moderation action + confidence +
   decision source on every row, filters status × decision × confidence ×
   channel × post, **worst-first default ordering** (delete > escalate >
   held > leave, then confidence desc), URL-backed.
2. **Per-row override + act** incl. shadow rows: hide / unhide / delete
   (confirm, marked permanent) / reply (preview composer per fork 2's ruled
   surface) — the Stage 4 endpoints, ≤2 interactions per act, optimistic row
   refresh on conflict. FB rows: actions disabled with the honest reason
   until the FB client exists.
3. **Right/wrong labeling** on every decided row (1 keystroke each, ruling
   3's table), with the label state visible so vetting progress is legible.
4. **Action history + undo**: per-row history (who/what/when/outcome from
   `ModerationAction`), undo affordance exactly where ruling 2 allows,
   permanent actions marked, no undo rendered on delete.
5. **Keyboard-first**: row navigation + hotkeys for act/label/confirm per
   the reacted mock's map; visible hint UI; all budgets from the UX law
   held.
6. **Restore-preview surface** (Stage 4's drafts) lives here: pending
   drafts are rows demanding a send/edit/discard decision.
7. **Triangulation dies where the fork says**: per fork 1's ruling the
   ReviewQueue is replaced (redirect + queue-depth links re-pointed) or
   stays beside; either way the console is the superset and the stage log
   records what was absorbed (the dissolution rule: nothing dropped
   silently).

**Verify**: TDD web throughout (ordering, filters↔URL, act flows incl.
confirm + conflict refresh, label keystroke, undo visibility matrix, FB
disabled state, keyboard nav — testing-library keyboard events); suites
capped; deploy loop; web built + published; **the UX walkthrough evidence
(gate bar 9) is produced in this stage.**

## Stage 6 — Bulk actions + per-post controls (capabilities 4-bulk, 5)

1. **Bulk**: multi-select (click + shift-range + select-all-filtered),
   bulk hide / delete (confirm states the count and permanence) / label —
   Stage 4's bulk endpoint, ≤3 interactions + confirm, per-row outcome
   report rendered honestly (partial failures listed, not averaged away).
2. **Per-post controls**: a post lens (group-by-post or post filter chip):
   **mute bot on this post** (ruling 5's table + pipeline check —
   coordination point) and **bulk-hide / clear this post's comments** (the
   bulk endpoint scoped by mediaId). Posts screen gets a "Moderate →" deep
   link into the console filtered by that post — the Posts side of the
   triangulation folds in.
3. Mute honesty: a muted post shows its mute state in the console AND on
   Posts; pipeline skip is a named trace, so Activity explains itself.

**Verify**: TDD api (bulk scoped-by-post; mute check short-circuits before
LLM — zero policy calls on muted posts, pinned; mute ignores human acts) +
web (selection model, bulk confirm copy, outcome report, mute toggle);
suites capped; deploy loop; live witnessed bulk action on test rows.

## Stage 7 — Labeled decisions → Playground replay (capability 8)

1. **Replay set**: labeled turns (ruling 3) become a named replay set —
   `POST /api/agent/playground/replay` accepts a label filter
   (`verdict, channel, date range`), reruns the CURRENT prompt/behaviour
   config over each labeled turn's inbound (comment text + post context,
   through the existing playground path, `isPlayground: true`), and reports
   per-turn: old decision vs new decision vs the human verdict —
   "did the change fix what I marked wrong, and did it break what I marked
   right".
2. **Spend, stated and bounded**: organic eval only — no paid harness, runs
   are operator-clicked, never scheduled. Per-run estimate at DeepSeek
   comment-pass rates (~$0.0005/pass, two passes): **N labels ≈ $0.001×N —
   100 labels ≈ ~$0.10/run.** The run UI shows the estimate BEFORE the
   click; the standing shadow-rate approval covers it; anything anomalous
   (set > ~500 labels) → stop and ask with cost.
3. Results render in Playground (existing surface) with a link from the
   console's label view; replay turns are `isPlayground` and excluded from
   automation rate/acceptRate (pinned).

**Verify**: TDD api (set assembly from labels; replay excluded from
metrics; estimate math pinned) + web (estimate shown pre-run, results
diff view); suites capped; one live replay over the operator's real labels
witnessed, cost recorded in the stage log.

---

## Gate bar (Gate A = bars 1–5 on Stages 0–3 · final gate = all bars)

1. **Suites green per package** (capped forks), typecheck/lint clean, builds
   by exit code; migrations additive + cleanly applied; deploy loop run,
   health `{ok,db:true}`; web built (publish is the operator's).
2. **Zero unapproved LLM spend** — Stages 1–6 spend nothing (pinned: muted
   posts and human acts make zero policy calls); Stage 7 runs only
   operator-clicked, estimate shown, within the standing approval.
3. **No gate flips by executors** — all four comment gates (IG/FB ×
   reply/moderation) end in the operator's last-set mode; human actions
   provably bypass gates without writing them.
4. **Dark capabilities live**: thresholds editable end-to-end (live GET/PUT
   witnessed), moderation log rendering real rows per channel, one real
   escalation alert witnessed (notification id in the stage log).
5. **Human-action attribution provable**: for one witnessed human act and
   one bot act, the `ModerationAction` rows show the correct `actorType` +
   `actorUserId`/`turnId`, the human act wrote zero traces, and the console
   history renders both distinctly. Claim discipline pinned (no double-act).
6. **The console closes the loop**: on ONE screen the operator sees a shadow
   decision, judges it, and acts on it within the click budget; shadow rows
   are actionable; FB rows visible with honest action state; worst-first
   ordering + keyboard path pinned by tests.
7. **Capabilities 4–7 delivered per their stages**: bulk with per-row
   outcomes; per-post mute short-circuiting the pipeline; undo exactly where
   the platform allows with permanence marked; every human-initiated reply
   path previews (restore auto-post is dead — pinned).
8. **Replay honest**: labels → replay set → diff vs verdicts; playground
   turns excluded from metrics; cost recorded.
9. **UX walkthrough evidence**: a screen recording or stepped screenshots of
   the core loop (see → judge → act, single + bulk) demonstrating the click
   budgets, **delivered to the operator as rendered files he can open
   remotely** (published evidence URL or email attachment — never a bare
   repo path), plus 390px mobile capture showing no shell regression.
10. **Fences held** (Behaviour/engine untouched; pipeline edits
    pull/rebased; Settings/AppShell/Home untouched); triangulation
    absorption recorded (nothing dropped silently); all work
    committed+pushed; HANDOFF + scope board current; every deferral written
    into a backlog before the gate closes.

## Operator decisions (the executor must not decide these)

Forks 1–3 are ruled at the round-10 reaction (Stage 0); 4–6 may be ruled
there too or async — each carries a recommendation:

1. **Replace vs beside**: does the console REPLACE the Review queue
   (redirect, queue-depth links re-point) or sit beside it? Recommend:
   replace — the console is a strict superset (same items, more context,
   same actions), and "kills the triangulation" was the locked shape; keeping
   both re-creates the split surface this package exists to kill.
2. **Reply edit surface**: inline row composer vs side panel? Recommend:
   side panel (the ReviewQueue pattern the operator already knows; preserves
   list keyboard flow; inline editing fights the row-height/keyboard model).
3. **Nav placement**: top-level "Moderation" in the Agent subnav vs the
   Overview as the entry? Recommend: Agent subnav entry right after
   Overview — it is a daily working screen, not a drill-down.
4. **Shadow escalation alerts** (Stage 3): do shadow high-confidence
   `would delete`/`escalate` decisions page you in real time, or live events
   only? Recommend: yes, include shadow — in shadow you are the actuator;
   the alert is the moment the console pays for itself. (Built as a toggle
   either way.)
5. **Preview scope confirmation** (ruling 6): preview-before-send applies to
   every HUMAN-initiated reply (console, queue, restore) while the bot's
   gate-governed live auto-replies stay automatic. Confirm this reading —
   if you mean literally every reply incl. the bot's, that is a gate-model
   redesign and the package stops for a re-grill. Recommend: confirm as
   interpreted.
6. **Gate A deploy**: deploy + publish at Gate A (thresholds + log + alerts
   live while the console builds)? Recommend: yes.

## Intake + backlog triage (board rule: every kickoff triages)

**Intake**:
- **⭐ TOP-priority line** (moderation+reply UX/capability): its console half
  is CONSUMED by this kickoff (the standing prioritization order itself
  stays open until the operator lifts it). FB pipeline (channels-scope-2)
  and Behaviour Stage 6 (agent-scope-2) keep serving it in their lanes.
- **Knowledge screen below the bar**: NOT consumed — ⚠ the board line says
  "Candidate agent-scope-3" but the grill assigned agent-scope-3 to THIS
  console; the Knowledge package takes the next number (agent-scope-4).
  Board line should be corrected by the orchestrator.
- Ads/boosted posts: FIXED per the board — nothing to consume. Adopt-audit
  line: triaged below.

**Consumed from the Agent backlog by this package**:
- The orphan-guard successor policy ("live hidden comment whose trace write
  fails gets NO queue item — accept log-witness or create trace-less
  item?"): **answered structurally by Stage 4** — `ModerationAction` rows
  are written on bot acts regardless of trace success, and the console lists
  rows by STATUS (a hidden row shows hidden even with no queue item), so a
  stuck-hidden comment is findable without its trace. The board line closes
  when the console ships; noted at gate.

**Left on the Agent backlog, with reasons**:
- `SocialPost.caption_edited_at` — cosmetic, unrelated; stays.
- ManyChat keyword/trigger import — pre-cutover prerequisite for the REPLY
  gate; independent of the console; its own package at cutover scheduling.
- IG mentions + non-`comments` fields — Channels-scope plumbing; stays.
- Moderation bypasses trigger-matched comments — revisit condition
  unchanged (triggers exist AND moderation live); the console does not
  change trigger short-circuiting.
- Structural Behaviour editing — agent-scope-2 Stages 5–6, in flight there.

**Adopt-audit Agent-tagged lines**: the styling-migration lines (CR-1/HI-6/
ME-7 slice) stay on the backlog awaiting the design-system convergence;
mitigated here the same way agent-scope-2 did — all new console UI is
`ui/` + `--cm-*` with no new `styles.ts` entries (UX law above). WCAG
contrast findings bind this package's UI (gate bar via the UX law).

## Stop-and-consult list

- Any spend beyond the standing approvals → ask, with cost (incl. a replay
  set over ~500 labels).
- A pipeline edit (`comment-pipeline.ts`) conflicts with channels-scope-2's
  working tree → consult before resolving; never race the merge.
- The round-10 reaction demands a shape that contradicts a LOCKED capability
  (e.g. drops bulk, or hides shadow actions) → consult; the lock wins until
  the operator re-rules.
- Fork 5 comes back "literally every reply path" → STOP (gate-model
  redesign; re-grill).
- `ReviewItemsService` turns out to have a consumer that breaks under
  replace-mode (fork 1) → consult before diverging from the ruled fork.
- Undo for `reply` (delete-own-reply) fails against the real Graph API →
  consult; ship reply as non-undoable honestly rather than faking it.
- FB action semantics needed before channels-scope-2's client exists →
  consult; never build a second FB Graph client in this package.
- Any migration that stops being purely additive → stop.
- The escalation hook threatens webhook-path latency (alert delivery must
  never block comment processing — fire-and-forget with error logging) →
  if fire-and-forget can't hold, consult.

## Consult log (executor ↔ Fable)

- **2026-07-17 (executor → main): Stage 3 pipeline hook.** The real-time
  escalation hook's only remaining piece wires `EscalationNotifier` into the
  escalation sites, which live in FIVE channels-scope-2 coordination-point files
  (`comment-pipeline.ts`, `ig-comment.service.ts`, `fb-comment.service.ts`,
  `instagram.module.ts`, `facebook.module.ts`) — actively churning all session.
  Per the stop-and-consult list, consulted before racing the merge. The Stage 3
  BACKEND (delivery spine + notifier + config toggle + internal witness endpoint)
  shipped self-contained (`7578c0a`) and is witnessable via
  `POST /internal/alerts/escalation` without the hook.
  **RESOLVED (gate seat):** do NOT race — channels-scope-2 is actively building
  its Stages 3–4 in those files. Ship Stage 3 on the internal-witness path (zero
  production urgency — nothing is live-mode today). The on-creation hook is a
  named fast-follow (mapped in the Stage 3 log); the gate seat releases a seat to
  wire it the moment channels-scope-2 banks the files.

- **2026-07-17 (executor → main): Gate-A deploy BLOCKED by broken origin.**
  Deploy lane was cleared; VPS pulled to `b276694`. `pnpm --filter
  @channels/shared build` FAILS with three errors, ALL in agent-scope-2's
  committed Stage-6 work (my fence forbids touching `packages/shared/src/agent`):
  (1) `settings.ts` imports `./prompt-seed.js` but `prompt-seed.ts` was never
  committed (commit `d7a8b0d`) — origin references a missing file; (2)
  `prompt-inventory.ts:70` implicit-any; (3) `run-turn.ts:264` `sectionText` type
  error (`b71a339`). api/worker import built shared dist, so this blocks the api
  build → pm2 restart → my whole Stage 1-3 deploy + witness. Production is SAFE
  (old build still serving, health `{ok,db:true,redis:true}`; pm2 NOT restarted).
  Nothing I own is broken. Reported to main; awaiting agent-scope-2 to commit
  `prompt-seed.ts` + fix the two type errors. Resume the deploy the moment
  origin's shared build is green.
  - **Update 1 (`55b36d7`):** shared build fixed by main (prompt-seed committed).
    Pulled VPS; shared/worker/web build green — but **`pnpm --filter @channels/api
    build` STILL fails**: `agent-admin.controller.ts:477` (agent-scope-2's
    `mergeBehaviour`) spreads a `fields` property the shared `CaseBehaviour` type
    doesn't declare (dynamic-fields shared↔api skew). Not mine (my Stage 1 code is
    elsewhere in the file). Reported to main; the prior fix verified only shared,
    not api. Production still SAFE (pm2 not restarted). Resume on a FULL green
    build (shared+api+web).

## Stage log

- **GATE A VERDICT: PASS (2026-07-17, Fable).** Stages 1–3 deployed live and
  witnessed on production; (F) = Fable's own oracle:
  1. ✅ (F) all four package builds green (Fable re-ran them on origin head
     before clearing the deploy); post-restart health `{ok,db,redis}` (F) —
     the DI proof for this package + the FB modules + dynamic-fields riding
     along; served bundle `index-Xwx6T-uN.js` (F, curled twice) carries the
     Stage 1+2 markers.
  2. ✅ threshold editor witnessed on prod: GET → PUT 0.7 → GET persists,
     other knobs preserved → restored 0.8. Endpoint existence+auth re-checked
     by Fable (401 not 404).
  3. ✅ moderation log witnessed: 46 real IG rows w/ channel, per-channel
     summary `{instagram:46, facebook:0}`, honest FB empty state.
  4. ✅ real-time escalation: induced shadow would-delete → notification row
     (escalation/critical, dedup key, focus href) → deliver job COMPLETED →
     email SENT to ammar@silveroakglobal.ae with the shadow-honest body.
     Operator can confirm receipt in his inbox — the email IS the witness
     artifact.
  5. ✅ production restored (threshold 0.8, witness row deleted), zero gate
     flips, zero LLM spend; evaluator's 14 tests byte-identical through the
     delivery extraction.
  **Post-Gate-A amendment binding on ALL later stages** (operator, recorded
  in DECISIONS): /agent/moderation folds INTO Activity as its moderation
  view (route retires, deep links redirect); Activity's default stream stays
  general-purpose. Stages 4–7 land inside Activity's moderation view.

- **Stage 0 — round-10 mock reaction intake:** *(pending — mocks in flight)*
- **Stage 1 — threshold editor:** ✅ **BUILT + committed + pushed** (`0e1ec4e`,
  on `82ab6eb`). Dark capability № 1 exposed.
  - **API (additive)** `GET/PUT /api/agent/review-settings` on
    `AgentAdminController` — Zod-strict boundary (0..1; wrong type/range → 400);
    partial PUT merges onto stored values then re-normalizes through
    `normalizeAgentReviewSettings` (a malformed write can never store an abnormal
    shape). No new semantics — next decision reads the new values.
  - **Web** `ThresholdEditor` panel (`components/ui` + `--cm-*` tokens, no
    `styles.ts` entry, WCAG focus rings, 390-safe) mounted on Agent → Overview
    beside the gate cards; re-homes into the console at Stage 5. Plain-language
    captions match the approved round-10 Tile C; copy honest that the knobs are
    cross-surface (IG+FB comments/replies AND DM review), not comments-only.
    Three sliders surfaced; `enabled`/`selfReport` API-complete but unsurfaced in
    v0 (merge PUT preserves them) — matches the mock.
  - **Red-first proof:** api 6 new tests failed with `putReviewSettings is not a
    function` → green; web ThresholdEditor test failed to resolve the component →
    green.
  - **Suites:** full api **1161/1161**; web ThresholdEditor **5** + Overview
    **8** green; api+web typecheck exit 0; web build exit 0; web lint 0 errors
    (7 pre-existing warnings).
  - **Deploy/live witness:** batched to the Gate-A deploy (see verdict) —
    per-kickoff recommendation "deploy at Gate A", and to avoid racing the shared
    VPS while other seats deploy mid-flight. Local verification complete.
- **Stage 2 — moderation log surfaced:** ✅ **BUILT + committed + pushed**
  (`64a0bf1`). Dark capability № 2 exposed — the console read spine.
  - **API (additive)** `ig-comment-admin.service.list()` gains a `channel`
    (instagram|facebook) filter; summary gains per-channel counts (`groupBy
    channel`); the row carries its `channel`. Controller Zod adds `channel` +
    widens `status` to include held/hidden (additive; existing params untouched).
  - **Web** `/agent/moderation` route + **AgentNav entry beside Activity** (per
    the round-10 ruling the coordinator relayed) rendering the read-only log v0:
    rows + status×decision summary, per-channel tabs (All/IG/FB with counts),
    URL-backed filters (`useSearchParams`). Worst-first within what the row
    carries today (hidden/held/failed → posted/shadow, then recency; full
    severity ordering is Stage 5's turn join). FB rows honest — channel chip, no
    dead buttons, honest empty state until a real FB comment lands. Table rides
    the shared scroll container (390-safe).
  - **Red-first proof:** api service 2 new tests red (channel filter + per-channel
    summary) → green; web Moderation module-resolve failure → green.
  - **Suites:** ig-comment-admin service **13** + controller **14**, web
    Moderation **5** + AgentNav **3** green; api+web typecheck exit 0.
  - **Note:** shared tree churned mid-stage (channels-scope-2's FB-admin files
    went `??`→committed under me); my edits stayed isolated to the fenced
    `ig-comment-admin.*` + new `Moderation.*` + nav files.
  - **Deploy/live witness:** batched to Gate A.
- **Stage 3 — real-time escalation alerts:** 🟡 **BACKEND BUILT + committed +
  pushed** (`7578c0a`); pipeline hook pending coordination (consult log).
  - **`AlertDeliveryService`** — notification-row + email-enqueue + dedup/cooldown
    spine, EXTRACTED byte-identical from the evaluator's `notifyAndDeliver`. The
    evaluator delegates to it; its **14-test suite stays green with assertions
    unmodified** (only harness DI wiring changed) — byte-identity proven.
  - **`EscalationNotifier`** — fires per ruling 4: LIVE moderate escalation
    (hidden / failed hide-or-delete) always pages; SHADOW would-delete/escalate
    pages when `escalation.shadowAlerts` (default ON, fork 4) AND confidence ≥
    `deleteThreshold`. Rides the red master switch + red cooldown (fresh install
    still sends nothing — the operator lock; a burst is one alert). dedupKey
    `escalation:<igCommentId>`; href → `/agent/moderation?focus=<id>`. Copy is
    severity-honest (shadow: "would … nothing was done, act in the console";
    live: what happened).
  - **`alerting-config`** gains `escalation.shadowAlerts` seeded default true
    (data-over-constants law).
  - **Internal witness path** `POST /internal/alerts/escalation` (callback-guarded)
    induces an escalation through the notifier WITHOUT acting on a real comment —
    the Gate-A witness path.
  - **Red-first proof:** delivery 5 red→green; notifier 7 red→green; internal
    controller +2. **Suites:** full alerts **60/60** (evaluator pinned); api
    typecheck exit 0.
  - **Deploy/live witness:** Gate-A deploy; induced via `POST
    /internal/alerts/escalation`.

  #### ✅ FAST-FOLLOW DONE — the on-creation pipeline hook (`85d715a`)
  The gate seat released the right of way (channels-scope-2 banked
  `comment-pipeline.ts`, `fb-comment.service.ts`, `facebook.module.ts`;
  `ig-comment.service.ts` + `instagram.module.ts` were never theirs). Wired
  red-first with an **`EscalationSink` port** (the pipeline stays decoupled from
  the alerts module — `EscalationNotifier` satisfies it structurally); no module
  cycle. Fire-and-forget `pageEscalation()` (never awaited into / throws into the
  processing path). 4 sites live: shadow would-delete/escalate (once, after the
  row exists, independent of the reply outcome), live escalate→hidden, live
  hide-failure, live delete-failure. **6 new IG-service tests red→green; the 54
  byte-pinned pipeline tests still pass (60/60); FB + alerts green (74).** Below
  is the map that was executed:
  - **Wire the notifier in (DI):** add `EscalationNotifier` to
    `EscalationNotifier`-exporting `AlertsModule` (already exported); import
    `AlertsModule` into `InstagramModule` + `FacebookModule`; inject
    `EscalationNotifier` into `IgCommentService` (ctor ~L89–96) and
    `FbCommentService` (ctor ~L111), and thread it as a new `CommentPipeline`
    constructor arg (ctor ~L112–119).
  - **4 call sites in `comment-pipeline.ts`, all fire-and-forget** (wrap in
    try/catch + log; alert delivery must NEVER block webhook processing):
    1. **Shadow would-delete/escalate** — the trace-write + return at ~L325–334
       (`shadow: would ${decision.action}`): call `notify({ igCommentId, channel,
       mode:"shadow", action: decision.action (delete|escalate),
       confidence: moderation.decision.confidence, text })` when
       `decision.action !== "leave"`. The notifier itself enforces the
       `deleteThreshold` bar + the shadow toggle, so the site passes the raw
       decision.
    2. **Live escalate** — `hideAndQueue` (~L560, after the `moderate` review
       item is created): `notify({ mode:"live", action:"escalate",
       outcome:"hidden", confidence, igCommentId: rowId, channel, text })`.
    3. **Live delete-fail** — the delete-failure `moderate` create (~L442):
       `notify({ mode:"live", action:"delete", outcome:"failed", … })`.
    4. **`queueHideFailure`** (~L579): `notify({ mode:"live", action:"escalate",
       outcome:"failed", … })`.
  - **Verify (when unblocked):** red-first that a live moderate-item create and a
    shadow high-confidence delete each call `notify` once; NOT on leave; NOT below
    threshold; the call is fire-and-forget (a thrown notifier never aborts
    processing — pin with a rejecting mock). Suites capped; deploy loop.
- **★ GATE A — DEPLOYED + WITNESSED (2026-07-17), ready for the gate seat's verdict.**
  After two broken-origin holds (agent-scope-2's shared then api build — both
  fixed by main), the deploy ran clean on VPS HEAD `27c2eb2`:
  - **Build:** shared (tsc), api (nest build), worker (tsc), web (vite) all exit
    0. **pm2 restart channels-api channels-worker --update-env** → both online;
    **health `{ok,db:true,redis:true}`** — the DI proof that my
    AlertsModule←Instagram/Facebook wiring, channels-scope-2's FB modules, AND
    agent-scope-2's dynamic-fields code all boot together.
  - **Web published** (`scripts/publish-web.sh`), served bundle
    `index-Xwx6T-uN.js` — carries the Stage 1 markers (`Auto-post threshold`,
    `Confidence thresholds`) + Stage 2 markers (`Moderation log`, `Skip / leave
    sampling`).
  - **Stage 1 witness (admin JWT):** `GET /api/agent/review-settings` →
    normalized defaults; `PUT {threshold:0.7}` → merged, other knobs preserved
    (`deleteThreshold:0.8, skipReviewThreshold:0.8`); `GET` confirms persistence;
    restored to 0.8. Full round-trip + merge semantics proven live.
  - **Stage 2 witness:** `GET /api/instagram/comments` → **46 real IG rows**,
    rows carry `channel`, summary carries the new per-channel counts
    `{"instagram":46,"facebook":0}`; `?channel=facebook` → 0 rows (the honest
    empty state — no FB comment has landed yet, their gate item).
  - **Stage 3 witness (induced, no real comment):** `POST
    /internal/alerts/escalation` (callback-guarded) with a shadow would-delete
    conf 0.95 → **201 `{delivered:true, notificationId:fe2ad321…}`**. Notification
    row: `kind=escalation, severity=critical, dedup_key=escalation:witness-gate-a,
    title="Instagram: bot would delete a comment",
    href=/agent/moderation?focus=witness-gate-a`. The alerts **deliver job
    (id 11) COMPLETED** → email sent to `ammar@silveroakglobal.ae` with the
    shadow-honest body ("Nothing was done … YOU are the actuator. Act in the
    console."). Witness notification row then DELETED (evidence captured);
    threshold restored; **no comment gate touched, zero LLM spend, no gate flips.**
  - Minor copy nit noted for the fold increment: "a Instagram" → "an Instagram".

- **★ GATE A verdict (Stages 0–3):** *(gate seat's — evidence above)*
- **⏭ Post-Gate-A amendment (operator, docs/DECISIONS.md "console placement
  AMENDED"):** `/agent/moderation` retires as a separate screen — the Stage 2
  list/summary/filter FOLDS INTO Activity as a moderation VIEW (deep links
  preserved via redirect); Activity's DEFAULT stream stays general-purpose
  (moderation strip/columns/worst-first only when the moderation view is active);
  all Stage 4–7 console capabilities land inside Activity's moderation view. This
  is the next increment BEFORE any Stage 4+ code.
  - ✅ **BUILT + committed + pushed + DEPLOYED + published + served-marker
    verified** (`280925d`; deploy on VPS HEAD `2719b0f`). Hold lifted by the
    coordinator once the operator approved the Behaviour rework on the screenshots.
    - **Deploy loop** (`scripts/deploy.sh` as `sopserver1`): pull → `2719b0f`,
      `prisma migrate deploy`, four-package build (shared/db/api/worker), **`pm2
      reload` both apps** (api ↺36, worker ↺26 — the notifier copy change rode
      the api reload), **health `{ok,db:true,redis:true}`** — the DI proof
      everything (my fold + Behaviour rework + FB modules) boots together. Web
      SPA rebuilt, **published as root** (`publish-web.sh`, rsync → nginx, HTTP/2
      200).
    - **Served bundle `index-qxxMpTwP.js` carries both lanes** (grepped in
      `/var/www/channels-web/assets`, referenced by the served index.html):
      my fold marker "Acting on a row lands with the console" ×1 (+ the
      moderation-view subtitle), AND the Behaviour rework's typed-confirm literal
      "Save cancelled — safety change not confirmed" ×1 + `base-section-fields`
      ×1. Behaviour seat cleared to run its live e2e.
    - All local oracles green (below).
    - **Route + nav retire → redirect.** `/agent/moderation` is now a
      `ModerationRedirect` (`agentRoutes.tsx`) → `Navigate` to
      `/agent/activity?view=moderation&<every incoming param>` (channel/status/
      decision/**focus** preserved). AgentNav's standalone "Moderation" entry
      removed. Old links — the escalation email's `?focus=<id>` in particular —
      keep landing on the right row.
    - **Activity gains the view.** New `view` URL param; a `SegmentedControl`
      ("Activity" | "Moderation") toggles it. `view=moderation` renders the folded
      log (`ModerationView.tsx`, extracted verbatim from the retired `Moderation.tsx`
      minus its PageHeader, + a `focus` deep-link row highlight): per-channel tabs,
      status×decision summary, worst-first ordering. The turn-stream query is
      `enabled:false` in that view (no wasted fetch).
    - **Default stream stays general.** Audited agent-scope-2 Stage 2's additions:
      the moderation **filter dropdown stays** (one filter among five — coverage
      kept) and the **modsummary strip stays gated to when that filter is active**
      (already amendment-compliant: "appear only when the moderation view/filter is
      active"); the per-row moderation **pills stay** (per-row facts). No
      moderation strip/columns/worst-first render by default — the ig-comments log
      lives only behind `view=moderation`.
    - **Copy nit fixed** (Stage-3 note): notifier `escalation-notifier.service.ts`
      now picks the article by channel — "**an** Instagram", "**a** Facebook" —
      in both shadow and live bodies. Its `href` re-pointed to
      `/agent/activity?view=moderation&focus=<id>` (delivery fixture too).
    - **Coverage moved, not dropped.** `Moderation.test.tsx` → `ModerationView.test.tsx`
      (same 5 assertions + a new focus-highlight test); `Activity.test.tsx` +3
      (default = no moderation surface / view=moderation = the log / toggle writes
      the URL); `AgentNav.test.tsx` asserts no standalone entry; `App.routes.test.tsx`
      pins the redirect + focus/filter preservation; notifier +1 (article).
    - **Red-first proof:** ran the 4 web files before impl → 6 red (ModerationView
      missing, Activity view split, AgentNav entry, redirect) → green after impl.
    - **Suites (capped):** web agent folder **143/143** (incl. ModerationView 6,
      Activity 21, AgentNav 3, App.routes 15); api alerts **61/61** (evaluator
      pinned, +1 article). web typecheck 0 / lint 0 errors (7 pre-existing warns) /
      build exit 0; api typecheck exit 0.
    - **Fences held:** did NOT touch Behaviour.tsx/test (other seat's uncommitted
      work at commit time — since committed as `bd13d45`), `packages/shared/src/agent/*`,
      or `facebook/*`. Pathspec commit.
- **Stage 4 — action spine (migrations + endpoints):** ✅ **BUILT + pushed** (`0f8f6ed`).
  - Migration `20260718000000_moderation_action_spine` (additive, 3 tables:
    `moderation_actions`, `decision_labels`, `post_moderation_settings`) — NOT yet
    applied to live (deploy lane). Prisma client regenerated; schema validates.
  - `ModerationActionService` (act/actBulk/undo/history) + `DecisionLabelService`
    + `ModerationController` (admin-guarded, Zod). Human path NEVER reads a gate
    (structural — no settings dep); optimistic `expectedStatus` claim; channel-
    resolved client; FB honest-failure as data; waiting review items reconciled to
    the actor; undo platform-honest (delete permanent). Bot enforcement appends
    `actorType:"bot"` rows inline via prisma (best-effort). Restore-preview: live
    `replyAfterRestore` no longer auto-posts — held draft preview (old auto-post
    test FLIPPED deliberately).
  - Red-first. api 1248/1248, api build exit 0.
- **Stage 5 — the console:** ✅ **BUILT + pushed** (`aa62c04`).
  - `ConsoleReadService` + `GET /api/moderation/console` (turn join: confidence /
    moderation verdict / decision source + label + action-count). Additive.
  - `ModerationView` v2: per-row act incl. SHADOW rows (optimistic conflict
    refresh), right/wrong labeling (visible state), action history + undo (delete
    marked permanent), reply-PREVIEW composer, worst-first ordering, keyboard-first
    (j/k/h/u/d/r/g/b + hint bar), FB honest-disabled. `--cm-*` tokens, 900px side
    panel, 390-safe.
  - ⚠ **FORK FLAGGED (not improvised):** the Review queue also serves DM `label`
    items the console doesn't surface — a wholesale replace would DROP DM-label
    vetting. Per stop-and-consult, the console ships BESIDE the queue (both safe —
    console acts reconcile waiting review items, no double-post). Operator rules
    whether/how DM labels re-home before the queue nav is retired.
  - Red-first. api moderation 32/32; web agent 154/154; builds/typecheck/lint clean.
- **Stage 6 — bulk + per-post controls:** ✅ **BUILT + pushed** (`cc7f13b`).
  - Bulk multi-select (click + shift-range + select-all), bulk hide/delete
    (confirm states count+permanence) / label; rows grouped by status so each
    `actBulk` call carries the right expectedStatus; per-row outcome report (partial
    failures listed, never averaged).
  - Per-post mute (`PostModerationService` + GET/PUT `/api/moderation/post-settings`
    [+/muted]), keyed (channel, externalPostId). Pipeline coordination point:
    `processComment` checks the mute BEFORE any trigger/LLM → named skip, ZERO LLM
    (pinned). Console post-lens (mediaId filter) mute toggle; Posts detail gains a
    "Moderate →" deep link.
  - Red-first. api moderation 40/40 + pipeline (IG 66 / FB 14); web agent 156/156.
- **Stage 7 — labeled replay:** ✅ **BUILT + pushed** (`7f89133`).
  - `ReplayService` + `POST /api/moderation/replay/preview` (estimate) + `POST
    /api/moderation/replay` (run). Labeled turns → rerun CURRENT config via
    `PlaygroundService.previewComment` (isPlayground → excluded from metrics) →
    old vs new vs verdict diff. Estimate ~$0.001/label shown pre-run; >500 REFUSES
    without spending (stop-and-ask). AgentModule now exports PlaygroundService.
  - Web replay panel: estimate before the click, then the diff list.
  - Red-first. api 1256/1256; web agent 157/157; NO LLM spent in tests (policy mocked).
- **NOT verified by the executor (deploy lane):** migrations applied to live;
  NestJS DI boot + health `{ok,db,redis}`; web publish; the live witnessed acts
  (one human act, one bot act, one escalation-driven act, one live replay for the
  gate bars). All builds green on the pushed HEAD (shared/db/api/web exit 0).
- **Final gate verdict (Fable, 2026-07-18): CONDITIONAL PASS — deployed live;
  witnessed-evidence bars open on the operator's click-through.**
  - Verified by the gate seat's OWN oracles (not seat reports): shared 782 ·
    api 1256 · web 598 all green capped-forks on the pushed HEAD; 4 builds
    exit 0. Deploy run via `scripts/deploy.sh` (log retained): migration
    `20260718000000_moderation_action_spine` applied live, pm2 reloaded,
    health `{ok,db:true,redis:true}`; web PUBLISHED — served bundle
    `index-CrquOEFJ.js` carries `view=moderation`; live
    `GET /api/moderation/console` → 401 (exists + admin-guarded); VPS HEAD
    `901cd7a` = origin/rethink.
  - Bars 1,2,3,7,8,10: **PASS** (test/build/deploy level, above).
  - Bar 4: PASS carried from Gate A (witnessed escalation email).
  - Bars 5, 6, 9 (witnessed human-act rows, the operator's see→judge→act
    loop, UX walkthrough + 390 capture): **OPEN — operator click-through**;
    sessions cannot authenticate as him. First operator session on the live
    console closes them.
  - Fork 1 RULED (operator, 2026-07-18, prompt line): **console absorbs DM
    label items, then the queue nav retires** — follow-up on the Agent
    backlog (board), not part of this package's bar.
