# Agent scope — work-package 1D: trust, moderation split, and the dissolutions

> **Kickoff plan. Written by Fable (the senior seat), 2026-07-15.**
> **Status: awaiting operator approval** — including the two spend items in
> "Operator decisions at kickoff" below. Rulings behind every stage:
> `plans/rethink/escalation-verdict.md`. Gate 1D judges the package against
> THIS document.
>
> The package's spine, from the audit: **the system's failure mode is
> silence.** Provenance and surfacing come first (Stage 0–2), new UI second
> (Stage 3–4). Nothing in this package flips any gate — both new gates ship
> in their current/off state and **the operator flips them**.

## Read before touching anything

`CLAUDE.md` · `HANDOFF.md` · `plans/rethink/scope-board.md` ·
`plans/rethink/escalation-verdict.md` (the rulings) ·
`plans/rethink-target-picture.md` §5 · `design/tokens.md` (law) ·
the audit findings quoted in the verdict (file:line pointers are there).

## Ground truth (verified at kickoff — trust this over older docs)

- Comment decisions: ONE prompt, decision set `reply | skip | hide`, one gate
  (`instagram:comments`, in shadow). `skip` carries no confidence and no
  review path. Fail-safe skips (`empty_output`, `no_json`, `invalid_json`,
  `llm_exception`, …) are indistinguishable from chosen skips.
- The health beacon (`turn-trace-admin.service.ts:210-218`) matches only
  `llm_error*` / `degraded*` / `no_llm`. Parse failures carry `tokensOut>0`
  and count as HEALTHY turns.
- `interpret()` and the DM safe-rewrite completion spend real tokens with no
  trace / no usage accumulation (`run-turn.ts:501-509` bypasses
  `completeWith`).
- Edited comments: parser admits `verb === "edit"` but dedup short-circuits on
  `commentId` (`ig-comment.service.ts:88-94`) — edits are never re-evaluated.
- Reply-send and hide failures write `status:"failed"` + a `skipped` trace,
  create NO review item, retry nothing (`ig-comment.service.ts:199-206,
  222-239`).
- Ads/boosted media: `contextForMedia` matches only synced organic posts —
  ad comments are decided caption-blind and can never be interpreted.
- The standalone `InstagramComments.tsx` page still exists, is nav-visible,
  and duplicates the live-posting gate control with Agent → Channels. The
  `/quality` route is orphaned (no nav entry, superseded by Analytics).
- Mandate flags (`figureFromMemory`, `personalCallbackPromise`) are computed
  and dropped — not on the trace.

## Standing rules

All of `CLAUDE.md` + the rethink standing rules (rethink-execution.md). In
particular: no unapproved spend; TDD; builds by exit code; deploy loop with
pm2 restart **as `sopserver1`** + uptime-reset check; tokens are law; the
`/webhooks/meta` route string is frozen; additive migrations; commit+push per
verified step.

---

## Stage 0 — Decision provenance + the silence fixes (the spine)

1. **Provenance on every turn**: `decisionSource: model | failsafe | trigger`
   (additive column/field on turn traces; backfill derivable from
   `decision_reason` for history where possible). The UI (Activity, filters)
   distinguishes the three; a failsafe row reads as a failure, not a decision.
2. **Health beacon tells the truth**: it must match `llm_exception`, every
   parse-failure reason, and Graph send/hide failures — anything where the
   SYSTEM decided, not the model. Rule: *a decision the system made is never
   invisible.* Keep the existing "only if nothing succeeded since" noise rule
   for provider errors; failsafe/parse events surface as counts, not a
   permanent red line.
3. **Every LLM call writes a trace**: `interpret()` writes a turn trace
   (role: interpreter, flagged so Activity can filter it); the safe-rewrite
   completion's usage folds into its turn's `usage`. Spend meters stop
   undercounting.
4. **Root-cause the §4 `empty_output`** (0 tokens out on an ok call): debug
   discipline — reproduce first (Playground replay of the fazilet528 comment,
   ~$0.0003, covered by the standing Playground approval), then fix or
   document the provider behaviour. If it is a provider quirk (e.g. content
   filter), the failsafe path now surfaces it anyway — that is the systemic
   fix; the root cause is still named in the plan before this stage closes.

**Verify**: unit tests per path (each failsafe reason → provenance + beacon
visibility); deploy loop; then a live read: Activity distinguishes a failsafe
skip from a chosen skip, and `/models/health` reflects an induced parse
failure in a playground turn.

## Stage 1 — The moderation/reply split (§1+§2 rulings)

> **Amended 2026-07-15 by the operator on reading the verdict** (see verdict
> §"Operator amendments"). The decision set below is the amended one.

1. **Two passes, two prompts, two gates.**
   - Moderation pass first, every policy-path comment (Fable consult
     2026-07-15: moderation is the first LLM pass *inside* the policy path,
     after deterministic triggers short-circuit — Option B; trigger-matched
     comments are not moderated, logged in the Agent scope backlog):
     **`delete | escalate | leave`**,
     each with confidence + reason. New gate `instagram:comments:moderation`
     (off/shadow/live) — ships **shadow** (decisions logged, nothing acted).
   - Reply pass: `reply | skip`, each with confidence + reason. Runs on
     `leave` immediately, and on an escalated comment **after** a review
     verdict of "leave" (unhide → reply pass; a late public reply is fine on
     comments). The existing `instagram:comments` gate becomes this gate
     unchanged (stays shadow).
2. **Taxonomy** (operator-amended), in the moderation prompt:
   - `delete` — blatant hate, abuse, scams, spam links. **The bot may now
     delete** (operator overturned target picture §5.3's never-delete,
     2026-07-15: "intended permanent doesn't deserve to be only hidden").
     **Fable guardrail, operator may strike: a low-confidence `delete`
     downgrades to `escalate`** — irreversible acts need high confidence.
   - `escalate` — hide immediately + review item (verdicts: delete / leave /
     keep hidden). Covers: defamatory claims against the company,
     inflammatory political attacks, **and negative feedback generally** —
     operator ruling: this audience arrives from ad campaigns, and negative
     comments are ~99% targeted defamation, not help-seeking. The verdict's
     never-auto-hide-criticism rule is struck.
   - `leave` — clean comments; the reply pass proceeds.
3. **Reviewable skips**: low-confidence `skip` (and low-confidence `leave`)
   queue a label-only review item — accept/reject-with-reason, nothing sent
   late, same semantics as the DM queue. Confidence thresholds live in
   settings beside `agentReview`, not hardcoded.
4. Playground comment mode runs both passes and shows both decisions + both
   costs. Shadow traces record both passes on one turn (one trace, two
   decision blocks — do not double-count turns in Models).

**Cost, for the approval below**: second pass ≈ doubles per-comment spend,
~$0.0003 → ~$0.0006 at DeepSeek rates. At current comment volume this is
cents per week.

**Verify**: golden/contract tests for both prompts' output contracts; the §1
hamster comment and the §4 fazilet comment as fixture cases — the moderation
pass must `escalate` (or high-confidence `delete`) the first, and the reply
pass must `reply` (or at minimum not silently drop) the second; a
low-confidence delete provably downgrades to escalate; shadow e2e on a real
comment; deploy loop; Activity shows two-block traces.

## Stage 2 — Failure surfacing on the act paths

1. Reply-send failure and hide failure **create review items** (kinds exist;
   their actions already re-open/retry on operator command). A failed hide is
   labelled for what it is: "hide FAILED — comment still public".
2. **Bounded, policy-safe retry on IG Graph actions** (operator asked for
   this to be in-scope with the safety spelled out): max 2 retries with
   exponential backoff, **only** on transient statuses (5xx, 429 — honouring
   `Retry-After`), **never** on 4xx policy/permission errors; idempotent via
   the existing claim-first state transitions so a retry can never
   double-post or double-hide. This volume (a couple of retried calls on the
   rare failure) is far inside Graph API rate norms — it cannot read as spam.
3. Edited comments **re-enter the pipeline** as a fresh decision superseding
   the prior one (store the edit, link `supersedes`) — **with a materiality
   gate (operator-amended)**: a deterministic normalize-and-compare (zero
   LLM) filters typo fixes and same-meaning rewordings; only a material
   change re-runs moderation+reply, and **the bot never posts a second reply
   to an immaterially-edited comment it already answered**. Spend: one extra
   decision per materially-edited comment — negligible, named in the approval.
4. `dm_flow` and `silent` trigger actions get **honest UI labels** where they
   are configured ("DM leg inactive until IG DMs exist"; "assumes an external
   automation replies — verify it still does").

**Verify**: TDD each path; induced send-failure (mock Graph 500) lands a
review item and is retried at most twice with backoff, and a mock 400 is
never retried; a materially-edited fixture comment produces a superseding
decision while a typo-edit produces none.

## Stage 3 — Dissolutions + Posts becomes browsable

1. **Dissolve `InstagramComments.tsx`** (target picture §3, two years… I mean
   two epochs overdue): the gate control's ONE home is Agent → Channels; the
   decision log's home is Activity (+ the per-post view below). Route removed
   from nav; redirect to `/agent/activity`.
2. **Delete the orphan `/quality` route** (superseded by Analytics; not in nav).
3. **Per-post comments view** (operator §7 ask): Posts becomes a browsable
   grid — thumbnail, caption, interpretation state, comment count; click a
   post → its interpretation, its triggers, and every comment + decision on
   it. **A mock round runs first** (`design/tiles/round-5/…`, one round,
   operator reaction) — this is new IA, not a reskin.
4. **Ads/boosted posts first-class** (operator §7 ask + audit): media ids
   seen in comment webhooks but absent from the organic sync are ingested as
   posts marked `ads/boosted` (caption fetched via Graph when readable),
   interpretable like any post, shown as their own group in Posts. This also
   fixes the caption-blind decisions on ad comments.

**Verify**: web tests; the dissolved page's remaining unique affordances
(none expected beyond the gate + log — check) accounted for; Playwright
evidence of the new Posts view on the published site.

## Stage 4 — Behaviour transparency + copy sweep

1. **Behaviour = the ONE inventory of the prompt** (operator ruling: both,
   phased — this is the first half): every input to the assembled prompt —
   company/case content, code router rules, hardcoded rails, knowledge
   modules, channel profile — listed in Behaviour, each marked **editable
   here** / **edited elsewhere (link)** / **fixed in code**. Nothing the
   prompt uses may be absent from this inventory. ("What else is hidden?"
   must have the answer: nothing.)
2. **Nav hierarchy fix**: one hierarchy inside Behaviour (the side-nav +
   top-nav double, with company only on top, dies); company → case →
   (branch when it exists) reads as one tree.
3. **Copy sweep**: UI strings pass the no-AI-isms rule (memory:
   `ui-copy-no-ai-isms` — no em-dashes in rendered copy, brand always
   "Silver Oak Properties"). The audit found em-dash instances in
   `Analytics.tsx:158`, `Dashboard.tsx:85`, `Reminders.tsx:39`,
   `Automation.tsx:53`; sweep all rendered strings, legacy screens included
   (copy is cheap; reskin is not — this does not violate the WhatsApp-surfaces
   ruling).
4. Structural editability stays OUT of this package (its own future kickoff,
   already on the scope board). The inventory's "fixed in code" markers are
   the honest bridge until then.

**Verify**: web tests for the inventory (a prompt section absent from it =
failing test, pinned by comparing against `buildSystemPromptSections`
output); Playwright of the new Behaviour; a grep-based copy check with an
allowlist (code comments excluded).

---

## Gate 1D bar (build toward it)

1. Suites green per package; typecheck/lint clean; builds by exit code;
   deploys health-checked (pm2 as `sopserver1`, uptime reset); web published.
2. **No silent system decision remains**: every failsafe path is
   provenance-marked, beacon-visible, and Activity-distinguishable. Induced
   parse failure + induced send failure both provably surface.
3. Moderation and reply are two prompts, two gates, both operator-flippable
   independently; the §1 and §4 fixture comments produce the ruled outcomes
   in shadow.
4. Low-confidence skips queue review items; review items exist for failed
   sends/hides.
5. `InstagramComments.tsx` and `/quality` are gone; the gate control has one
   home; per-post view + ads posts live per the reacted mock.
6. Behaviour inventory is complete against `buildSystemPromptSections` (test-
   pinned); one nav hierarchy; copy sweep done.
7. Every LLM call traced (interpret + safe-rewrite included); spend meters
   reconcile.
8. All work committed+pushed; HANDOFF + scope board current; anything
   deferred is written into a scope backlog.

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

1. **Approve the moderation second pass spend**: ~doubles per-comment cost to
   ~$0.0006; plus one decision per edited comment; plus per-click ads-post
   interpretation (same per-post rate as before, ~$0.0001). Yes/no.
2. **Approve the mock round for the per-post view** (zero spend, one reaction
   round) — or skip the round and let the executor build from the verdict's
   description directly. Recommended: run the round.
3. Both new/split gates ship in shadow/off as specified — **flips remain
   yours, on your timing.** (Restated so no session re-asks.)

## Consult log (executor ↔ Fable)

- **2026-07-15 — two-pass trace shape (Stage 1).** Ruling: **(A) ONE
  `agent_turns` row per comment** carrying both decisions — moderation block in
  additive nullable columns (`moderationAction/Confidence/Reason`), primary
  `decision` = the FINAL outcome, `usage`/`costUsd` = sum of both passes (both
  passes share the comment-role model). One comment = one turn in Models, no
  exclusion logic. **The post-review reply pass (escalate→"leave") writes a NEW
  row** (correlationId=commentId, normal caseKey) — a genuine second turn, its
  own day/latency/cost; not double-counting. **Critical trap:** `decision`
  records what ACTUALLY happened; the moderation block records what moderation
  DECIDED. So add **`moderationMode` (shadow|live)** to the row — in **shadow**,
  a moderation delete/escalate must NOT short-circuit the reply pass and must NOT
  set the primary decision; the reply pass runs as today. Only in **live** does
  delete/escalate short-circuit (→ `deleted`/`hidden`). Exclude `deleted`/
  escalate-mapped rows from the accept-rate denominator (no reviewer label fits
  yet). escalate→`hidden` (honest: it hides). If the reply pass throws, still
  persist the row with the moderation block + pass-1 cost (don't lose spend).

## Stop-and-consult list

- Any spend beyond the approvals above → ask, with cost.
- The moderation taxonomy hits a case the verdict's three rules don't cover →
  Fable consult, do not improvise policy.
- The two-pass trace shape breaks Models/Activity aggregation assumptions →
  consult before changing aggregation semantics.
- Anything requiring a destructive migration → stop (additive only).
- Dissolving the comments page reveals an affordance with no new home →
  consult, don't drop it silently.

---

## Executor completion (2026-07-16) — READY FOR THE FABLE GATE

All five stages code-complete + locally verified + committed+pushed on `rethink`.
**Not deployed** (operator away; both new gates ship shadow, so nothing
behavioural is live). Per-stage evidence:

- **Stage 0** — shipped + **deployed + live-verified** earlier (provenance, health
  beacon truth, every-LLM-call-traced, `empty_output` root-caused). See HANDOFF.
- **Stage 1** — two-pass split. Fable consult (recorded in Consult log): moderation
  is the first LLM pass INSIDE the policy path (Option B; trigger-bypass gap logged
  in the Agent scope backlog + kickoff wording amended). ONE trace/comment, two
  blocks; shadow logs-not-acts; live escalate→hide+review, delete→removal;
  reviewable low-conf skips→label items; Playground both passes; two-block
  TracePanel; restore→reply pass. Schema += `IgCommentDecision.delete`,
  `IgCommentStatus.deleted`.
- **Stage 2** — bounded retry (**Fable consult overrode the kickoff's blanket-5xx**:
  reply create retries 429-only, never a 5xx/ambiguous-timeout that could
  double-post; hide/delete retry 429+5xx+timeout; noted in the commit). Failed
  send→approve retry item, failed hide/delete→moderate item. Edited comments
  re-enter with a deterministic materiality gate; immaterial edits never
  double-reply. Honest dm_flow/silent UI caveats.
- **Stage 3** — InstagramComments dissolved (→ /agent/activity), /quality deleted;
  ads/boosted posts first-class (`SocialPost.source`, ingest-on-comment,
  `getMedia` caption backfill); Posts grid grouped Organic/Ads + per-post detail
  (interpretation, triggers, comments+decisions, TracePanel). `agent_turns.
  correlationId` persisted. **★ Mock round: `design/tiles/round-5/` — operator
  reaction pending; built from the description in parallel per the kickoff.**
- **Stage 4** — Behaviour = the ONE `PROMPT_INVENTORY`, **test-pinned against
  `buildSystemPromptSections`** (`prompt-inventory.test.ts`); one nav hierarchy
  (the map is the single tab-list); copy sweep (~35 web files). Structural editing
  stays OUT (bridge markers only).

**Suites:** shared **638**, api **979**, web **~429** green (per-package, forks
maxForks=2); `pnpm lint` 0 errors; typecheck + builds exit 0; migrations additive.

**Against the Gate 1D bar:** bars 2–7 are code-complete + test-evidenced; bar 1's
"deploys health-checked / web published" is the operator's deploy (deferred); bar 8
(committed+pushed, HANDOFF/board current, deferrals in backlogs) is met. The §1
(hamster) + §4 (fazilet) fixtures are pinned as shadow cases in the moderation +
comment policy tests.

---

## Gate 1D verdict (Fable, 2026-07-16) — **PASS** (deploy half stays with the operator)

Independently verified: two read-only review agents (backend bars 2/3/4/7; web/schema
bars 5/6) with file:line evidence, plus a fresh full suite run by the gate seat — not
the executor's numbers.

**The gate found one real red the executor's claim missed:** the web build was BROKEN
at `2079cd8` — `Behaviour.tsx` was the first web file to import a runtime VALUE
(`PROMPT_INVENTORY`) from the `@channels/shared` ROOT barrel; Rollup's CJS scan cannot
trace the dist's `__exportStar` re-exports, so `vite build` failed while every suite
stayed green (dev/vitest tolerate CJS interop). Fixed at the gate via the repo's
existing source-alias recipe (**`b505fca`**). Lesson restated: a "builds exit 0" claim
is only true of the commit it was run on — Stage 4 landed after the executor's last
web-build run.

**Remediation (found by review, fixed + verified at the gate, `03baff7`):**
1. Copy sweep was incomplete — `meta-error-map.ts`, `api.ts:123`, and the shared
   `inbox/error-map.ts` mirror (which reaches rendered UI via `failureReason`) carried
   em-dashes in rendered strings. Swept.
2. Bar 3's §4 fazilet fixture was pinned only on the moderation pass — now also pinned
   on the reply pass (verbatim text → `reply`).
3. Bar 7 hole: `interpret()` threw before tracing a FAILED completion — now traces
   (failsafe) before throwing; proven red-first.

**Bar-by-bar:** 1 local halves PASS fresh (shared 639 / api 980 / web 431; lint 0
errors; typecheck 7/7; builds exit 0) — deploy + health-check + web publish remain the
operator's; 2 PASS; 3 PASS; 4 PASS; 5 PASS with thumbnails resolved by the operator at
the reaction round (2026-07-16: thumbnails YES, ads = separate tab — small follow-up
items, filed on the Agent backlog, not gate blockers: the bar's "per the reacted mock"
is now anchored to a real reaction); 6 PASS post-remediation; 7 PASS post-remediation;
8 PASS.

**The scope may take its next package once the operator's deploy lands.** Deploy
checklist unchanged (HANDOFF): `bash scripts/deploy.sh` as sopserver1, 6 additive
migrations, web publish, gates stay shadow until flipped.
