# agent-scope-6 kickoff — FINISH THE AGENT (standing order 2026-07-21)

> Fable-authored 2026-07-21. Executes the operator's standing order
> (DECISIONS 2026-07-21 ruling 2): ① moderation runs cleanly → ② kill the
> pills, redo the button → ③ Agent UI/UX simplification. Stages run **in
> order** — that sequence is the operator's, not a preference. Evidence base
> for Stage 1 is the 2026-07-21 diagnostic in `HANDOFF.md` § "Moderation:
> live-state diagnostic" — **do not re-run the investigation.**

## Fences (all stages)

- No gate flips, no Meta config, no new LLM spend — operator-only.
- Golden pins hold byte-identical: nothing in this package touches prompt
  assembly. If a pin moves, stop.
- Non-Agent surfaces are out of scope except the shared button primitive
  (Stage 2) — the app-wide `so-btn`/`so-pill` sweep stays on the appshell
  backlog.
- Standing rules apply: TDD, per-package tests (forks pool, maxForks=2),
  deploy loop + health check, tokens are law, commit+push per verified step.

## Stage 1 — moderation runs cleanly (the headline)

Three code defects, none operator-blocked. Anchors verified 2026-07-21
against the live code (all shipped pre-diagnostic, so the live 07-21 events
ran exactly this code):

**A. A failed action reads as a success.**
The pipeline passes `outcome: "failed"` and the notifier's *body* is honest —
but the *title* is `"${chan}: comment escalated"` for hidden and failed alike
(`apps/api/src/alerts/escalation-notifier.service.ts:116`), and the title is
all the operator sees in an email subject line / alert list. That is exactly
the dishonest string he received.
- Failure-honest title, e.g. `Facebook: hide FAILED — comment still public`.
  Success keeps the current title. Same for the live-delete-failed path.
- Console: the Activity moderation view must show `outcome=failed` + the
  error on the affected row (check `console-read.service.ts` exposes the
  latest action outcome; add it if the web view can't render it today).
- Tests: notifier title unit tests (failed vs hidden vs shadow); a web test
  that a failed row renders the failure, not a neutral state.

**B. No detector for moderator fail-open.**
A moderation fail-safe (`empty_output*`, `no_json`, `invalid_json`,
`not_object`, `unknown_action`, `llm_exception`, `llm_error_*` —
`packages/shared/src/agent/moderation-policy.ts:80-102,164-175`) forces
`leave` with no review item and no alert. Root cause fixed 07-20
(`maxTokens: 1024`) but a recurrence is silent.
- Add a red rule: N+ moderation fail-safe turns in the trailing window →
  one red issue (stable key `moderation:failsafe`, kind
  `red.moderation.failsafe`), through the existing evaluator + cooldown
  spine (`apps/api/src/alerts/red-rules.ts` — follow the rule-1..4 shape).
- Threshold + window live in the alerting config settings key, seeded from
  defaults (self-rectifying law: operator-tunable = data). Signal source:
  turn traces' moderation block — count, don't sample.
- Tests: red-rules pure-function tests (below/at threshold, cooldown key).

**C. Real signal is buried by known-dead-number noise.**
`red.silence` for the Meta-disabled WhatsApp number re-pages every cooldown
(20+ criticals, 231h) into the same stream as genuine escalations.
- Per-channel silence-alert mute in the alerting config (data, seeded ON),
  editable where alert settings already live. Muted = no email/critical;
  Home still shows the truth (email is the pager, Home is the truth —
  red-rules.ts design note). Plain-words toggle copy, e.g. "This number is
  known-down — stop paging me about it."
- The operator then mutes WhatsApp himself, or the seat seeds it muted ONLY
  if a written default is defensible — do not hardcode the number.
- Tests: evaluator honors the mute per channel; unmuted channels unaffected.

**E. External deletions are invisible (Stage 1b — operator-reported
2026-07-21, code-confirmed; runs as its own seat AFTER defects A–D land —
it shares files with them).**
The platform never learns a comment was deleted outside it, and never hides
deleted comments from view:
- IG parser drops `remove` verbs silently
  (`packages/shared/src/instagram/instagram-comments.ts:129`); FB parser
  drops `remove`/`delete`/`hide`/`unhide` with "existing row untouched"
  documented as intent (`packages/shared/src/facebook/facebook-comments.ts:113,128`).
- Post details lists comments with NO status filter and renders whatever is
  stored (`apps/api/src/instagram/posts.service.ts:302-307,377`) — even rows
  we ourselves set to `deleted`.
Fix:
- Parsers emit a remove event instead of dropping (keep dropping hide/unhide
  only if syncing them is genuinely out of scope — say so in the stage log).
  Pipeline marks the existing row deleted (reuse the existing `deleted`
  status — fewest concepts; the `moderation_actions` spine already
  distinguishes OUR deletes from external ones by absence of an action row.
  If a distinct status is truly needed, it is an additive migration + a note).
  A removed comment's `waiting` review items resolve (nothing left to act on).
  No row exists → ignore (never create a row for a deletion).
- Post details + console: deleted rows leave the default comment list (or
  render as an explicit collapsed "deleted" line — pick the plainer of the
  two on the screen); counts stay honest. Action buttons never render on a
  deleted row.
- Tests first: parser emits remove events (IG + FB); pipeline marks row +
  resolves review items + ignores unknown ids; post-details excludes/marks
  deleted; a golden-pin move = stop.

**F. Review-accept delete: no audit row, raw Graph error (Stage 1c —
operator-hit 2026-07-21).** Accepting "delete" in review on the two gone FB
comments returned Graph `(code 100, subcode 33)` raw, and wrote NOTHING to
`moderation_actions` (verified live: the spine still holds only the two bot
hide rows). Fix: the review-verdict actuator records every attempt in the
actions spine (outcome ok/failed, actor human, source review) like the
console spine does; a `code 100 / subcode 33` on delete/hide maps to
plain-words copy ("Facebook can't find this comment — it was probably
already deleted; it clears automatically when the deletion webhook lands"),
never a raw Graph dump, and never fake success. Locate the decide path
(review-items / turn-trace-admin region) before editing; coordinate with the
Stage 1b seat's files.

**G. Home triple-reports one issue, one row false, one mislabeled
(Stage 1c — operator screenshot 2026-07-21).** The same two FB comments
produced three attention rows: "2 Hidden comments waiting for a decision"
(FALSE — the hides failed, they were public), "2 Instagram comment actions
that failed to send" (they are Facebook), "2 Agent fail-safe outcomes in the
last 24h". Fix: honest copy first — the review row must not claim "Hidden"
when the hide failed (say what is true per row state); the failed-actions
row takes its channel from the data, never a hardcoded "Instagram"; then
fold — one underlying issue = one row (the review row absorbs the
failed-action count for the SAME comments; the fail-safe row stays only for
turns not already represented). Fewer rows, each true.

**Stage 1b addendum — backfill the dropped removes.** The remove webhooks
Meta sent while the parser dropped them are stored verbatim in
`webhook_events` (4 known FB removes 2026-07-21 13:15–13:19; check IG too).
After the remove-handling deploys, backfill: replay stored remove events
through the new handler (or an equivalent one-off), marking the affected
rows deleted + resolving their review items. Self-rectifying law: backfill
what the deploy gap missed. Count and log what was backfilled.

**Stage 1 oracle** (executor-provable, zero customer impact):
`cd apps/api && npx vitest run --pool=forks --poolOptions.forks.maxForks=2`
green (alerts + moderation suites), same for `apps/web`; `pnpm typecheck`;
deploy loop + `curl -s http://localhost:3100/health` → `{"status":"ok"…}`.
The end-to-end live proof (operator's throwaway IG + FB comments after his
token rescope) is a **gate bar witness**, not an executor task.

## Stage 2 — the button: `--cm-*` primitive, pills dead on Agent surfaces

- Build the button primitive on the approved sheet (`design/tokens.md` is
  law; encoded at `apps/web/src/styles/tokens.css`). One primitive, its
  variants driven by `--cm-*` tokens; no screen ships outside the sheet.
- Retire `so-btn`/`so-pill` across the Agent surfaces (~28 occurrences, 8
  files): `routes/AgentKnowledgeDoc.tsx`, `routes/agent/{WhatsAppChannelCard,
  UnderTheHood, Situations, Playground, Models, KnowledgeModuleEditor,
  BehaviourTab}.tsx`. Nothing else — the other ~44 files wait for the
  appshell sweep.
- **Stage 2 oracle:** `grep -rn "so-btn\|so-pill" apps/web/src/routes/agent
  apps/web/src/routes/AgentKnowledgeDoc.tsx` → zero hits; web build green;
  320/375/768 screenshots of each touched screen show no layout break.

## Stage 3 — Agent UI/UX simplification (while touching)

Per the simplify law: short readable text, plain words, no engineering
vocabulary, fewer visible concepts. Applied to every screen Stage 2 touches,
plus the named backlog item: the WA/IG/FB gate-card MODE_HINT paragraphs
(`*ChannelCard.tsx` / `routes/agent/styles.ts`) get the copy diet.
Never a design round; no new operator-visible concept without removing one.
- **Stage 3 oracle:** Fable gate review of before/after screenshots against
  the simplify law; no new concepts net.

## Gate bars

1. Stage 1 A/B/C each landed test-first, deployed, health green.
2. A forced-failure exercise proves the honest title end-to-end in the alert
   store (unit-level force is fine; no customer comment touched).
3. Stage 2 grep-zero + build + screenshots.
4. Stage 3 screenshot review passes the simplify law.
5. **Operator witnesses** (recorded, not blocking the executor): token
   rescope → throwaway IG + FB comment run proves escalate→action→outcome
   live (also closes channels-scope-2 bar 2 and scope-3 bars 5/6/9).

## Gate — interim verdict (Fable, 2026-07-21; final gate after Stage 1b)

- **Bar 1 (Stage 1 A–D): MET, own-oracle verified** — prod checkout 6cb6c7a,
  health `{ok,db,redis}`, and the changed suites re-run by the orchestrator:
  194/194 green (alerts + moderation + ig-comment.service).
- **Bar 2: MET at unit level** — the failed-title path is pin-tested; the
  end-to-end force rides bar 5's witness.
- **Bar 3 (Stage 2): MET with a note** — orchestrator's own grep-zero on the
  fenced files; live site serves the new bundle (200, `index-DUv_v38z.js`).
  Screenshots cover the primitive in every button context at 320/375/768 but
  NOT the full admin screens (routes are role-gated and this box has no admin
  session) — the full-screen look lands with the operator's click-through.
- **Bar 4 (Stage 3): PASS with one seam** — copy is genuinely plainer
  (delete/escalate/leave → "keep, delete, or flag"; jargon gone; no new
  concepts). Seam created: gate cards now say **"flag"** while the escalation
  email + console still say **"escalated"** — one concept, two names. Small
  follow-up within this package: rename the email/console strings to the
  flag/plain family (they are copy strings + their tests, no logic).
- **Bar 5: open** — operator witness (token rescope → throwaway IG + FB
  comment). Unchanged.
- **Stage 1b (defect E): in flight** — its own seat, golden-pin fence.

## Gate — FINAL verdict, executor half (Fable, 2026-07-21 EOD)

**Every executor bar is MET; the package is open only on bar 5 (operator).**
All verified by the orchestrator's own oracle, not seat reports:
- Stage 1 A–D, Stage 1b (E + secondary IG-dedup bug + 13-event backfill: 6
  rows healed, 2 items resolved — prod-DB write disclosed in HANDOFF),
  Stage 1c (F: review acts now audit to the spine, 100/33 reads as plain
  "probably already deleted"; G: Home folds to one honest row, channel from
  data), Stages 2+3 (cm-btn, grep-zero pills, copy diet, flag/keep
  vocabulary unified incl. the missed dropdown, caught at gate and fixed in
  `229dc99`).
- Own-oracle runs: alerts+moderation+ig-comment 194/194 · shared incl. all
  goldens 506/506 · home 56/56 · review 26/26 · monorepo typecheck 7/7 ·
  prod health `{ok,db,redis}` at `229dc99` · live site 200.
- One seat overclaim caught and corrected (Activity dropdown); one
  false-alarm pin scare (EOL churn, zero content diff).
- **Bar 5 remains:** operator token rescope → one throwaway IG + FB comment
  proves flag→action→outcome live (also closes scope-3 bars 5/6/9 and
  channels-scope-2 bar 2). Stage 3's full-screen look rides the same
  click-through.

**Independent review (same day, operator-prompted):** a fresh read-only Opus
seat reviewed the full `a679e92..HEAD` code range against the kickoff.
Verdict: **0 critical, 0 high, 0 security, all defects A–G confirmed
delivered, no convention violations**; engine decision values + golden pins
verified untouched; review/console audit spines confirmed non-double-
recording; the 100/33 plain-copy mapping confirmed to never mask a genuine
permission error (requires code 100 AND subcode 33; all else passes raw).
Four low/informational notes filed on the scope board (delete-blind success
copy — currently unreachable; Home fold null-link guard; classify verb-key
widening; accepted handleRemoval race). Reviewer did NOT verify: admin-
screen visuals (bar 5), the prod backfill (data-only, taken as the disclosed
claim, separately DB-verified by the orchestrator).

## Stage log

- 2026-07-21 — kickoff authored (Fable). Stage 1 executor next.
- 2026-07-21 — **Stage 1 landed (executor).** All defects test-first, per-package
  green, `pnpm typecheck` clean (api + web), deployed to the VPS (6cb6c7a) and
  health-checked `{"status":"ok","db":true,"redis":true}`; web built + published.
  - **A — failed action read as success.** Failure-honest escalation title
    (`Facebook: hide FAILED — comment still public`; success keeps the neutral
    title); notifier body uses the physical verb (hide/delete). Console: added
    `lastActionOutcome`/`lastActionError` to the console-read row and rendered the
    failed act inline on the moderation row (was buried in the history panel).
    Commit **7f427ac**. Tests: `escalation-notifier` 11, `console-read` 7,
    `ModerationView` 19.
  - **B — no detector for moderator fail-open.** New red rule `moderation:failsafe`
    (kind `red.moderation.failsafe`) fires when N+ fail-safe moderation turns land
    in the trailing window; threshold (3) + window (60m) seeded/tunable in
    `alerts.config.moderation.failsafe`. Signal = `TurnTraceAdminService`
    `moderationFailsafeCount` counting turns whose `moderationReason` is a fail-safe
    family (not `decisionSource`, which reflects the reply pass). Evaluator wired.
    Commit **03b6afb** (with C backend). Tests: `alerting-config` 9, `red-rules` 12,
    `turn-trace-admin` 30, `alert-evaluator` 17.
  - **C — real signal buried by a known-dead number.** Per-channel silence mute
    (`alerts.config.silence.mutedChannels`, seeded empty = every channel pages);
    red rule 1 suppresses a muted channel's EMAIL only — Home computes silence
    independently and still shows the truth. Code never pre-mutes a number. Backend
    commit **03b6afb**; plain-words Settings→Alerts toggles ("Email me when
    WhatsApp/Instagram goes quiet") commit **febdf4b**. Tests: `alert-evaluator`
    mute case, `Settings` 15.
  - **D — trace-less review item (orchestrator addition, same Stage 1).** A hidden
    or still-public comment whose best-effort trace write returned null got NO
    review item — only a `logger.error`; a live customer comment could sit hidden
    with nothing to restore it. New `queueModerateReview` helper always creates the
    `moderate` item (`ReviewItem.turnId` nullable by design) and keeps the loud
    trace-loss log; applied to hide-success, hide-failure, and moderation
    delete-failure. Reply-HOLD paths keep the orphan guard (out of D's scope). Web
    already renders null-turnId items (`ReviewQueue` pin; `review-items` list
    enriches from `igComment`) — verified, no web change. Commit **6cb6c7a**.
    Tests: `ig-comment.service` 67 (3 reversed to assert the trace-less item).
  - NOT verified by me (executor scope): the operator's live IG/FB throwaway-comment
    end-to-end run (gate-bar witness, needs the Meta token rescope) and browser
    screenshots. Golden prompt pins untouched (no `shared/src` change); no pin moved.
- 2026-07-21 — **Stages 2 + 3 landed (executor).** Button primitive + copy diet.
  Commit **7fac52f**. `pnpm --filter @channels/web typecheck` clean; web build
  green; web tests green (previously-failing copy assertions fixed + new Button
  test; targeted re-run of the two touched test files + Button = 23/23).
  - **Stage 2 — the button.** One primitive on the token sheet: `.cm-btn` +
    variants `primary/secondary/ghost/danger-ghost`, every value a `--cm-*` token
    in `components/ui/ui.css`, plus a `Button` component beside the other ui/
    primitives (Links carry `cm-btn cm-btn--*` directly, mirroring raw `cm-input`
    usage). Retired all `so-btn`/`so-pill` in the 8 fenced files. Oracle:
    `grep -rn "so-btn\|so-pill" apps/web/src/routes/agent
    apps/web/src/routes/AgentKnowledgeDoc.tsx` → **zero hits**. Removed the now-dead
    `.cm-kb__delete` rule (delete button is `danger-ghost` now). Other ~44 files
    left for the appshell sweep, untouched.
  - **Stage 3 — simplify while touching.** Copy diet on every touched screen and
    the WA/IG/FB gate-card `MODE_HINT`/`MODERATION_HINT` prose: plain words, no
    engineering vocabulary ("swap gate", "endpoint", "deterministic legacy flows"
    gone), no em dashes, fewer concepts. Moderation hints now name the real
    actions in plain words (keep / delete / flag). No new operator-visible concept
    added. Tests re-pointed to the new honest copy (`touches nothing`).
  - Evidence: `plans/rethink/evidence/agent-scope-6/button-primitive-{320,375,768}.png`
    — the real `Button` primitive + real `ui.css`/`tokens.css` rendered in every
    touched-screen button context (actions row, toolbar, table actions, toggle row,
    gate prompt, chip/RowsEditor remove) at the three token breakpoints. No layout
    break; rows wrap cleanly at 320.
  - NOT verified by me: full-screen screenshots of the live Agent screens — they are
    `RequireRole("admin")`-gated and data-driven, and this box has no local
    API/DB nor live admin credentials, so the real screens can't be rendered
    end-to-end here. The primitive evidence above covers the Stage 2 "no layout
    break" bar; the live per-screen look is the operator's bar-5 witness. No golden
    pin moved (no `shared/src` change).
- 2026-07-21 — **Gate seam (bar 4) closed (executor).** Renamed the operator-visible
  moderation vocabulary from the engine word "escalate/leave" to the gate-card
  family "flag/keep": escalation-notifier titles+bodies (shadow "bot would flag a
  comment", live "comment flagged"/"was flagged"; the failure-honest FAILED titles
  from defect A are unchanged) and ModerationView's row verdict pill + replay diff
  (via a display-label map). Engine decision set `delete|escalate|leave` untouched —
  enum values, the `action` field, the `escalation:` dedup key, API payloads, DB
  values, and the moderation prompt are all unchanged (copy + test assertions only;
  no golden pin moved). Commit **a6d286a**. Deployed: api rebuilt + `channels-api`
  restarted, health `{"status":"ok","db":true,"redis":true}`; web built + published.
  Test-first: `escalation-notifier` 12 (flag/keep, no "escalat" leak), `ModerationView`
  21 (verdict in gate-card words). Shared-tree hygiene: only my hunk of the
  defect-E-contended `ModerationView.test.tsx` was staged (`git apply --cached`),
  never the other seat's WIP. **Remaining seam flagged:** `Activity.tsx` still shows
  "would escalate" on its moderation summary strip/filter label — out of this seam's
  named scope (notifier + console), owned by the apps/web seat; needs its own rename.
- 2026-07-21 — **Stage 1b (defect E) landed (executor).** External deletions are no
  longer invisible. Test-first throughout; per-package suites green; `pnpm typecheck`
  clean (all 7 packages); deployed (VPS at a6d286a, shared+api rebuilt, `channels-api`
  restarted, health `{"status":"ok","db":true,"redis":true}`). Golden prompt pins
  untouched — my `shared/src` changes are the comment parsers + the dedup arm, not
  prompt assembly; all `agent/**` golden suites green (the three `.snap` files show
  only Windows EOL churn in the working tree, zero content diff — never staged).
  - **Parsers emit removals** (`instagram-comments.ts`, `facebook-comments.ts`).
    IG: a `remove` verb now emits a `verb:"remove"` event instead of dropping; it
    bypasses the author/loop guards (a deletion keys on the comment id alone and
    never replies). FB: `remove`/`delete` normalize to `"remove"` and emit.
    **hide/unhide decision — dropped, out of scope (documented):** they are
    VISIBILITY toggles, not deletions; defect E's headline is *external deletions*.
    Mapping an external hide onto our `deleted` status would misrepresent state
    (a hidden comment still exists and is reversible) and would need restore/count
    semantics nobody specified — scope creep. `edited`-empty also stays dropped
    (ambiguous: a text-cleared comment may still be a present media-only comment).
    Commit **cdb5912**. Tests: `instagram-comments` 17, `facebook-comments` 28.
  - **Dedup fix (secondary bug found + fixed).** IG's webhook dedup key was a bare
    `comment:<id>` under a UNIQUE constraint, so a `remove` would have been dropped
    at ingest as a false duplicate of the earlier `add` and never reached the
    pipeline. Verb-qualified it (`comment:<id>:remove`) in `meta/classify.ts`;
    add/absent verb keeps the historical bare key (byte-identical — the FB
    "does NOT touch the instagram dedup arm" pin still passes). FB was already
    verb+time qualified, so it survived. Same commit **cdb5912**.
  - **Pipeline** (`comment-pipeline.ts`, shared by IG + FB). A `remove` event
    short-circuits before any decode/decide/act: existing row → status `deleted`
    (reuses the status; our own deletes stay distinguishable by the presence of a
    `moderation_actions` row — an external delete writes none); its WAITING review
    items are resolved (claimed `WHERE status=waiting` → deleted); no stored row →
    ignored silently (never mint a row for a deletion); **no LLM call ever runs**.
    Commit **509c052**. Tests: `ig-comment.service` 70 (+3), `fb-comment.service`
    18 (+3, full envelope→parser→adapter→pipeline).
  - **Views.** Post details (`posts.service.ts`): `detail()` excludes
    `status:deleted` from the comment list and `list()` excludes it from the
    per-post count — deleted comments leave the default list; counts stay honest.
    Console (`ModerationView`): already correct and needs no change — `actionsFor`
    returns `[]` for a `deleted` row (no action buttons ever render), the row shows
    a plain "deleted" status and sinks to the bottom via severity; it is an audit
    surface, so keeping the row is right (our own live-deletes already appear there).
    No web change for defect E; `ModerationView.tsx/.test.tsx` left entirely to the
    concurrent seat. Commit **acfe25c**. Tests: `posts.service` 36 (+2).
  - **Backfill (addendum) done — closes the operator's live complaint.** After the
    remove-handler deployed, a targeted one-off replayed every stored comment
    `webhook_events` payload through the SAME shared parser and applied the SAME
    effect as the live `handleRemoval` (mark row `deleted` + resolve WAITING review
    items). QUERIED, did not assume the 4: **13 distinct FB removes found** (none IG),
    spanning 2026-07-17 → 07-21. Result (idempotent, dry-run-verified first, re-run
    confirms `alreadyDeleted=6 marked=0`): **6 rows marked deleted** (incl. the two
    the operator hit — `1025191506965245_{1586952249764937,2086896965199520}`, both
    stuck `failed`→`deleted`), **2 waiting review items resolved**, **7 removes had
    no stored row → ignored** (never mint a row for a deletion). This is why his
    review-accept delete hit Graph 100/33: the comments were already gone. Ran on the
    VPS as `sopserver1` against prod (reused `@channels/db` + `@channels/shared`); the
    one-off script was removed after. No schema change, no code commit (pure data heal).
  - NOT verified by me (executor scope): the operator's live IG/FB throwaway-comment
    deletion end-to-end run (needs the Meta token rescope — bar 5 witness); browser
    screenshots of the post-detail/console screens (role-gated, no local admin
    session). No golden pin moved.
- 2026-07-21 — **Stage 1c (defect F) landed (executor).** Review-accept delete no
  longer swallows the audit trail or leaks the raw Graph error. Test-first; suites
  green; `pnpm --filter @channels/api typecheck` clean; deployed (VPS at 12a0f9d,
  api rebuilt as sopserver1 + `channels-api` restarted under pm2, health
  `{"status":"ok","db":true,"redis":true}`). No `shared/src` change → no golden pin
  touched; no web change.
  - **Located gap:** the review-verdict actuator is
    `ReviewItemsService.decide → actOnInstagram` (`apps/api/src/review/review-items.service.ts:124,174`).
    It called the SAME Graph client the console spine uses but wrote NOTHING to
    `moderation_actions` and threw the raw Graph message (that is the operator's
    2026-07-21 `(code 100, subcode 33)` dump); the spine held only the bot's two
    pipeline hide rows. Confirmed before editing.
  - **Audit spine.** New best-effort `recordReviewAction` (mirrors the pipeline's
    `recordBotAction` and the console `ModerationActionService` shape) records EVERY
    attempt — approve/send→`reply`, delete→`delete`, restore→`unhide` — as
    actorType `human`, `source:"review"`, outcome ok/failed + error, resultExternalId
    on a posted reply. Same client, no second Meta integration. A failed audit write
    is logged, never thrown (the act already happened). Console moderation rows now
    show these acts automatically (`console-read` already reads
    `lastActionOutcome`/`lastActionError`).
  - **Plain words for a gone comment.** New pure helper `graph-act-copy.ts`:
    `code 100 / subcode 33` (both required) → "Facebook can't find this comment — it
    was probably already deleted. It clears automatically when the deletion webhook
    arrives."; every other Graph error passes through unchanged. NEVER fakes success
    — the ig-comment row status is untouched (the Stage 1b remove-webhook handler is
    the truth-bearer); the item re-opens to `waiting` so the webhook clears it.
  - **Web:** `ReviewQueue` already renders the thrown message via
    `toast.error((err as Error).message)` and `api.ts` `request()` carries the
    server message — the plain copy surfaces with no web change (checked, not needed).
  - **schema.prisma:** `ModerationAction.source` doc comment reserves `review`
    (comment-only, no migration). Commit **12a0f9d**. Tests: `graph-act-copy` 4,
    `review-items.service` 22 (+7).
  - Flagged (micro): the schema comment previously reserved `review_queue`; the order
    said `source:"review"` — followed the order and aligned the comment to it. Nothing
    reads/filters `source`, so no runtime effect.
  - NOT verified by me: the operator's live review-accept-delete end-to-end (needs the
    Meta token rescope — bar 5 witness); role-gated console/review screenshots.
- 2026-07-21 — **Defect G + the Activity copy seam landed (executor).** Deployed:
  api rebuilt + `channels-api` restarted, health `{"status":"ok","db":true,"redis":true}`;
  web built + published. `pnpm typecheck` clean (api + web).
  - **G — Home triple-reported one issue.** The two failed FB hides made three
    attention rows (one FALSE "Hidden", one mislabeled "Instagram", one duplicate
    fail-safe). `HomeService.attention()` now splits the `moderate` review row by
    the linked comment's REAL state (genuinely hidden → "hidden, waiting for a
    decision"; failed hide/delete → "<Channel> comments still public — the bot's
    hide or delete failed"), takes the channel from the comment data (`channelLabel`,
    never hardcoded), and FOLDS: the failed-action comments are represented once by
    the review row; the standalone failed-sends row shows only comments not already
    in a review; the fail-safe row counts only turns whose comment isn't already
    represented (`correlationId` ∉ shown set). Web `attentionKind` maps the new
    `comment:failed:*` key. Commit **1cf7258**. Tests: `home.service` 30 (fold to
    one FB row, channel honesty, hidden-vs-failed, residual failed + residual
    fail-safe), `Home` web (attentionKind).
  - **Activity copy seam** (the loose end flagged after the gate-4 rename). Activity's
    moderation filter options ("Would flag"/"Would keep"), summary strip ("would
    flag"/"would keep", "N of the flags…"), and the per-row moderation pill now use
    the gate-card family via the same display-label map as ModerationView. Filter
    VALUES (`delete|escalate|leave`) and all engine/API fields unchanged — copy +
    tests only, no golden pin moved. Commit **9540fe1**. Tests: `Activity` web 37.
  - Region discipline: stayed in the home service + its web rendering and Activity;
    did NOT touch the Stage 1b seat's files (parsers, ig-comment.service,
    posts.service, PostDetail, ModerationView.test). Defect F remains unassigned to
    me (its region collides with 1b).
- 2026-07-21 — **Correction to 9540fe1 (gate re-grep).** The Activity moderation
  filter dropdown had been reported as flag/keep but committed with the engine words
  still in the options — a concurrent write in the shared tree reverted that one
  `<select>` block before 9540fe1 committed (the summary strip + row pills were
  correct). Fixed labels only: `Would escalate → Would flag`, `Would leave → Would
  keep` ("Would delete" is the same word in both vocabularies); option values
  `delete|escalate|leave` unchanged; no test asserted these labels. Commit
  **229dc99**, verified committed (`git show 229dc99:apps/web/src/routes/agent/Activity.tsx`
  lines 254–255 = "Would flag"/"Would keep"). Web built + published on the VPS
  (source re-grepped there); web-only, no api restart.
