# Channels & Connections scope — work-package 1: webhook truth + IG DMs enter

> **Kickoff plan. Written by Fable (the senior seat), 2026-07-16.**
> **Status: awaiting operator approval** (decision items below).
> Sources: target picture §6 + §9.6; scope board (IG DMs backlog item);
> escalation-verdict audit (IG DMs "ingested and dropped whole").
> Gate judges the package against THIS document. Intake was triaged: empty.

The package's spine: **two silences die** — the operator cannot see webhook
state without SSH, and Instagram DMs are received, stored, and dropped whole.
The first is a screen over data we already store; the second unlocks the
agent's IG DM leg (the ONE agent's `INSTAGRAM_PROFILE` has been waiting for
it). Deliberately OUT of this package: channel filters everywhere and the
Segments → Contacts fold (they collide with the App shell package's nav work
— they are this scope's package 2, after the shell lands).

## Read before touching anything

`CLAUDE.md` · `HANDOFF.md` · `plans/rethink/scope-board.md` ·
`plans/rethink-execution.md` (you are the executor seat) ·
`plans/rethink-target-picture.md` §6 · `docs/integrations.md` (Meta contract)
· `design/tokens.md` (law).

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

- **IG DMs today**: `classifyEvent` (`packages/shared/src/meta/classify.ts:
  31-53`) only maps `object==="instagram" && field==="comments"`; an IG
  `entry[].messaging[]` DM classifies `"unknown"`. The processor
  (`webhook-processor.service.ts:53-67`) routes ALL instagram objects to
  `igComments.processEnvelope` — a DM payload is stored as a `WebhookEvent`
  and never handled. `deriveDedupKey` has no DM arm.
- **The agent is DM-ready**: `INSTAGRAM_PROFILE` exists
  (`packages/shared/src/agent/channel-profile.ts:77-91`, "a future IG DM
  external turn would consume them"); `ChannelId` includes `instagram`.
- **No IG DM send API exists**: `instagram-client.ts` has
  `replyToComment/setCommentHidden/deleteComment/listMedia/getMedia` only —
  a `POST /{ig-user-id}/messages` send is net-new.
- **⚠ UNVERIFIED — verify before building the send arm**: whether the app's
  standard access covers `instagram_manage_messages` for the own account, and
  whether the webhook subscription includes the `messages` field for IG.
  (Operator ruling 2026-07-15: no Advanced Access/App Review needed for
  own-account management — believed to cover this, but PROVE it with a spike:
  subscribe + receive one real DM event + one test send to a test user.)
- **Webhook status**: `webhook_events` carries `source`, `eventType`,
  `signatureValid`, `processed/error`, `receivedAt`. Subscribed fields +
  callback URL live at Meta — readable via Graph (`/{app-id}/subscriptions`,
  page `subscribed_apps`); the verify token + app secret are in the
  `whatsapp`/`instagram` connections.
- Connections UI is schema-driven (`provider-schemas.ts`, 6 providers,
  list/add/edit/test) — the webhook panel extends `Settings.tsx`'s
  Connections tab; **no new nav entry** (parallel-execution constraint).
- Conversation/contact plumbing for a DM contact keyed by IG user id (no
  phone): **verify at execution** how `contacts` uniqueness is keyed
  (`waId`/phone vs channel identity) before writing rows — consult if the
  model needs more than an additive column.

## Standing rules

All of `CLAUDE.md` + rethink standing rules. **The `/webhooks/meta` route
string is frozen.** Additive migrations only. The new `instagram:dms` gate
ships **off** (not even shadow) until the operator flips it — a DM reply is a
private outbound message, the most personal surface yet; nothing sends in
this package regardless of gate state except the operator-approved spike
test send. LLM spend: shadow DM turns only after the operator's explicit
go-ahead (decision 3 below); at DeepSeek rates a DM turn is ~$0.0005.

### Parallel-execution constraints (this package runs beside appshell-scope-1)

- **This package OWNS**: `packages/shared/src/meta/classify.ts`,
  `apps/api/src/webhooks/*`, `apps/api/src/instagram/*` (DM service),
  `packages/shared/src/instagram/instagram-client.ts`, `Settings.tsx`
  (Connections tab only), the `instagram:dms` gate + channel-gates.
- **It must NOT edit**: `AppShell.tsx`, `App.tsx`, `Dashboard.tsx`/Home,
  `apps/api/src/alerts|email/*` — the App shell package owns those. A needed
  nav/route change is filed to that package via the scope board, not made here.
- Additive Prisma migrations: pull/rebase before creating one; serialized
  deploys.
- HANDOFF: append under this scope's own heading.

---

## Stage 0 — The spike (cheap, first, decides the send arm)

1. Verify the IG webhook subscription can deliver `messages` (DM) events for
   the own account (subscribe the field; send a DM to the account from a
   personal IG; observe the `WebhookEvent` row).
2. Verify a test DM send via Graph (`instagram_manage_messages`, human-agent
   window rules noted) to the operator-controlled sender. ONE message,
   operator-witnessed.
3. Record findings in this plan. **If either half fails at standard access,
   STOP — the package re-scopes to webhook-status-only and the DM half goes
   back to the board with the blocker named.**

## Stage 1 — IG DMs enter the pipeline (receive + shadow decide, no send)

1. `classifyEvent` learns `instagram_message` (+ `deriveDedupKey` arm:
   message id). TDD from a real captured payload (Stage 0 gives us one).
2. Processor routes `instagram_message` to a new `IgDmService`: contact
   (keyed by IG user id — see ground truth), conversation, inbound message
   row — the same shape WhatsApp inbound follows, channel `instagram`.
3. **Agent turn in shadow**: `run-turn` with `INSTAGRAM_PROFILE`, gate
   `instagram:dms` (off/shadow/live, ships **off**; shadow = decide + trace +
   review-label, send nothing — same semantics as comments). Turns carry
   `decisionSource`, traces land in Activity like any turn (1D's rails).
4. Inbox shows the conversation (it is channel-aware already — verify; if a
   filter chip is needed it is a one-line addition to the existing Inbox
   filter, not the "filters everywhere" sweep, which stays in package 2).

## Stage 2 — The send arm (built dark behind the off gate)

1. `instagram-client.ts` gains `sendMessage(igUserId, text)` with the same
   bounded-retry discipline as 1D ruled for creates: **429-only retry, never
   5xx/timeout** (a DM double-send is worse than a comment double-post),
   max 2, Retry-After honoured, 4xx never.
2. Outbound path mirrors comments: claim-first state transitions, failed
   send → review item, trace `decisionSource:"failsafe"` on system failure.
3. Live mode stays unreachable until the operator flips `instagram:dms` —
   restated: **nothing in this package sends except the Stage 0 spike.**

## Stage 3 — Webhook status surface (Settings → Connections)

1. Per channel connection card: subscribed fields (Graph read), callback URL
   + verify-token set?, signing-secret verification state, **last event
   received (age, from `webhook_events`)**, signature failures count,
   processing error count (24h). "Visible, not folklore."
2. A "re-check" button per channel (re-reads Graph + DB; no mutation).
3. Backend: one `GET /api/connections/:id/webhook-status` doing the Graph
   reads with the connection's own credentials, honest `unknown` where Meta
   doesn't expose a field.

**Verify (all stages)**: TDD per path; per-package suites; deploy loop; a
real DM flows to a shadow trace visible in Activity (operator-witnessed);
webhook panel shows true state for both channels on the published site.

---

## Gate bar

1. Suites green per package; typecheck/lint clean; builds by exit code;
   deploy health-checked; web published.
2. The spike's findings are recorded in this plan; if the DM half proceeded,
   a real inbound DM produces a contact + conversation + shadow trace with
   provenance, visible in Inbox + Activity — no more silent drop.
3. `instagram:dms` gate exists, ships **off**, operator-flippable; shadow
   mode provably sends nothing (test-pinned, like comments).
4. Send arm exists dark: retry policy test-pinned (429-only on send);
   failed-send review item path test-pinned.
5. Webhook status panel shows subscribed fields, last-event age, signature +
   processing failures for whatsapp AND instagram, with honest unknowns.
6. No AppShell/App.tsx/Home edits (parallel constraint held); committed+
   pushed; HANDOFF + board current; deferrals in backlogs (incl. IG
   `mentions` field — already on the Agent backlog, do not duplicate).

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

1. ~~Approve the Stage 0 spike~~ **LOCKED 2026-07-16 (grill): APPROVED** —
   subscribe `messages` + ONE operator-witnessed test DM. Script is prepared
   (`tools/spike/ig-dm-spike.mjs`, double-gated); execution waits for a
   coordinated moment with the operator.
2. **Approve shadow DM turns' LLM spend** once deployed (~$0.0005/DM). *(Open.)*
3. The `instagram:dms` gate ships **off** and only you flip it (restated so
   no session re-asks).

## Stage log

- **Stage 0 spike, SUBSCRIBE half RUN (2026-07-22, agent-scope-9 Seat D):**
  token was rescoped that morning to carry `pages_messaging`, clearing the
  earlier 403 (#200). Ran the committed `tools/spike/spike-env.mjs`
  (stash@{0}'s spike edit was verified pure line-ending noise, `git diff -w`
  empty + CRLF-normalized md5 identical, and dropped). Subscription target =
  FB Page `109939868479359` (`IG_PAGE_ID` overridden; NOT `settings.igBusinessId`).
  Before: app `1040420455120273` subscribed `[feed]`. `--subscribe --yes` POSTed
  `subscribed_fields=feed,messages` → HTTP 200 `{"success":true}`. Read-back:
  `[feed, messages]` — `messages` now PRESENT, `feed` preserved. **DM webhook
  delivery is now enabled.** Remaining before the shadow flip: the operator's ONE
  witnessed test DM (send half) — deliberately NOT run here; `instagram:dms`
  stays off. No test send performed, no inbound awaited.
- **Stage 0 spike, first half RUN (2026-07-17, operator present):** wrapper
  `tools/spike/spike-env.mjs` decrypts the instagram connection in-process on
  the VPS (secrets never printed). Findings: the stored token is a **Page
  token** for FB page `109939868479359` ("Silver Oak Properties - UAE") — the
  IG business id (`settings.igBusinessId`) is NOT the subscription target;
  `/{page}/subscribed_apps` works. App `1040420455120273` subscribed with
  **`feed` only**. Subscribe attempt → **403 (#200): `messages` needs the
  `pages_messaging` permission on the token** — a token-scope gap, NOT a
  standard-access denial (Stage 0.3 stop NOT triggered). Fix known: regenerate
  the Page token with `pages_messaging` + `instagram_manage_messages` (Graph
  API Explorer), paste into the Instagram connection. **Operator deferred the
  Meta login — spike halves paused there.** Script patched first: subscribe now
  posts `feed,messages` because POST `subscribed_apps` REPLACES the field list
  (posting only `messages` would have silently killed comment ingestion);
  Meta confirmed nothing changed on the failed call (`feed` intact).
- **Deployed live (2026-07-17, orchestrator session):** all package code is on
  the VPS + published web (webhook-status panel included; UI half unverified —
  needs an authenticated session). **The Stage 0 spike was NOT run** — it is
  operator-witnessed by design and the operator was away; deferred, script
  unchanged and ready. `instagram:dms` remains **off**; the flip to shadow
  stays conditional on the spike (spend pre-approved, DECISIONS 2026-07-16).
  Gate stays open on the spike + live-DM evidence.

- **Stages 2 + 3 DONE (2026-07-16, executor + Fable-verified, merged):** send
  arm dark (`sendMessage` via `POST /me/messages`, 429-only retry pinned,
  red-first TDD; live path replaces the Stage-1 interim; shadow provably never
  builds a client; gate default untouched = off); webhook-status endpoint +
  Settings→Connections panel (Graph mocked in all tests; credentials never in
  URLs; honest unknowns). Fable rulings: `/me/messages` blessed pending the
  spike's live confirmation; empty live reply fails safe (no invented copy);
  one-row failsafe trace rewrite blessed. **Backlog:** wire DM retry from the
  review item; optional `appId` on the instagram connection to surface the
  callback URL. Remaining in this package: the operator-witnessed spike, then
  the gate verdict.
- **Stage 1 + spike prep DONE (2026-07-16, executor + Fable-verified, merged
  to rethink):** `instagram_message` classify + dedup arm (fixture from Meta
  docs, to be confirmed against the first real event), `IgDmService`
  (contact/conversation/message — NO schema change needed: `Contact` was
  already `@@unique([channel, channelUserId])`), shadow turns behind
  `instagram:dms` (ships off), spike script double-gated. Fable rulings on
  the executor's calls, all BLESSED: receive is UNGATED (storage always
  happens — the silent drop dies; the gate governs only the agent);
  IG `mid` reuses the unique `wamid` column (commented, no migration);
  staff-directory lookups are WhatsApp-only now (an IGSID must never match a
  staff phone — test-pinned); one `dm`/`label` review item per shadow turn
  (revisit sampling if volume is noisy); gate=live logs a warning and runs
  shadow until Stage 2's send arm exists (interim honesty — Stage 2 MUST
  replace it). Web follow-up filed: Inbox `ConversationRow` needs a channel
  field + chip so IG rows don't render a raw IGSID — belongs to package 2
  (channel filters) or the appshell package's Inbox touchpoint, NOT this one.

## Stop-and-consult list

- Contact identity model needs more than an additive change → consult.
- Meta denies the `messages` subscription or send at standard access → stop,
  re-scope per Stage 0.3.
- The DM turn wants a case/prompt the Behaviour inventory doesn't cover →
  consult (the inventory's completeness is a 1D invariant; don't break it).
- Any send beyond the spike → stop.
