# agent-scope-8 — shadow → propose-and-approve, + operator live-use fixes

> Fable kickoff, 2026-07-22. Source: operator prompt-line directives 2026-07-22
> (board Intake, same date) + the shadow-visibility investigation (findings
> below). Web + api/worker work; engine prompt content untouched.

## Context (verified, do not re-investigate)

Shadow-mode reply turns ARE drafted and persisted, but invisible in Activity:

- Would-be reply text lands only on `ig_comments.reply_text`
  (`apps/api/src/instagram/comment-pipeline.ts:443`).
- The shadow branch (`comment-pipeline.ts:502-511`) calls `writeTrace` with
  `turnDecision="skipped"`, `sentText=null`; `writeTrace`'s `desiredText`
  only fires on `"queued"` (`comment-pipeline.ts:931`) → trace has no text.
- Feed API never joins `IgComment.replyText`
  (`apps/api/src/feed/agent-feed.service.ts:237-240`); `desiredText` on a
  feed row comes only from a waiting `ReviewItem.draftText`
  (`agent-feed.service.ts:348`). Shadow creates no ReviewItem.
- Web renders the empty row; composer prefills from `sentText`
  (`Activity.tsx:818`), TracePanel omits null texts (`TracePanel.tsx:196-213`).

Operator directive (2026-07-22, verbatim intent): *in shadow mode, instead of
just not doing anything and only showing what it would do, let it ask for
approval to actually do it.*

## Stage 1 — shadow reply = approval request (the directive)

A shadow-gated **reply** decision creates a waiting review item carrying the
draft (`draftText = decision.text`), so it lands on Activity's Needs-action
preset; the operator's **accept sends the real reply** through the existing
review-accept path. Requirements:

1. Reuse the existing review-item + accept machinery — verify the accept path
   actually posts for a comment whose turn was `skipped`; extend if needed.
   No parallel approval system.
2. Trace honesty: shadow turn's trace records the draft (pass the text into
   `writeTrace` so `desiredText` is set; reason stays `shadow: would reply`,
   copy may become plain "awaiting your approval").
3. An operator REJECT (existing dismiss path) resolves the item; nothing sends.
4. Sends still ride the existing per-channel send path + audit
   (`moderation_actions`/turn update as the live path does) — an approved
   shadow send must not look like an autonomous send in the log
   (actor = operator/review, not bot).
5. Historical shadow rows (52 IG dry_run + FB): do NOT backfill review items
   (would flood Needs-action). Instead surface their stored draft read-only:
   join `IgComment.replyText` onto the feed row for `dry_run` comment turns
   so Activity/TracePanel show "Would have replied: …". Composer prefill uses
   it too (`Activity.tsx:818`).
6. UI copy: where the shadow state shows, say what it now means in plain
   words ("Draft waiting for your approval"), no engineering vocabulary.

**Gate-semantics note (ledger in DECISIONS on completion):** shadow stops
meaning "never posts"; it means "posts only what the operator approves,
item by item". No autonomous send is possible in shadow — that invariant is
a test.

## Stage 2 — operator live-use fixes (small, batched)

1. **Activity outcome column discipline** — one short vocabulary for the
   outcome cell (draft the closed set, plain words, ≤2 words each; e.g.
   replied / draft waiting / skipped / flagged / deleted / failed). No
   stacked pills, no raw engine strings. Fable reviews the vocabulary at gate.
2. **Labels removable** — a right/wrong example label can be cleared back to
   unlabelled (UI + API + the labels-feed-examples pipeline treats cleared as
   never-labelled).
3. **Wrong learned examples** — labels are training data for moderation
   examples; add the correction affordance (change keep↔delete on a labelled
   example). The two specific mislabelled comments the operator saw need his
   pointer (screenshot never reached the session) — leave a note in the gate
   section; do not guess.
4. **"See exact prompt" is non-scrollable** — fix (CSS overflow, likely the
   modal/panel container).
5. **DMs tab → "Conversations"** — rename, nav + route label (route path may
   stay; label change is the ask).
6. **Situations fixed blocks demoted** — non-editable blocks render quiet
   (collapsed/summary row, muted), not as dominant cards. Editable content is
   the page's visual foreground.
7. **"Callback number and time" WhatsApp-only wording** — move the
   WhatsApp-specific sentences into that block's per-channel (whatsapp)
   difference; base text stays channel-neutral. Content edit via the seeds +
   the situations data path — golden pins regenerate deliberately if wording
   moves; state the diff in the stage log.

## Deferred (NOT this package)

- **Instruction-field standardization overhaul** (Intake ⑦) — needs its own
  design + kickoff (operator called it "almost an overhaul"). Do not start.
- Anything touching gate VALUES (`channelGates`) — flips stay the operator's.

## Fences / rules

- Standing rules: TDD, per-package vitest recipe (forks pool, maxForks=2),
  deploy loop (shared build → api build → pm2 restart → health), tokens are
  law, commit+push per verified step, HANDOFF updated.
- Golden pins: byte-identical unless a stage explicitly moves seed wording
  (Stage 2.7); regeneration is deliberate + logged, never silent.
- No LLM spend. No gate flips. No Meta config.

## Gate (Fable verifies)

1. Shadow reply → waiting review item with draft visible on Needs-action;
   accept posts it (evidence: test + trace/audit rows); reject resolves silently.
2. Invariant test: no send occurs in shadow without an accept.
3. Historical dry_run rows show their stored would-be reply in Activity.
4. Outcome column shows the closed vocabulary; Fable copy review.
5. Labels clearable; example-label correction works end-to-end.
6. Prompt view scrolls; DMs renamed; fixed blocks demoted; callback wording
   moved per-channel (diff shown).
7. All suites green per-package; health OK; web built + published.

## Stage log

**Stage 1 — shadow reply = approval request. DONE + LIVE (2026-07-22).**
- `comment-pipeline.ts` shadow branch: a shadow REPLY now writes a `queued` trace
  (draft on `desiredText`), sets the row `held`, and creates a waiting `approve`
  review item with `draftText` — the exact held-reply machinery. Non-reply shadow
  decisions (would-hide) stay trace-only. Channel-agnostic (IG + FB).
- Accept posts through the EXISTING review path (`review-items.service` `approve`/
  `send` → `client.replyToComment` → row `posted`), audited human/review (not bot).
  Verified: added a test that an approve item minted by a shadow (skipped-turn)
  reply accepts, posts, and audits actorType human / source review.
- Invariant test: a shadow reply NEVER calls the channel client (no reply/hide/
  delete, no bot moderation-action row).
- Historical rows: NOT backfilled. Feed joins `IgComment.replyText` for `dry_run`
  comment turns → `wouldReplyText`; Activity shows it read-only ("Would have
  replied: …") and the composer prefills from it. Reject resolves silently (existing
  dismiss path). Copy: needs-action pill reads "draft waiting for your approval".
- Tests: api ig/fb/feed/review suites green (127); web Activity green (incl. new
  historical-draft test). Typecheck + build clean. Deployed: `channels-api` rebuilt
  + restarted (uptime reset), health `{ok,db,redis:true}`.

**Stage 2.1–2.6 — operator live-use fixes. DONE + LIVE (2026-07-22).**
- 2.1 Activity outcome column: one closed vocabulary via `outcomeLabel()` —
  replied · draft waiting · skipped · flagged · deleted · failed. Stacked pills
  (decision/source/moderation/status) removed. Operator's own right/wrong verdict
  kept as a quiet ✓/✗ glyph (a separate axis, not an outcome pill).
  **Fable copy review pending (Gate bar 4)** — the closed set is drafted above.
- 2.2 Labels clearable: clicking the set verdict again clears it (`clearLabel`).
  A cleared label deletes the row → the examples pipeline already yields no example
  (never-labelled). Verified end-to-end (test).
- 2.3 Correction affordance: clicking the OTHER verdict corrects a mislabelled
  decision (test). Placed in Activity (where labels are set) — one concept, one
  surface; `ExamplesBlock` stays its read-only reflection.
- 2.4 "See exact prompt" drawer scrolls: flex body needed `min-height:0` for
  `overflow:auto` to engage.
- 2.5 DMs situation → "Conversations" (route id `dms` unchanged; test pins it).
- 2.6 Fixed (non-editable) blocks demoted to quiet one-line summary rows.
- Tests: web Situations (36) + Activity (20) green. Deployed: web built on VPS +
  published; site 200.

**Stage 2.7 — callback wording. DONE + LIVE (2026-07-22, Option A per Fable ruling).**
- `prompt-seed.ts` extra-contact seed: the `[[buttons]]\nMorning\nAfternoon\nEvening`
  block is now gated on `profile.interactiveInstructions !== undefined`. WhatsApp/web
  (interactive) keep the tappable buttons; Instagram/Facebook (no interactive markers)
  ask the same call-time question in plain words ("Morning, Afternoon, or Evening, or
  they can just say 'anytime' or name a time."). `altContactNote` was already
  per-channel, so no other WhatsApp-ism leaks.
- **WhatsApp byte-identity (hard bar) — PROVEN:** the WhatsApp/web golden snapshots
  did NOT move. `channel-profile.golden.test.ts.snap` (whatsapp-only external prompt +
  tool defs) is byte-untouched (0-line diff, restored). `situations-golden` whatsapp/
  external (seed + full + job-seeker-disabled + removed-pivots + clientFlow) and
  `routing-equivalence` whatsapp (minimal + full) all passed WITHOUT regeneration.
- **Deliberate IG/FB regen — exact diff:** 4 snapshots updated, across 2 files:
  `situations-golden` → instagram/external (seed), facebook/external (seed), instagram
  full; `routing-equivalence` → instagram external (v2 behaviour). Each block's only
  change: `Second, when they'd prefer a call back, as buttons:\n[[buttons]]\nMorning\n
  Afternoon\nEvening\nThey can also just say 'anytime' or name a time.` →
  `Second, when they'd prefer a call back — Morning, Afternoon, or Evening, or they can
  just say 'anytime' or name a time.` No comment/moderation/whatsapp/web pin moved.
- Display base for dms stays `whatsapp` (`SITUATION_DISPLAY_CHANNEL` untouched; Option
  B off the table). No web rebuild — the Situations per-channel text is server-resolved
  (the API now serves the new IG/FB seedText).
- Tests: full shared suite 963/963 green (only the 4 intended snapshots updated) +
  a new behavioural test (WhatsApp/web keep `[[buttons]]`, IG/FB carry none). Deployed:
  shared + api + worker rebuilt on VPS, `channels-api` + `channels-worker` restarted,
  health `{ok,db,redis:true}`. Commit `d81e59a`.

## Gate verdict — PASS (Fable, 2026-07-22)

Own-oracle: api 1422/1422 (136 files) + web 602/602 (78 files), both run by
the gate session (forks pool, capped); prod checkout at `dcfed29`, site 200,
health `{ok,db,redis:true}`; invariant + approval tests confirmed in source
(`ig-comment.service.test.ts:300,334`); prod DB shows 1 historical dry_run
draft (most shadow decisions were skips — engine history, not a defect).
Bar 4 copy review: the closed set (replied · draft waiting · skipped ·
flagged · deleted · failed) APPROVED. DECISIONS entry written (2026-07-22).
2.7 fork RULED: **Option A** ordered as a follow-up stage; Option B only on
explicit operator ask. Still open (operator): authenticated glance; pointer
to the two mislabelled comments.

## Gate notes / open questions (for Fable)

**2.3 — the two specific mislabelled comments (operator pointer needed).** The
correction affordance is built and works, but the operator's screenshot of the two
mislabelled comments never reached this session. He needs to point at them (turn ids
or the comments) so we can confirm the corrected labels flow into the examples set.
Do NOT guess which two.

**2.7 — callback WhatsApp-only wording: a genuine fork the kickoff doesn't resolve.**
The kickoff asks for "base text channel-neutral; WhatsApp-specific sentences moved to
that block's per-channel (whatsapp) difference". But the settled architecture makes
this contradictory as stated:
- The `extra-contact` seed (`prompt-seed.ts:149`) is already channel-aware: it
  interpolates `getChannelProfile(channel).altContactNote` and hardcodes the
  `[[buttons]] Morning/Afternoon/Evening` block. The WhatsApp-specific wording is
  (a) the WhatsApp `altContactNote` ("you ALREADY have their WhatsApp number… never
  call it 'mobile'") and (b) the `[[buttons]]` interactive syntax (WhatsApp-only;
  IG/FB have no interactive markers).
- The Situations DISPLAY base for the DMs situation is resolved at
  `SITUATION_DISPLAY_CHANNEL.dms = "whatsapp"` (`situations.ts:396`). So WhatsApp
  **is** the base for DMs and cannot also be shown as a "difference from base" —
  the per-channel diff engine compares each channel against the whatsapp base, so
  IG/FB already show as differences, WhatsApp cannot.
- There is no channel-neutral display channel to switch to (options are whatsapp /
  instagram / facebook / web); switching to IG just reframes the base as IG-specific.
- Making the base truly neutral requires the seed to resolve identically for every
  channel with channel bits layered as separate per-channel appends the web diffs —
  a restructuring of how the seed/`altContactNote`/`interactiveInstructions` compose,
  with broad golden-pin impact and a risk to the empty-config byte-identity invariant
  ("an EMPTY config contributes no override").

Two candidate resolutions for the operator/Fable to pick:
  A. **Engine-correctness only (low risk):** make the `[[buttons]]` block and the
     number caveat channel-conditional in the seed so the *assembled* IG/FB prompt
     stops carrying WhatsApp button syntax (goldens for IG/FB comment/dms regen;
     WhatsApp stays byte-identical). The Situations DISPLAY base stays whatsapp.
  B. **Reframe the display (higher risk):** rewrite the seed to a neutral base with
     channel appends and change how the DMs base is displayed, so WhatsApp shows as
     a genuine per-channel difference. Broad golden regen + display-model change.
Recommend A unless the operator specifically wants the DISPLAY base neutral (B).
