# Channels & Connections scope — work-package 2: Facebook comments moderation

> **Kickoff plan. Written by Fable (the senior seat), 2026-07-17.**
> **Status: authored; execution BLOCKED on the channels-scope-1 gate** (board
> WIP rule: one package in flight per scope — package 1's gate is still open on
> the operator-witnessed DM spike). The operator may explicitly override.
> Sources: `docs/DECISIONS.md` 2026-07-17 channels-scope-2 grill (LOCKED — this
> plan honors every line); scope board Intake; channels-scope-1 stage log
> (spike findings); Meta Page-webhooks reference (cited in Stage 1).
> Gate judges the package against THIS document.
>
> **Intake triage (board rules):** the "Facebook comments moderation" line
> (operator, 2026-07-17) is CONSUMED by this kickoff. Its second half — IG
> comments moderation "completed" — is NOT build work: completion = the
> operator's `instagram:comments:moderation` flip (Agent → Channels), and the
> trigger-bypass item already sits on the Agent backlog. The orchestrator
> updates the board line; this file is authored touching nothing else.

The package's spine: **the most negative surface the operator has is invisible
to the platform.** FB page comments ("facebook comments are the most
negative") already arrive as `feed` webhooks and are stored — then classify
`unknown` and drop. This package gives them the full IG treatment: moderation
pass (delete|escalate|leave) + reply pass, same policy literally, behind two
new gates that ship **shadow**. Deliberately OUT: backfill (webhook-only —
old negativity is already visible), visitor posts (parked `?`, explained
below), a FB posts screen / post-context sync (no `pages_read_user_content`
yet), and any live action (token rescope pending).

## 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` (2026-07-17 channels-scope-2 grill — the locked scope) ·
`plans/rethink/channels-scope-1-kickoff.md` (stage log: spike findings this
plan builds on) · `docs/integrations.md`.

## Ground truth (verified at kickoff, 2026-07-17)

- **FB feed events are already subscribed and arriving.** App
  `1040420455120273` (App B — the `instagram` connection's app) is subscribed
  to FB page `109939868479359` ("Silver Oak Properties - UAE") with **`feed`
  only** (`/{page}/subscribed_apps`, spike-verified 2026-07-17). The stored
  `instagram` connection token is a **Page token** for that page — the same
  token already serving IG comment ops.
- **They classify `unknown` and drop.** `classifyEvent`
  (`packages/shared/src/meta/classify.ts`) has no `page`/`feed` arm;
  `deriveDedupKey` neither. The processor
  (`webhook-processor.service.ts:59-77`) routes on `envelope.object` —
  `"page"` falls to the default branch: warn + ack, never processed. So the
  events are in `webhook_events` (payload intact) but nothing downstream.
  **This is positive evidence:** the stored `unknown` rows ARE the feed
  events — delivery and the app's page subscription are proven live already.
  Stage 0's remaining questions are only (a) which endpoint/signature they
  land on and (b) payload completeness (`from`/`message` redaction).
- **⚠ UNVERIFIED — where they land, and whether they verify.** The intake
  says feed events "hit `/webhooks/meta`", but App B's dedicated endpoint is
  `/webhooks/instagram` (its own verify token + app secret; `/webhooks/meta`
  verifies against App A's WhatsApp secret). If App B's page-object callback
  points at `/webhooks/meta`, every feed event stores with
  `signatureValid=false` and can NEVER be processed. Stage 0 resolves this
  from the live `webhook_events` table before any code is written.
- **Ad posts are in scope by design:** Meta sends no feed webhooks for ad
  posts themselves but DOES send them for **comments on ad posts** (Page
  webhooks reference) — so comments on boosted listings enter this pipeline.
  That matches the operator's motive ("facebook comments are the most
  negative" skews to boosted reach); deliberate, not accidental.
- **⚠ UNVERIFIED — payload completeness under today's token.** Meta may
  redact user-generated fields (`from`, `message`) on Page webhooks depending
  on app permissions. The grill locked "shadow must work with TODAY's token";
  if the stored events show redacted `message`, shadow is blocked on the
  operator's Meta rescope — **stop and flag, don't improvise** (Stage 0).
- **The pipeline to mirror is one file**: `IgCommentService`
  (`apps/api/src/instagram/ig-comment.service.ts`) — parse → dedup → triggers
  → moderation pass → reply pass → persist → trace → review items. Its parser
  is `packages/shared/src/instagram/instagram-comments.ts` (incl.
  `isMaterialCommentEdit`, built in 1D and dormant on IG — IG comments are not
  user-editable; FB comments ARE, so the edit gate goes live here).
- **The comment table is shared-ready**: `IgComment`
  (`packages/db/prisma/schema.prisma:461`) has no channel column but every
  consumer (home counts, alert failed-count, review flow + `replyAfterRestore`,
  admin groupBy, posts join) queries it channel-blind — see the data-model
  ruling below.
- **Gates**: `ChannelGates` (`packages/shared/src/agent/channel-gates.ts`)
  resolves per-surface `off|shadow|live`; `instagram:comments:moderation`
  already defaults `shadow` with no legacy value — the exact pattern the two
  new FB gates follow. `ChannelId` (`packages/shared/src/agent/types.ts:6`) is
  `"whatsapp" | "instagram" | "web"` — needs an additive `"facebook"`.
- **Gate UI** lives on Agent → Channels (`apps/web/src/routes/agent/
  Channels.tsx`, `InstagramChannelCard.tsx` as the shape to mirror).
- **Graph ops for FB comments** differ from IG in path/param only:
  reply `POST /{comment_id}/comments` (IG: `/replies`), hide
  `POST /{comment_id}` with `{ is_hidden }` (IG: `{ hide }`), delete
  `DELETE /{comment_id}` (same). Same `graph.facebook.com` base, same Page
  token, same 429-only-on-create retry discipline (`instagram-client.ts`).
- **Live actions need** `pages_manage_engagement` + `pages_read_user_content`
  — folded into the operator's pending Meta rescope (grill-locked). Shadow
  makes ZERO Graph calls (no post-context sync for FB, no client in shadow),
  so the build never blocks on the token.

## Standing rules

All of `CLAUDE.md` + rethink standing rules. Webhook route strings are
**frozen** (`/webhooks/meta`, `/webhooks/instagram`) — a wrong callback URL is
fixed in the Meta App Dashboard (operator-witnessed config), never by a new
route. Additive migrations only. Both new gates ship **shadow** (grill-locked
— unlike `instagram:dms` which ships off): decide + trace + review-label,
act on NOTHING; only the operator flips to live, and live stays unreachable
until the rescoped token lands anyway. Shadow LLM spend is **pre-approved**
(~$0.0005/comment, DeepSeek — DECISIONS 2026-07-17); volume/day is unmeasured
(parked `?`) so Stage 4 measures it and the gates are the kill-switch.

### Parallel-execution constraints

- **This package OWNS**: `packages/shared/src/meta/classify.ts`,
  `packages/shared/src/facebook/*` (new), `packages/shared/src/agent/
  channel-gates.ts` + `types.ts` (additive members), `apps/api/src/instagram/
  ig-comment.service.ts` (adapter refactor, IG tests pinned), `apps/api/src/
  facebook/*` (new), `apps/api/src/webhooks/webhook-processor.service.ts` +
  `webhook-status.service.ts`, the `IgComment` migration, Agent → Channels
  (FB card only).
- **It must NOT edit**: `AppShell.tsx`, `App.tsx`, Home, alerts/email,
  `Settings.tsx` (channels-1 owns the Connections tab and has uncommitted
  working-tree changes there), the IG DM path (`ig-dm.service.ts`,
  `instagram-client.ts` send arm). A needed change in those files is filed to
  the owning package via the scope board.
- Additive Prisma migrations: pull/rebase before creating one; serialized
  deploys. HANDOFF: append under this scope's own heading.

---

## Fable rulings (the grill's delegated items, decided here)

1. **Data model — additive `channel` column on `IgComment`, no rename.**
   `channel String @default("instagram")` + `@@index([channel])`; FB rows set
   `channel: "facebook"`, store the FB `post_id` in the existing `mediaId`
   column (comment the column: "IG media id | FB post id") and the FB
   `comment_id` in the unique `commentId` (FB ids are `{post}_{comment}`
   composites — no cross-channel collision with IG's numeric ids). *Why:*
   every consumer stays valid and goes cross-channel for free — home counts,
   the alert `status:"failed"` count, `ReviewItem.igCommentId` and the whole
   review flow (`replyAfterRestore` included) work on FB rows with zero new
   plumbing. A parallel `FbComment` table would fork ReviewItem with a second
   FK and duplicate the review/home/alert/admin joins; renaming `ig_comments`
   is a destructive migration on a live table for cosmetics. The Prisma model
   name stays `IgComment` (rename = churn in 17 files for nothing).
2. **Verb handling — an EXPLICIT FB→pipeline verb map, not IG's implicit
   default.** FB's feed verb enum is
   `add|block|edit|edited|delete|follow|hide|mute|unblock|unhide|update`,
   and the wild sends **`edited`** (not IG's `edit`). The map:
   - `add` → pipeline;
   - `edit`/`edited` **with non-empty text** → re-enter ONLY through
     `isMaterialCommentEdit` (cosmetic edits dropped; a material edit
     supersedes the prior decision in place — 1D's edit gate, dormant on IG
     since IG comments aren't user-editable, finally fires here);
   - `edited` **with EMPTY `message`** → treat as **remove** (practitioners
     document this shape as an actually-deleted comment; there is nothing to
     moderate and re-running the pipeline on "" would be noise);
   - `remove`/`delete`/`hide` → the remove path: named skip, existing row
     untouched (history preserved; a stale pending review item on a removed
     comment is a known edge → backlog, not built now);
   - `unhide` → named skip + log (echoes a manual action; re-deciding an
     unhidden comment is the review queue's restore flow, not the webhook's);
   - **any other/unknown verb** → counted (Stage 4 breakdown), never
     processed — no verb ever falls through into the pipeline by default.
3. **Dedup key — verb + event-time qualified, because FB comments mutate.**
   `deriveDedupKey` gains a `page`/`feed` arm: for `item:"comment"` →
   `fbcomment:<comment_id>:<verb>:<entry.time>`; other feed items →
   `fbfeed:<item>:<comment_id|post_id|entry.id>:<verb>:<entry.time>`; no id
   found → contributes nothing (falls through to the null-key store-always
   path). *Why the qualifiers:* IG's bare `comment:<id>` is verb-blind — safe
   there (IG comments can't be edited) but on FB it would eat every `edited`
   event at ingest as a false duplicate, and two successive edits share a verb
   so `entry.time` disambiguates (a genuine Meta re-delivery re-sends the
   identical payload, same time, same signature — still collapses). The IG arm
   is NOT touched. Downstream guards (unique `commentId` + materiality gate)
   stay authoritative, as ever.
4. **(Adjacent, needed to spec Stage 3) Credentials — no new connection.**
   The FB client resolves the **`instagram` connection's token**: it IS the
   Page token for the target page (spike-verified 2026-07-17), one token in
   one home, rotation stays a single Settings edit. A dedicated `facebook`
   provider is backlogged for the day the tokens diverge. If at live-flip time
   the rescoped token fails FB ops → consult, don't improvise.

## Stage 0 — Ground truth from the live DB (no code)

1. On the VPS, query `webhook_events` for stored `object:"page"` payloads:
   which `source` (endpoint) they arrive at, `signatureValid`, and one full
   `item:"comment"` payload captured as the parser fixture (scrub nothing —
   it's already in our DB; the fixture file redacts names).
2. **Decision rule:** arriving at `/webhooks/instagram` with valid signatures
   → proceed. Arriving anywhere with `signatureValid=false` → the callback URL
   for App B's `page` object must be pointed at `/webhooks/instagram` in the
   Meta App Dashboard (operator-witnessed config change; route strings frozen)
   — record it in the stage log, then re-verify with a fresh test comment.
3. **Payload completeness check:** if the captured comment payload lacks
   `message`/`from` (permission-redacted), shadow cannot work with today's
   token — **STOP, flag to the operator** (the grill's "shadow works today"
   premise fails; the package waits on the rescope). Do not build around it.
4. If NO page events are stored yet: the operator posts one test comment on
   any page post; the event lands within seconds (subscription is live).

**Oracle:** a real `feed` comment payload saved as a fixture + the stage log
naming endpoint, signature state, and field completeness.

## Stage 1 — Classify + dedup + parser (packages/shared, TDD)

1. `classifyEvent` learns `object:"page"` + `field:"feed"`:
   `item:"comment"` → `"facebook_comment"`; any other feed item →
   `"facebook_feed"` (the named-classification bucket; per-item detail stays
   in the payload for Stage 4's counts). New `MetaEventType` members only —
   no existing arm changes.
2. `deriveDedupKey` gains the `page`/`feed` arm per ruling 3. TDD both from
   the Stage 0 fixture AND doc-shaped cases (add/edited/remove; batched
   changes; missing ids).
3. New `packages/shared/src/facebook/facebook-comments.ts` mirroring
   `parseInstagramCommentEvents`: Zod-loose value schema over the documented
   fields — `item`, `verb`, `comment_id`, `post_id`, `parent_id`, `from{id,
   name}`, `message`, `created_time`, `post{permalink_url,status_type}` (Meta
   Page-webhooks reference: <https://developers.facebook.com/docs/graph-api/
   webhooks/reference/page/#feed>; documented verbs include add, edit, edited,
   remove, hide, unhide). Normalizations the parser owns:
   - `item !== "comment"` → not an event (counted by Stage 4, never parsed);
   - **loop guard**: `from.id === entry.id` (entry.id IS the page id on
     `object:"page"`) → page-authored, dropped — the self-echo guard needs no
     config, but also accept an optional `ownPageId` like the IG parser's
     `ownIgId`, defensively;
   - **missing `from` — a NAMED unprocessed class, never a silent drop.**
     Documented Meta issue: comment webhooks sometimes arrive with only
     `from.name` or no `from` at all (unresolved by Meta). The IG parser
     silently drops no-commenter events; mirrored blindly, real FB comments
     would vanish invisibly. Policy: a no-`from` comment event is stored (it
     already is, in `webhook_events`) and **counted as its own named class**
     on the Stage 4 panel (`comment (no author)`), but NOT processed — the
     loop guard fails CLOSED (no author → cannot prove it isn't us → cannot
     enter the pipeline → cannot loop). Enrichment via a Graph read of the
     comment is a **live-mode option only** (needs
     `pages_read_user_content`) → backlog, not built now;
   - `parent_id` **equal to `post_id` means top-level** on FB (unlike IG) →
     normalize to `parentId: null`; confirm against the real fixture;
   - verb handling per ruling 2's explicit map (parser emits add/edit events
     only; `edited`+empty-text normalizes to the remove path; unknown verbs
     go to the counted class).
4. Output shape mirrors `IgCommentEvent`: `channel:"facebook"`,
   `commentId`, `postId` (carried in the event as its own field; the service
   maps it into the `mediaId` column), `parentId`, `commenterId`,
   `commenterName`, `text`, `verb`.

**Oracle:** `cd packages/shared && npx vitest run --pool=forks
--poolOptions.forks.maxForks=2` — new tests red first, then green; whole
package green.

## Stage 2 — The pipeline (migration + adapter refactor + shadow turns)

1. **Migration (additive):** `channel` column per ruling 1. Apply with
   `source .env && npx prisma migrate deploy` then `prisma generate`; existing
   rows default `"instagram"`.
2. **Adapter refactor of `IgCommentService` — the grill's "same code path
   either way", taken literally.** Extract the channel-specific seams into an
   injected adapter (channel id, Graph ops client, post-context lookup,
   own-account id); the pipeline logic — dedup guards, triggers
   (`where channel: <adapter.channel>`), moderation pass, reply pass,
   confidence/held, traces, review items — runs once for both channels.
   **The existing IG test suite is the pin: it must pass UNMODIFIED** (DI
   wiring in test setup may change; assertions may not). If the refactor
   can't hold that bar → consult; fallback is a mirrored `FbCommentService`
   (accepted duplication over a destabilized live path).
3. **Edit re-entry gets a staleness/ordering guard** (Meta retries up to 36h
   and guarantees neither order nor dedup — a late duplicate `edited` must
   not re-moderate a resolved comment, and `edited`-before-`add` must not
   crash or double-create):
   - `edited` with **no existing row** → treat as an add (process once; the
     out-of-order `add` that follows dedups against the existing row);
   - `edited` on a row whose status is **acted/terminal** (`posted`,
     `hidden`, `deleted`) → named skip: the resolution (ours or the
     operator's) stands; a late edit never re-runs moderation over it;
   - `edited` whose event time (`entry.time`) is **not newer than the row's
     `updatedAt`** → named skip (stale re-delivery);
   - all three test-pinned red-first.
4. **FB adapter:** post-context returns null always (no FB post sync —
   webhook-only ruling; policy input degrades honestly to text-only, which
   `CommentPolicyService` already handles); Graph client per Stage 3 (shadow
   never constructs one); own id = entry-derived (loop guard already in the
   parser). Traces write `channel:"facebook"`, `caseKey:"comment"`,
   `correlationId: commentId` — FB comments become Activity rows like IG's.
5. **Gates:** `facebook:comments` + `facebook:comments:moderation` join
   `ChannelGates`, both defaulting **`shadow`** with no legacy mapping (the
   `instagram:comments:moderation` pattern; malformed stored value falls back
   to shadow — grill says ship shadow, so shadow IS the fail-closed state
   here). `ChannelId` gains `"facebook"`; `gateAllows` learns it.
6. **Processor routing:** `case "page":` → the comment pipeline with the FB
   adapter (`facebook_comment` only; `facebook_feed` envelopes are stored +
   acked, counted by Stage 4, not processed).
7. Review items ride the existing `surface:"comment"` flow untouched
   (`igCommentId` points at the shared table); verify the review queue and
   Activity render a `facebook` channel row sanely (label/chip — a small
   additive fix if a channel name is hardcoded, in files this package owns).

**Oracle:** `cd apps/api && npx vitest run --pool=forks
--poolOptions.forks.maxForks=2` green INCLUDING the unmodified IG suite;
shadow-posts-nothing pinned by a test that fails if any Graph client is
constructed in shadow. Deploy loop: shared build → api build → `pm2 restart
channels-api channels-worker --update-env` → `curl -s
http://localhost:3100/health` → `{"status":"ok","db":true}`.

## Stage 3 — Graph ops dark + gate UI

1. `FacebookClient` (or a parameterized comment-ops client) in
   `packages/shared/src/facebook/`: `replyToComment` →
   `POST /{comment_id}/comments`, `setCommentHidden` → `POST /{comment_id}`
   `{ is_hidden }`, `deleteComment` → `DELETE /{comment_id}` (404 =
   success-equivalent). Retry discipline copied from `instagram-client.ts`
   verbatim: create retries **429 only**, hide/delete retry 429+5xx+timeout,
   max 2, Retry-After honoured — test-pinned red-first. Token resolution per
   ruling 4. Built DARK: only the live gate path constructs it, and live is
   unreachable until the operator flips + the rescoped token exists.
2. **Hide is verified by read-back, not trusted.** Documented Meta bug:
   `is_hidden=true` can return success while the comment stays public. So FB
   `setCommentHidden` success = POST ok **AND** a follow-up
   `GET /{comment_id}?fields=is_hidden` confirming the state; an unconfirmed
   hide is recorded as a FAILURE (the existing failed-hide review-item path —
   the comment is still public and the operator must know). Test-pinned.
   Recorded semantics, so no session mistakes them: **FB "hidden" ≠ gone** —
   the author and their friends still see a hidden comment; and hiding a
   top-level comment auto-hides its reply thread, so a later reply arriving
   under a parent we hid/deleted is a **named skip, not fresh pipeline input**
   (the service checks the parent row's status before processing a reply).
3. Agent → Channels gains a Facebook card mirroring `InstagramChannelCard`:
   both gates visible and flippable (shadow default shown honestly), no other
   screen touched. `pnpm --filter @channels/web build`; operator publishes.

**Oracle:** shared + api + web package suites green (same vitest invocation
per package); web build by exit code.

## Stage 4 — Feed visibility (counts) + volume measurement

1. `webhook-status.service.ts`: for the instagram/page source, add a per-item
   breakdown of `facebook_feed` events (SQL JSON-extract over stored payloads,
   e.g. last 7d: `post: 12, reaction: 40, share: 3, other: n`) surfaced on the
   existing webhook panel — "named classification + webhook-panel counts, not
   processed" (grill). The breakdown MUST include the Stage 1 named
   unprocessed classes: **`comment (no author)`** (the missing-`from` policy —
   these are real comments the pipeline could not safely touch, and they must
   be visible, not folklore) and **`comment (unknown verb)`**. Honest `other`
   for unrecognized items; no new nav.
2. **Measure the parked `?`:** record observed FB comment volume/day (from
   `webhook_events` + `ig_comments where channel='facebook'`) in the stage log
   so the operator's spend picture is real, and note it against the board's
   parked volume question.

**Oracle:** api suite green; panel shows real counts on the published site.

## Visitor posts — explained for the operator (parked `?`, no build)

A **visitor post** is a post a Facebook user writes ON the page itself (the
"Posts by others" / page timeline surface) — distinct from a *comment* under
the page's own post. In `feed` webhooks it arrives as `item:"post"` (or
`status`) with `from.id ≠ page id`. This package classifies it into the
`facebook_feed` count bucket and does NOT process it. If the operator wants
them moderated later, they are pipeline-shaped (text + author + moderation
verdict) and would be a small follow-on package — **decision stays with the
operator; nothing here builds toward it.**

---

## Gate bar

1. Suites green per package (shared, api, web) via the capped vitest
   invocation; `pnpm typecheck` / `pnpm lint` clean; builds by exit code;
   backend deploy health-checked; web built + published.
2. Stage 0's findings recorded (endpoint, signature validity, payload
   completeness); a **real FB comment** flows end-to-end: `webhook_events` row
   (valid signature) → `ig_comments` row `channel:"facebook"` → shadow
   moderation + reply decisions on ONE trace visible in Activity with
   provenance — and provably NOTHING posted/hidden/deleted (shadow pinned by
   tests + witnessed live).
3. Both gates exist, default **shadow**, operator-flippable from the Channels
   card; shadow constructs no Graph client (test-pinned).
4. **IG unbroken:** the pre-existing IG comment test suite passes unmodified;
   a live IG comment still traces normally post-deploy.
5. Page-authored comments skipped (test-pinned); the ruling-2 verb map
   test-pinned including `edited`+empty-text → remove and unknown-verb →
   counted; `edited` re-enters only via the materiality gate AND the Stage 2
   staleness/ordering guards (no-row, terminal-status, stale-time — all
   pinned); no-`from` comments fail closed and appear as the named
   `comment (no author)` class on the panel — never silently dropped;
   non-comment feed items show named counts on the webhook panel.
6. Dedup arm per ruling 3 test-pinned (re-delivery collapses; add vs edited
   do NOT collapse); IG dedup arm byte-identical.
7. Hide is read-back verified (test-pinned: POST-success + read-back-public =
   failure + review item). **Before any live flip of
   `facebook:comments:moderation`** (recorded here so the flip session sees
   it): an **empirical hide test on one real comment is REQUIRED** — which
   permission gates hiding a USER's comment is publicly undocumented (the
   reference is silent), so the rescope list alone is not proof the rescoped
   token can hide. Operator-witnessed, one comment, read-back confirmed.
8. Migration additive + applied cleanly; no secret committed; committed +
   pushed; HANDOFF + board updated; deferrals written to backlogs: visitor
   posts (awaiting operator), FB post-context after rescope, Graph-read
   enrichment of no-`from` comments (live-mode, needs
   `pages_read_user_content`), stale review items on removed comments,
   dedicated `facebook` provider split, FB comment volume/day recorded
   against the board's parked `?`.

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

1. **Visitor posts** — explained above; parked `?` until you rule. Nothing in
   this package processes them.
2. **Meta rescope** — `pages_manage_engagement` + `pages_read_user_content`
   fold into your pending token regeneration (LOCKED in the grill; restated so
   no session re-asks). Shadow does not wait for it; live cannot precede it.
3. **Gate flips** — both FB gates ship shadow; only you flip to live
   (restated so no session re-asks).
4. **Execution start** — this package starts only after the channels-scope-1
   gate closes (WIP rule), unless you explicitly override.

## Stage log

### Stage 0 — 2026-07-17 (executor) · BLOCKED on operator (Meta config + test comment)

**Finding (contradicts this plan's ground truth): NO FB feed event has EVER
arrived. The "stored `unknown` rows ARE the feed events" premise is false.**
Evidence, all read-only:

1. Live `webhook_events` has **zero** `object:"page"` rows (full-table group
   by `payload->>'object'`: `whatsapp_business_account` 1110, `instagram` 15 —
   nothing else). The 3 `unknown` rows (ids 1127–1129, 2026-07-11) are
   empty-`entry` `object:"instagram"` verification pings, not feed events.
2. Root cause, from a read-only Graph check run on the VPS (in-process
   decryption per the spike-env pattern, no secrets printed):
   `GET /1040420455120273/subscriptions` (app token) returns **only the
   `instagram` object** — callback
   `https://channels.silveroakglobal.com/webhooks/instagram`, fields
   `[comments]`, active. **There is NO `page` object subscription on App B.**
   The page-level `GET /109939868479359/subscribed_apps` still shows `feed`
   (re-verified today), but a page-level subscription delivers nothing unless
   the app also registers a `page`-object callback — so the events were never
   sent anywhere. Delivery was never "proven live"; only the page-side
   subscription was.
3. Stage 0's remaining questions are therefore **unanswerable today**:
   endpoint = nowhere, signature state = n/a, payload completeness
   (`from`/`message`) = unknown until a real event arrives. No fixture yet.

**Remedy (this plan's own decision rule, item 2 — operator-witnessed):**
- Operator adds the **`page` object** subscription to App B (id
  `1040420455120273`) with field **`feed`** and callback
  `https://channels.silveroakglobal.com/webhooks/instagram` (route strings
  frozen; same verify token — the endpoint already answers App B's verify
  handshake). Meta App Dashboard → App B → Webhooks → Page. NOTE for the
  flip session: POST `/{app}/subscriptions` could do this via API, but the
  kickoff reserves Meta config changes as operator-witnessed — not taken.
- Then operator posts **one test comment** on any page post (Stage 0 item 4).
- Then re-run the Stage 0 queries: expect a `webhook_events` row at source
  `instagram` with `signatureValid=true`; capture the payload as the parser
  fixture; check `from`/`message` presence (redacted → the stop rule fires;
  package waits on the rescope).

**Status: STOP per the stop-and-consult discipline** — Stage 1's TDD needs the
real fixture (and the `parent_id == post_id` normalization must be confirmed
against it), and the payload-completeness stop rule sits unresolved ahead of
any build. Reported to the orchestrator 2026-07-17.

**Update (same day) — operator authorized the API subscribe; DONE, verified.**
Ruling relayed by the orchestrator: try `POST /{app}/subscriptions` from the
VPS instead of the dashboard click. Executed (app token + existing verify
token, decrypted in-process, never printed):
- `POST /1040420455120273/subscriptions` `object=page, fields=feed,
  callback_url=https://channels.silveroakglobal.com/webhooks/instagram` →
  **HTTP 200 `{"success": true}`** — Meta's GET verify handshake at the
  frozen callback was answered during the POST (success is proof).
- Read-back `GET /1040420455120273/subscriptions` → `page` object **active**,
  fields `[feed]`, callback `/webhooks/instagram`, alongside the untouched
  `instagram`/`comments` subscription. Page-level `subscribed_apps` not
  touched (already `feed`).
- Revert path if ever needed: `DELETE /{app}/subscriptions?object=page`.

**Still open before Stage 1:** the operator posts ONE test comment on a page
post → re-run the Stage 0 queries → capture the fixture, confirm
`signatureValid=true` at source `instagram`, check `from`/`message`
completeness (redacted → stop rule fires).

### Stage 0 — 2026-07-17 (executor, second seat) · GROUND TRUTH CAPTURED · the big stop rule does NOT fire · one gap: no live comment yet

Delivery is now proven — **74 `object:"page"` rows** exist. All queries below
read-only, live DB (`psql "$DATABASE_URL"` as `sopserver1`).

**Endpoint + signature (decision rule → PROCEED):** all 74 rows land at
**`source = instagram`**, **`signature_valid = true`**, `event_type = unknown`
(the processor's `case "page"` doesn't exist yet — expected). The App-B page
subscription created earlier (`2b5dbd0`) is delivering to `/webhooks/instagram`
with valid signatures. No Meta config change needed.

**Payload completeness — THE STOP RULE DOES NOT FIRE.** User-generated fields
are **present and unredacted** under today's token: the external reaction from
`Dayaker Reddy` (id `27609570315360176`) and `Mo Nad` (id `27672526999035682`)
carry real `from.id`/`from.name`; the photo carries the full `message` caption.
So the grill's "shadow works today" premise **holds** — the package is NOT
blocked on the Meta rescope for shadow. (Live actions still need the rescope,
unchanged.)

**Loop guard confirmed on REAL data:** page-authored items (`post/remove`,
`photo/add`) have `from.id === entry.id === 109939868479359`; the external
reactions have `from.id !== entry.id`. The `from.id === entry.id` fail-closed
guard is validated against production payloads.

**`parent_id === post_id` → top-level confirmed on REAL data:** both reactions
show `parent_id` byte-equal to `post_id` for a top-level item — the exact shape
the parser normalizes to `parentId: null`. (Confirmed on reactions, which share
the `feed` value schema with comments.)

**Item-type histogram of the 74 rows (classified honestly):**

| item | verb | count | note |
|---|---|---|---|
| `post` | `remove` | 71 | page deleting its OWN posts (`from.id === page id`) — self-echo noise, loop-guard-dropped, `facebook_feed` bucket |
| `reaction` | `add` | 2 | external users liking posts — `facebook_feed` bucket |
| `photo` | `add` | 1 | page publishing its own post — page-authored, `facebook_feed` bucket |

**Flattened-changes and substring sweeps both return `comment` = 0.**

**GAP (the one open item): NO `item:"comment"` event has arrived yet**, so the
Stage 0 oracle's "real comment payload saved as a fixture" is not literally
satisfiable today. Judgement call (reported to the orchestrator, non-blocking):
**PROCEED with Stages 1–2**, because (a) the parser is fully specifiable from
the Meta Page-webhooks reference, and (b) the three things a real comment
fixture would prove — envelope shape, field non-redaction, and the
`parent_id === post_id` normalization — are all **confirmed against the real
sibling `feed` payloads** captured above (comments share the `feed` value
schema). The real payloads are persisted at
`packages/shared/src/facebook/fixtures/live-feed-events.json` for provenance;
the parser's comment cases are doc-shaped and corroborated by them.
**Gate bar 2's end-to-end "a real FB comment flows" proof still needs the
operator to post ONE test comment** — that is a gate/deploy-time item, not a
Stage 1–2 code blocker.

### Stage 1 — 2026-07-17 (executor, second seat) · DONE + pushed `f234b81`

`packages/shared` (TDD): `classifyEvent` learns `object:"page"`+`field:"feed"` →
`facebook_comment` (item comment) / `facebook_feed` (else, the counted bucket);
`deriveDedupKey` gains the page/feed arm (`fbcomment:<id>:<verb>:<entry.time>`,
`fbfeed:<item>:<id>:<verb>:<time>` — ruling 3; IG arm byte-identical); new
`facebook/facebook-comments.ts parseFacebookCommentEvents` (Zod-loose, loop
guard fail-closed, no-`from` fail-closed, `parent_id===post_id`→top-level,
explicit verb map, carries `entry.time` as `eventTime`); `ChannelId += "facebook"`
(channel-profile Record completed — a forced additive touch of a concurrent
seat's file, flagged to the orchestrator).
**Oracle:** `cd packages/shared && npx vitest run --pool=forks
--poolOptions.forks.maxForks=2` → **739 pass** (FB 26 new + IG 15 unmodified);
typecheck + build exit 0.

### Stage 2 — 2026-07-17 (executor, second seat) · adapter refactor holds the IG pin byte-identical

**The grill's "same code path either way" taken literally.** Extracted a
channel-agnostic `apps/api/src/instagram/comment-pipeline.ts` (`CommentPipeline`
+ `CommentChannelAdapter`) holding the ENTIRE former `IgCommentService` logic.
`IgCommentService` is now a thin forwarder building an `IgCommentAdapter`; new
`FbCommentService` (`apps/api/src/facebook/`) builds an `FbCommentAdapter`
(getClient→null [Stage 3 dark; shadow never constructs one], contextForMedia→null
[no FB post sync], ownId→null [parser self-checks via entry.id]). Both channels
run the pipeline once.

- **Migration (additive):** `20260717120000_ig_comment_channel` —
  `channel TEXT NOT NULL DEFAULT 'instagram'` + `ig_comments_channel_idx`
  (ruling 1). FB rows store `post_id` in `media_id`, `from.name` in `username`.
- **Edit staleness/ordering guards** (item 3, test-pinned): `edit` with no row →
  add; `edit` on a terminal row (posted/hidden/deleted) → named skip; `edit`
  whose `entry.time` ≤ row `updatedAt` → named skip. Written channel-agnostically
  and **no-op for IG** (IG events carry no `eventTime`, IG rows in the pin carry
  no terminal status) — the IG suite proves it.
- **Gates:** `facebook:comments` + `facebook:comments:moderation` join
  `ChannelGates`, both default **shadow** (malformed → shadow, the fail-closed
  state here); `ChannelId`/`gateAllows` learn facebook.
- **Processor:** `case "page"` → `facebook_comment` runs the FB pipeline;
  `facebook_feed` acked + logged, not processed.

**Oracles (all green):**
- IG PIN — `apps/api ... ig-comment.service.test.ts` → **54 pass, test file
  UNMODIFIED** (byte-identical; the refactor holds the bar with room to spare).
- FB service — `fb-comment.service.test.ts` **10 pass** (shadow persists
  channel:facebook + posts nothing + no client constructed; gate off; loop guard;
  all three staleness guards).
- Processor — `webhook-processor.service.test.ts` **25 pass** (page comment →
  FB pipeline; page non-comment reaction → acked, not processed).
- Full api suite **1155 pass / 116 files**; full shared **739 pass**;
  api+shared typecheck + build exit 0; `pnpm lint` 0 errors.

**Deferred (noted here, not built — out of the 0–2 scope or a live-mode concern):**
- Cross-channel RESTORE routing: `review-items.service` calls
  `IgCommentService.replyAfterRestore` for every row; an FB row would trace
  channel:instagram. Unreachable in shadow (FB review items only exist after a
  LIVE hide), so harmless now — wire FB restore when the FB gate goes live.
- Web channel chip/label for a `facebook` Activity/ReviewQueue row (item 7):
  there is no live FB row to render yet (zero comments), and the Agent→Channels
  FB card is Stage 3 (owns the web surface) — verified at Stage 3/gate.

### Stage 3 — 2026-07-17 (executor, Opus seat) · Graph ops DARK + read-back hide + FB card · committed `d7a8b0d`* + pushed

**Built (all TDD red-first):**
- **`FacebookClient`** (`packages/shared/src/facebook/facebook-client.ts`, 14
  tests): reply `POST /{id}/comments`, hide `POST /{id} {is_hidden}`, delete
  `DELETE /{id}` (404=success). Retry copied verbatim from `InstagramClient` —
  create 429-only (no double-post), hide/delete 429+5xx+timeout, Retry-After
  honoured. **Hide is READ-BACK verified**: POST-success + `GET
  ?fields=is_hidden` confirming the requested state; an unconfirmed hide (state
  mismatch OR read-back failure) returns `ok:false` → the existing failed-hide
  review-item path fires (documented Meta silent-hide bug). Built DARK.
- **`FbConfigService`** (3 tests): resolves the **`instagram` connection's** Page
  token (ruling 4) → `FacebookClient`; null when absent/keyless. Wired into
  `FbCommentAdapter.getClient`; **shadow never calls it** (new test pin:
  `getClient` not invoked in shadow).
- **Parent-status named skip** (pipeline): a reply under a HIDDEN/DELETED parent
  is a named skip (parent thread auto-hidden). Guarded by `parentId`, so the
  entire top-level IG suite is a no-op — **IG pin passes byte-identical (54)**.
- **Cross-channel review routing** (the Stage-2 deferral, fixed): `review-items`
  acts through the FB client + `FbCommentService.replyAfterRestore` for a
  `facebook` row, IG's for an `instagram` row. Unreachable in shadow; correct for
  the FB live flip.
- **FB card** (`FacebookChannelCard.tsx`, 6 tests + ChannelsTab): mirrors the IG
  card — both gates as SegmentedControls, honest hints (read-back verify,
  rescoped-token, hidden≠gone, empirical-hide-test), live-confirm dialogs; ships
  shadow, flips nothing. `facebook` chip in `channelLabel`. `GET/PUT
  /api/facebook/comments/settings` (`FbCommentAdminController/Service`, 4 tests).

**Oracles green:** shared FB client 14; api FB 21 + review 16 (+4 FB) + IG pin 54
(unmodified) + processor 25; web FB card 6 + ChannelsTab (incl. FB) + TracePanel
27. `typecheck`/lint clean; api+web `build` exit 0.

***Git attribution tangle (flagged to orchestrator, ACK'd):** the shared working
tree is committed by 3 seats concurrently; a concurrent seat's bare `git commit`
swept my staged Stage-3 index into commit `d7a8b0d` ("feat(agent): migrate ~15
prompt sections into PROMPT_SEED"). Content verified intact in that commit
(read-back hide, parent-skip, cross-channel routing, FB card all present); no
history surgery (unsafe with seats live). Remaining commits use explicit
pathspecs (`git commit -m … -- <files>`) so staging can't ride along.

### Stage 4 — 2026-07-17 (executor, Opus seat) · feed-panel counts + volume · committed `e66a4ee` + pushed

- **Per-item feed breakdown** on the webhook panel: shared
  `facebook-feed-breakdown.ts` (9 tests) classifies every `page`/`feed` change —
  reactions/posts/photos by `item`, and the two NAMED unprocessed comment
  classes **`comment (no author)`** + **`comment (unknown verb)`**, honest
  `other`. `WebhookStatusService.feedBreakdown` computes it for the `instagram`
  source (null for whatsapp); `WebhookStatusPanel` renders it. The lookback
  **window is operator-tunable DATA seeded from a default (7d)** per the new
  config-or-seed law — `normalizeWebhookFeedWindowDays` clamps [1,90], read from
  settings, never a buried constant. api webhook-status 14 (+3); web panel 7 (+1).
- **Volume measured (the parked `?`, live read-only query 2026-07-17):**
  **FB comment volume/day = 0 observed.** `ig_comments where channel='facebook'`
  = **0**; comment-item feed events = **0**. The 131 page/feed events (all
  2026-07-17) are non-comment noise: `post/remove` 128 (page deleting its OWN
  posts — loop-guard-dropped), `reaction/add` 2, `photo/add` 1. So **shadow LLM
  spend so far = $0/day**; the real comment volume stays unmeasurable until the
  operator posts a test comment (gate-bar item 2). Recorded against the board's
  parked volume question: comments = 0/day today; total FB feed ~131/day but
  ~98% page self-echo, not moderatable.

### DEPLOY / PUBLISH — NOT DONE (deferred to a coordinated point) · needs runtime DI verification

Both stages are code-complete, package-suite green, `build` clean, committed +
pushed. **Not yet deployed/health-checked or web-published.** Rationale + what
the deploy must verify:
- My backend changes add **cross-module DI** (`ReviewModule`→`FacebookModule`;
  `WebhooksModule`→`SettingsModule`; `FacebookModule` gains `FbConfigService` +
  `FbCommentAdminController`). `nest build` passes (compile-level) but **NestJS
  DI resolves at RUNTIME** — per CLAUDE.md this needs rebuild+restart+health, not
  yet done.
- A deploy/publish of `origin/rethink` HEAD would carry **three seats'**
  concurrently-committed work to LIVE prod — a coordination decision above one
  executor seat. Deferred to the gate/coordinated deploy (which also awaits the
  operator's test comment for the end-to-end proof anyway).
- **Deploy loop to run at that point:** `pnpm --filter @channels/shared build` →
  `pnpm --filter @channels/api build` → `pm2 restart channels-api
  channels-worker --update-env` → `curl -s localhost:3100/health` → `{ok,db:true}`
  → web `scripts/publish-web.sh`. Web `dist` is already built locally.

## Stop-and-consult list

- Stage 0 shows redacted `message`/`from` under today's token → STOP, flag
  (the "shadow works today" premise fails; package waits on the rescope).
- Page events verify at NEITHER endpoint after the dashboard fix → stop.
- The adapter refactor cannot keep the IG suite green unmodified → consult
  (fallback: mirrored service, accepted duplication).
- The `channel` column turns out to need more than an additive change
  anywhere (a consumer hard-codes IG semantics in a way a default can't
  cover) → consult.
- At live-flip time the instagram connection's rescoped token fails FB ops →
  consult (ruling 4's fallback is the dedicated provider).
- Any Graph write outside the live gate path → stop.
