# Channels scope — work-package 3: channel filters everywhere + Segments → Contacts fold

> Authored by the orchestrator (Fable), 2026-07-20. **Authority:** board
> backlog line (green-lit 2026-07-18, queued behind the new nav — the nav is
> LIVE) + operator WIP override 2026-07-20 (DECISIONS; scope-1/2 gates stay
> open on operator/Meta, zero collision with this web-side package) + the
> one-concept-one-surface law (Segments is a lens on Contacts). No mock
> round was run for the fold — precedent pattern is the Models→Settings tab
> fold; **screenshots go to the operator BEFORE publish** (publish is the
> orchestrator's lane anyway).

## Scope

1. **Segments → Contacts fold.** `/segments` content moves into `/contacts`
   (tab or filter view — follow the Models→Settings pattern:
   `?tab=segments` + redirect from `/segments`). `SegmentBuilder` survives
   as the segment-define UI inside Contacts. Role gating preserved
   (marketing for segment edit; Contacts stays as-is for others — the fold
   must not widen access). The Segments nav entry dies; grouped sidebar
   Outreach keeps Contacts.
2. **Channel filters everywhere.** A consistent, URL-backed channel filter
   (WhatsApp / Instagram / Facebook) on every channel-mixed list surface.
   Stage 0 inventories which screens qualify (candidates: Inbox, Contacts,
   Agent Activity if its existing filter isn't already the house control,
   Posts). Analytics is OUT — its own package owns its controls.
3. **Contact channel identity.** Contacts rows/detail show which channels a
   contact exists on (wa/ig/fb marks — house style, no pills, icons not
   emojis) where the data model already knows; if it doesn't, record the
   gap on the board instead of inventing identity resolution.

## Standing rules

TDD red-first; per-package capped-fork suites (`npx vitest run --pool=forks
--poolOptions.forks.maxForks=2`); `corepack pnpm`; Zod at boundaries;
additive migrations only (expect none — this is web-first); no gate flips;
no LLM spend; pathspec commits, `git pull --rebase` before push (several
seats share the branch), push per verified stage; deploy/publish is the
orchestrator's lane.

## Stages

### Stage 0 — inventory
(a) Channel-mixed list surfaces + what filter each already has; (b) every
consumer of Segments (campaign audience picker, exports, API) — the fold
must leave each working, test-pinned. Recorded in the stage log.

### Stage 1 — the fold
Contacts absorbs Segments per Scope 1; `/segments` → `/contacts?tab=segments`
redirect; route-pin tests updated deliberately; campaign audience flow
pinned unbroken.

### Stage 2 — channel filters
The shared URL-backed channel filter applied to the Stage-0 inventory;
honest counts (a filter that would always show one channel on a
single-channel screen is NOT added — inventory decides).

### Stage 3 — evidence
Screenshots 1440 + 375 (Contacts with segments tab; one filtered list),
saved under `plans/rethink/evidence/channels-scope-3-*`.

## Gate bar

1. Suites green (capped forks), typecheck/lint/build exit 0.
2. `/segments` redirect live in code; nav entry gone; role gating unchanged
   (test-pinned both ways: marketing can edit segments, below-marketing
   cannot — and Contacts access is not widened).
3. Campaign audience picking provably unbroken (test + one live check at
   deploy, orchestrator).
4. Filters URL-backed, consistent control, only on screens the inventory
   justified; inventory recorded.
5. Screenshots delivered to the operator before publish; style law holds.
6. Deferrals to the board; HANDOFF + board current.

## Out of scope (recorded)

- Analytics controls (its own package).
- Identity resolution across channels (gap goes to the board if found).
- Any Templates/Campaigns/WhatsApp-surface rework (dormant scope).
- FB posts in the Posts section (separate ordered item, queued behind the
  interpretation seat).

## Stage log

*(executor appends; gate verdict recorded here)*

### Stage 0 — inventory (executor, 2026-07-20)

Ground-truth read of every candidate list surface + every Segments consumer.
Evidence is file:line against the tree at this stage.

**(a) Channel-mixed list surfaces + the filter each already has**

| Surface | Route | Channel-mixed at the WEB layer? | Existing filter | Verdict |
|---|---|---|---|---|
| Inbox | `/inbox` | **No.** `ConversationRow` (`inbox-api.ts:9`) is WhatsApp-shaped — `waId`, `windowOpen` (the WA 24h-window concept), no `channel`; `listConversations` (`inbox-api.ts:75`) takes `search/failedOnly/from/to/cursor`, no `channel`. | search + "Failed sends only" | **No channel filter** — single-channel at the DTO; a filter would always read WhatsApp. |
| Contacts | `/contacts` | **Yes in the DB, No at the web DTO.** The `Contact` model HAS `channel` (`packages/db/prisma/schema.prisma:249`, `whatsapp\|instagram\|web\|…`, `@@unique([channel, channelUserId])` → each contact lives on exactly ONE channel). But `ContactRow` (`contacts-api.ts:10`) does NOT expose `channel`, and `listContacts` (`contacts-api.ts:90/114`) does not accept a `channel` param. | search, tag, consent, activity, block-state, include-outbound | **BLOCKED / fork** — a data-backed channel filter AND the Scope-3 per-row channel mark both need a *trivial additive* apps/api contacts-DTO change (surface `channel`; accept `?channel=`). No migration (column exists). That change is in **apps/api**, outside this package's web fence and shared with concurrent seats → recorded as a board gap + flagged to the orchestrator. NOT built unilaterally. No identity resolution needed (a contact = one channel). |
| Agent Activity | `/agent/activity` | Yes | **URL-backed `?channel=` select** (All / WhatsApp / Instagram) — `Activity.tsx:100,204-213`. This IS the house control. | **Nothing to add** — already the reference URL-backed channel filter. Out of this package's web fence (agent seat's surface). Note only: its options omit Facebook; FB turns are shadow-only — leave to the agent seat. |
| Posts | `/agent/posts` | **No.** Instagram-only — IG grid, "Sync posts from Instagram", `externalId`, IG-style rows (`Posts.tsx`). FB posts are **explicitly out of scope** (queued behind the interpretation seat). | organic/ads tabs (post source, not channel) | **No channel filter** — single-channel (IG) today. |

**Net Stage-2 result:** within this package's web fence, **no surface qualifies
for a NEW channel filter** — Activity already carries the house control;
Inbox/Posts are single-channel at the DTO; Contacts is channel-mixed in the DB
but its web DTO doesn't carry `channel`, so a data-backed filter/mark needs an
apps/api slice this fence excludes. This is the honest "inventory decides /
honest counts" outcome the kickoff anticipated. See the fork flag below.

**(b) Every consumer of Segments — the fold must leave each working**

- **Campaign audience picker** (`CampaignBuilder.tsx:10,101,152,268-278`): a
  `<select>` fed by `listSegments()` + a live count via `getAudience()` /
  `previewSegment()`. **No dependency on the `/segments` web route** — it calls
  the API directly. Fold-safe. Test-pinned by `CampaignBuilder.test.tsx`
  (`listSegments` mock + segment select + sendable-count assertions, lines
  53/107/113/134).
- **Segments API client** (`contacts-api.ts:187-224`): `listSegments`,
  `createSegment`, `updateSegment`, `deleteSegment`, `previewSegment` → all hit
  `/api/segments*`. Untouched by the fold (web-route move only).
- **The `/segments` web route itself** (`App.tsx:20,81`) + `Segments.tsx` +
  `SegmentBuilder.tsx` — the fold's subject.
- **Nav:** there is **no `/segments` nav link** — AppShell `NAV_GROUPS`
  (`AppShell.tsx:113-135`) never listed Segments, and `AppShell.test.tsx:155`
  already pins "Segments no longer sits in the nav". So "the Segments nav entry
  dies" is **already satisfied**; the fold is a route redirect + a Contacts tab.

**Fork flagged to the orchestrator (Scope 2 + 3 for Contacts):** delivering a
Contacts channel filter and per-row channel mark needs a narrow additive
apps/api contacts change (expose `Contact.channel` in the row DTO; accept a
`?channel=` filter param). It is additive, needs **no migration**, and the
apps/api/contacts slice looks uncontended by the other seats (agent/situations,
interpretation, analytics). But it lies **outside this package's stated web
fence** and apps/api is shared. Recommendation: orchestrator either grants this
seat the `apps/api/src/contacts` slice for a follow-up, or queues it as a board
item. Not built in this package. Stages 1 + 3 (the fold + evidence) proceed
regardless — they are fully within the web fence.

**GRANT (orchestrator, 2026-07-20):** fence extended to include
`apps/api/src/contacts` (additive only) — expose `Contact.channel` in the row
DTO + accept a Zod-validated `?channel=` filter; no migration; no other api
surface. Scope 2 + 3 for Contacts delivered against it in Stage 2 below.
Inbox/Posts confirmed filter-free.

### Stage 1 — the fold (executor, 2026-07-20) — `8e765b6`

Segments folded into Contacts as a marketing-gated tab; `/segments` redirects.
Red-first: route-pin moved from renders→redirects + a query-preservation pin;
Contacts tab-gating tests (both role directions).

- `App.tsx`: `/segments` → `<Navigate to="/contacts?tab=segments" replace/>`;
  `Segments` route + import removed; `Segments.tsx`/`Segments.test.tsx` deleted.
- `Contacts.tsx`: URL-backed tab strip (`?tab=segments`, house Campaigns-tab
  pattern). Segments tab shows to marketing+ only (`useAuth`); a viewer never
  sees it and a hand-typed `?tab=segments` falls back to the directory (no
  widening, no bounce). Directory access unchanged.
- `SegmentsPanel.tsx` (new): the saved-list + `SegmentBuilder`, headerless, under
  the shared Contacts header. `SegmentsPanel.test.tsx` carries the old Segments
  screen tests (list / builder / confirm-before-delete).
- **Oracle** (`cd apps/web && npx vitest run --pool=forks
  --poolOptions.forks.maxForks=2`): App.routes 43 · Contacts 12 · SegmentsPanel
  3 · AppShell 17 · **CampaignBuilder 5 (audience picker pinned unbroken)** —
  80/80, re-run green after a mid-stage branch rebase (63/63). Web typecheck
  clean for the touched files. Nav entry was already gone (AppShell.test pins it).

### Stage 2 — channel filter + marks (executor, 2026-07-20) — `95ab2c8`

Per the GRANT. Red-first: service where-clause test (`where.channel`) + DTO
carry-through + web filter/mark tests.

- **api** (`apps/api/src/contacts`): `listContactsSchema` gains
  `channel: z.enum([...ChannelId]).optional()` (Zod at the boundary);
  `contacts.service.list` adds `if (q.channel) where.channel = q.channel`. The
  row DTO already carried `channel` (`serializeContact` spreads it) — now
  consumed. Oracle: `contacts.service` 21 · `contacts.controller` 14 ·
  `dto` 7 = **39/39** (capped forks). No migration; no DI/module change.
- **web**: `ContactRow.channel` + `ListContactsParams.channel` typed;
  `Contacts.tsx` gains a URL-backed channel `<select>` (`?channel=`, mirrors the
  Agent Activity house control) folded into the query + page-reset + Clear +
  empty-state; a `ChannelMark` line-glyph icon (no pills, not emojis) per row +
  a Channel column, and on the ContactDetail identity line (`withLabel`); the
  ID header is now channel-honest ("Channel ID"). Oracle: Contacts 15 ·
  ContactDetail 6 · ChannelMark 5 = **26/26**.
- Full `pnpm --filter web build` / `--filter api build` are currently RED from
  **concurrent sibling WIP only** (analytics seat's uncommitted `analytics-api.ts`
  + `Home.test.tsx`; interpretation seat's `comment-policy.service.ts` importing
  not-yet-exported shared symbols). `tsc` reports **zero** errors in any
  contacts/segments file. Flagged to the orchestrator — not this seat's fence.

### Stage 3 — evidence (executor, 2026-07-20)

Live-app capture is blocked (shared-tree sibling build breakage + no local stack
per the Local-dev memory; live prod lacks the unpublished change). So — exactly
as appshell-scope-3's evidence — a static harness renders the REAL
`tokens.css` + `global.css` with markup mirroring the component output, via
`tools/screenshot/channels-scope-3-shots.mjs` (Playwright, deviceScaleFactor 1.5).

- `plans/rethink/evidence/channels-scope-3-segments-tab-{1440,375}.png` — the
  Segments tab active inside Contacts (builder + Live count + saved segments).
- `plans/rethink/evidence/channels-scope-3-channel-filter-{1440,375}.png` — the
  directory with the Channel filter on "Instagram" + per-row IG/WA/FB marks.
- **These are static-harness renders, NOT live-app captures** — recommend the
  orchestrator regenerate from the live app at deploy (their lane) for the
  operator's before-publish review.

### Follow-up — operator order "add other channels to inbox and contact" (Seat I, 2026-07-22)

Operator (said twice): *"add other channels to inbox and contact. the inbox
doesn't even have the filter. the contact only has the filter."* Stage 2 had
delivered Contacts' channel filter but skipped Inbox (Inbox `ConversationRow`
was WhatsApp-shaped at the DTO). This follow-up makes Inbox genuinely
multi-channel and shows the channel *plainly* on both surfaces.

- **Inbox API** (`apps/api/src/inbox`, commit `3c394bd`): `listConversations`
  gains a Zod `?channel=` filter (scoped at the DB — cursor-safe) and every
  row carries `conversation.channel` (the column already existed:
  `schema.prisma:342`). Additive, no migration. Tests: inbox.service 21 (+3),
  inbox.controller 7 — green.
- **Inbox web** (`Inbox.tsx` + `inbox-api.ts`, commit `c766e3a`): URL-backed
  channel filter (house control, mirrors Agent Activity + Contacts) whose
  options come from the shared registry — **`channelsForCase("dm")`** =
  WhatsApp + Instagram today, never hardcoded; a comment-only channel
  (Facebook) is correctly absent and a future FB-DM surface would appear
  automatically. Plain-word channel `Chip` on every conversation row. Proof:
  a test seeding an `instagram`-channel conversation asserts it renders with
  its "Instagram" chip AND is reachable via the filter. Inbox 13 (+3) green.
- **Contacts channel shown plainly** (`ChannelMark.tsx` + tests, commit
  `42cc744`): converted the scope-3 line-glyph `ChannelMark` to a plain-word
  `cm-chip`, matching the Inbox row chip (one concept, one surface; honors
  the operator's repeated "plain word"). Covers all four contact channels
  incl. web. Contacts 15 · ContactDetail 6 · ChannelMark 4 green.
- **Conformance** (commit `d37ee3e`): scan kept green — Inbox select inherits
  font-size (no off-scale marker), ChannelMark dropped from LEGACY_BASELINE
  (now token-clean).

**What creates Contact rows per channel (investigated, file:line):**
- WhatsApp inbound → `processWhatsAppEnvelope`
  (`webhook-processor.service.ts:100`) upserts a `whatsapp` contact.
- Instagram DM → `IgDmService.processDm` (`ig-dm.service.ts:82`) upserts an
  `instagram` contact — **receive is UNGATED**, so a contact is created the
  moment any DM lands (no DM ingested yet — subscribe done 2026-07-22, send
  half awaits the operator's witnessed test).
- Manual/CSV/campaign/outbound paths → `whatsapp` (explicit).
- **IG/FB commenters do NOT become contacts** — `ig-comment.service.ts` and
  `fb-comment.service.ts` contain zero `contact` references. So IG/FB people
  exist as contacts ONLY once a DM arrives. Whether commenters should become
  contacts is a **product decision, not built here** (board item candidate).
  The Contacts screen is honest today: it is effectively all-WhatsApp until
  the first IG DM, and an IG DM contact will display correctly the moment it
  is created (test-seeded via the `Priya`/`instagram` row).

**Deploy:** api rebuilt + build-green on this dev box; **not deployed** (no
local PM2/stack here — VPS deploy + web publish is the orchestrator's lane).
