inbox-triage.md73.0 KBView on GitHub
# One label vocabulary for every channel — INBOX as a label, channel triggers, AOP on the row, and a LinkedIn admission classifier

## 1) Introduction — goal, present state, future state

We want a Slack, LinkedIn or WhatsApp row in the inbox to work the way an email row already works: it is in the inbox because it **carries an `INBOX` label**, it leaves when you archive it, it comes back when a new message arrives, and it carries the same chips — AOP and user labels — that an email carries. Email already does all of this, on a store that is 364,589 `INBOX` rows deep. The channels do not, and the gap is not conceptual: **`cedar_inbox_labels` and `cedar_inbox_item_labels` already exist, already carry `(item_id, channel, label_id, user_id)`, and have never had a single row written to them.**

The sync foundation below this is built. [channel-sync-architecture.md](apps/server/docs/channel-sync-architecture.md) landed shared containers, a derived envelope, derived unread, one message identity, one link store and a deterministic linking ladder; `cedar-channel-container-reads` is at 100%, so [feed-containers.ts](apps/server/src/services/inbox/feed-containers.ts) is the live read path and the envelope divergence that made 60% of LinkedIn chats lie is measured at **0**.

What is missing sits above that line. **"Is this in my inbox" is guessed, not labelled** — every channel source ends in a `NOT EXISTS` against `cedar_inbox_item_state` (75 rows org-wide) that hides a done item only while its `updated_at` still post-dates the newest message, so "it comes back on a reply" is a timestamp coincidence rather than a rule, and nothing records that it came back or why. **Slack mention detection does not exist**: `crm_slack_messages` has no mention column at all, while **9,238 of 38,849 stored Slack messages (24%) carry an unparsed `<@U…>` token** and 181 carry `<!here>`/`<!channel>` — the strongest inbox signal Slack emits, stored and never read. **LinkedIn and WhatsApp reach no conversation**: 316 and 118 containers, **0 linked**, so their 963 and 4 `crm_events` carry no `conversation_id` and can inherit no AOP; the linking ladder is right to refuse them, and the one path that creates supply — a cold, two-way-open thread — sits behind `linkedinAutoCreateDeals`, which defaults off, with **115 chats already two-way open**. And **the row renders none of it**: `inbox.setItemLabels` still excludes email, and [InboxRow.tsx](apps/mail/modules/inbox/components/InboxRow.tsx) renders a channel badge and a draft chip and nothing else — no AOP, no labels — even though 433 of 440 Slack-linked conversations already carry an `aop_id`.

The work runs in two tracks. **Track A** makes the label vocabulary universal: `INBOX` and `STARRED` become reserved labels on `cedar_inbox_item_labels` for channels (Gmail keeps owning them for email), archive becomes "remove `INBOX`", ingest re-applies `INBOX` on a new message unless the container is muted, and `cedar_inbox_item_state` is retired outright. Then Slack mention and LinkedIn reciprocity triggers apply the label, the LinkedIn deal supply opens, AOP and label chips reach the row, and the client composes the feed from one per-channel query each instead of one server-rendered page. **Track B** is a LinkedIn admission classifier, and LinkedIn is the only channel that earns one. **Email is already triaged, by Gmail** — 170,806 `IMPORTANT` rows in the mirror against 364,589 `INBOX` — so a Cedar classifier over email would be a second opinion competing with a better-informed one, and the Important tab already reads Gmail's answer. **Slack does not get classified either**; it gets scoped. A Slack channel is in the inbox because it is **linked to a CRM conversation** — external by construction — and a busy external channel is a sectioning problem the split inboxes already solve, not a classification problem. That leaves LinkedIn: a brand-new chat has no CRM history to judge it by, and the deterministic rules below (reciprocity, known contact) admit 115 of 316 chats and leave a real remainder. Track B is deferred, and specified only so Track A's schema does not have to change to accept it.

**Per-user is required, and measurement decides it.** `done` cannot live on the container row: **213 of 820 Slack containers have two members** (LinkedIn and WhatsApp are always one). One member archiving a shared channel must not archive it for their teammate. `cedar_inbox_item_labels` is already keyed `(item_id, label_id, user_id)`, so this falls out of the existing shape rather than needing a decision.

**One prerequisite sits outside this document.** channel-sync-architecture Phase 6's cutover is unfinished: `channel_sync_buffer` holds 0 rows and `runDrain` has no callers, so live traffic still runs on the legacy buffers and the legacy crons — the LinkedIn drain still never deletes what it processes (245 rows / 23,050 events / 95 chats, oldest 6.8 days), and the Slack auth reclassification never reached the live cron (241 requeued rows re-quarantined within 35 minutes on 2026-08-11). Phase 4 below hangs a trigger off Slack ingest and Phase 5 reads LinkedIn bodies. Finish that cutover first.

## 2) Present state

### 2.1 Architecture diagram

```text
  EMAIL — labels already work                 SLACK / LINKEDIN / WHATSAPP
  ───────────────────────────                 ───────────────────────────
  Gmail Pub/Sub → upsertEmailThread           webhooks → buffers → ingest
        │                                            │
        ▼                                            ▼
  crm_thread_labels  (thread_id, connection_id, label_id)
   ─ 1,845,792 rows · 671 distinct labels      channel_containers   (shared)
   ─ INBOX      364,589                        channel_container_users (per user)
   ─ UNREAD     325,763                          last_read_at ← unread derives from this
   ─ IMPORTANT  170,806                          done · snoozed_until · starred · draft
   ─ STARRED      1,515                          ▲ EXIST, 0 of 1,466 rows ever written
        │
        │  markDone = modifyLabelsWithSync(… [], ['INBOX'])   cedar_inbox_item_state
        │  a reply re-adds INBOX (Gmail's own rule)            ─ 75 rows, string item_id
        ▼                                                      ─ the ONLY done/star store
  fetchEmailItems: label:INBOX → 'INBOX' = ANY(label_ids)      for channels today
   ▲ calls listThreadsFromDb — the LEAF under                        │
     mail.listThreads' router, NOT the router.                       │
     No Gmail fallback, no reconcile, no drafts union.               │
                                                                     │
                                        notDoneOrSnoozed(): NOT EXISTS a done row whose
                                        updated_at >= last_message_at   ◄── the guess
                                                                     │
  ┌──────────────────────────────────────────────────────────────────┘
  ▼
  cedar_inbox_labels        (id, organization_id, user_id, name, color,
                             gmail_label_id, is_folder, filter_query)   0 ROWS
  cedar_inbox_item_labels   (item_id, channel, label_id, user_id)       0 ROWS
        ▲
        └── ALREADY the right shape. Already per-user. Never written to.

  assembleInboxFeed → attachAopIds → attachItemStates → attachItemLabels → mergeAndPage
                           │                                  │
                           │                                  └─ non-email ONLY
                           └─ crm_conversations.aop_id — Slack yes, LinkedIn/WhatsApp NULL
        ▼
  InboxRow: channel badge + draft chip ONLY — no AOP, no label chips
```

### 2.2 Step-by-step walkthrough

Measured read-only against the shared DB, org-wide:

|                                    | email     | slack   | linkedin | whatsapp |
| ---------------------------------- | --------- | ------- | -------- | -------- |
| Containers / threads               | —         | 820     | 316      | 118      |
| …with 2+ members                   | —         | **213** | 0        | 0        |
| …linked to a conversation          | —         | **785** | **0**    | **0**    |
| `crm_events`                       | 519,978   | 50,143  | 963      | 4        |
| …carrying a `conversation_id`      | 519,348   | 50,143  | **0**    | **0**    |
| Conversations carrying an `aop_id` | —         | 433/440 | 0        | 0        |
| `INBOX` label rows                 | 364,589   | **0**   | **0**    | **0**    |

1. **Email's inbox is a label, and archive is a label removal.** `markDone` ([mail.ts:950](apps/server/src/trpc/routes/mail.ts)) is literally `modifyLabelsWithSync(conn, threadId, [], ['INBOX'])`; `markActive` ([mail.ts:959](apps/server/src/trpc/routes/mail.ts)) adds it back. The chain lands in `crm_thread_labels` via `patchEmailThreadLabels` ([email-threads.ts:438](apps/server/src/services/crm/email-threads.ts)), and `fetchEmailItems` ([feed.ts:156](apps/server/src/services/inbox/feed.ts)) reads it back as `'INBOX' = ANY(label_ids)`. Reappear-on-reply is Gmail's rule, not ours, and it is exactly the behaviour the channels need.
2. **Channels guess instead.** `notDoneOrSnoozed` ([feed-containers.ts:79](apps/server/src/services/inbox/feed-containers.ts)) hides an item when a `cedar_inbox_item_state` row says done or snoozed **and** `updated_at >= last_message_at`. So a done channel silently un-hides the instant a newer message exists — the right outcome by accident. Nothing records the return, so nothing can fire on it, and nothing can say why a row is present.
3. **The label tables exist and are empty.** `cedar_inbox_labels` carries `id`, `organization_id`, `user_id` (NULL = org-shared), `name`, `color`, **`gmail_label_id`**, `is_folder`, `filter_query` — a registry already designed to bridge to Gmail. `cedar_inbox_item_labels` carries `(item_id, channel, label_id, user_id)`. **Both are 0 rows org-wide.** `inbox.setItemLabels` ([inbox.ts:185](apps/server/src/trpc/routes/inbox.ts)) writes them but takes `z.enum(['linkedin','whatsapp','slack'])`, and `attachItemLabels` ([feed.ts:545](apps/server/src/services/inbox/feed.ts)) merges them onto non-email items only.
4. **Per-user state is scattered across three places.** `cedar_inbox_item_state` (75 rows) holds done/star/snooze for channels, keyed by a text item id. `channel_container_users` holds `done`, `snoozed_until`, `starred`, `draft`, `is_muted` and `last_read_at` on a proper `(container_id, user_id)` key — **1,466 rows, and only `last_read_at` has ever been written** ([feed-containers.ts:66](apps/server/src/services/inbox/feed-containers.ts) explains why: nothing writes the rest, so reading them would discard every done, snooze and draft). Drafts live in a third place again, on `linkedin_chats.draft` / `whatsapp_chats.draft`.
5. **Slack mention detection does not exist.** `ingestSlackMessages` ([slack-events.ts:298](apps/server/src/services/crm/slack-events.ts)) writes `crm_events`, `crm_slack_messages`, then `refreshEnvelopeAfterIngest` ([slack-events.ts:692](apps/server/src/services/crm/slack-events.ts)). There is **no mention column on `crm_slack_messages`**; the legacy `mentioned_user_ids` array survives only on the write-dead `crm_slack_events` and was never populated on any of its 7,563 rows. Measured live: **9,238 of 38,849 messages carry `<@U…>`, 181 carry a broadcast.** The identity a trigger needs is already resolved at ingest — `connectedSlackUserId` ([slack-events.ts:360](apps/server/src/services/crm/slack-events.ts)), used today only to decide direction.
6. **Slack reaches the CRM properly and is the AOP supply.** 785 of 820 containers linked, 50,143 events all conversation-bound, 433 of 440 conversations carrying an `aop_id`. `attachAopIds` ([feed.ts:487](apps/server/src/services/inbox/feed.ts)) already resolves it onto the item. **The badge is simply not rendered** ([InboxRow.tsx:66](apps/mail/modules/inbox/components/InboxRow.tsx)).
7. **LinkedIn and WhatsApp reach no conversation at all.** All 316 and 118 containers sit at `link_state = 'pending'`. The ladder is right to refuse: their counterparties are on no deal, and it is forbidden from spending an enrichment call to manufacture a link. So there is **no LinkedIn or WhatsApp AOP supply** until a link exists.
8. **The one path that creates supply is off by setting, not by code.** `promoteChatOnMessage` ([channel-deal-binding.ts:141](apps/server/src/services/crm/channel-deal-binding.ts)) omits `commit`, deferring to `linkedinAutoCreateDealsEnabled` ([channel-deal-binding.ts:153](apps/server/src/services/crm/channel-deal-binding.ts)), which **defaults off**. Measured supply behind it, excluding InMail: **316 chats, 115 two-way open, 79 at ≥2 in / ≥1 out, 43 with no message bodies at all.**
9. **Email never touches the unified feed.** `showUnifiedFeed = channel !== 'email'` ([mail.tsx:92](apps/mail/modules/threads/mail.tsx)) routes the default view to `MailList`. Anything added to `assembleInboxFeed` is invisible on the surface most reps look at.
10. **And where it does, it is a second implementation.** `fetchEmailItems` ([feed.ts:233](apps/server/src/services/inbox/feed.ts)) calls `listThreadsFromDb` ([feed.ts:306](apps/server/src/services/inbox/feed.ts)) — the leaf SQL **under** `mail.listThreads`, not the router `mail.listThreads` actually calls ([mail.ts:1262](apps/server/src/trpc/routes/mail.ts) → `listThreadsViaMirrorWithFallback`, [route-list-threads.ts:105](apps/server/src/services/mail/list/route-list-threads.ts)). The header comment at [feed.ts:17](apps/server/src/services/inbox/feed.ts) claims the feed "matches `mail.listThreads` exactly"; it does not. The router is where the kill switch, the passthrough fallback, the empty-mirror probe ([route-list-threads.ts:218](apps/server/src/services/mail/list/route-list-threads.ts)) and the inline reconcile ([route-list-threads.ts:242](apps/server/src/services/mail/list/route-list-threads.ts)) live — and the reconcile is the only thing that unions in **draft-only threads, which Gmail's search index never returns** ([check-sync.ts:121](apps/server/src/services/mail/list/check-sync.ts)). So the Email tab reads *and repairs* the mirror while `All` reads it and cannot; a draft the rep just saved shows on one and not the other. `fetchEmailItems` also re-derives the tab's scope query itself in `inboxCompiledQuery` ([feed.ts:211](apps/server/src/services/inbox/feed.ts)), a third copy of the client's `buildImportantQueryExclusions` ([inbox-query-params.ts:78](apps/mail/modules/threads/lib/inbox-query-params.ts)) — and it drops that copy's `!inbox.conversationFilter` guard, so a CRM-ruled split is subtracted from `All` and kept by `Email`.

## 3) Designed state

### 3.1 Architecture diagram

```text
 ══ TRACK A — one label vocabulary, four channels ═══════════════════════════════

   RESERVED LABELS, identical names on every channel
   ─────────────────────────────────────────────────
     INBOX     in the list          STARRED   starred
     (no third kind: the LinkedIn classifier decides ADMISSION,
      so its verdict is an applied_by reason, not a new label)

        EMAIL                              SLACK · LINKEDIN · WHATSAPP
        ─────                              ───────────────────────────
   crm_thread_labels                   cedar_inbox_item_labels
   (thread_id, connection_id,          (item_id, channel, label_id, user_id)
    label_id)                            + applied_by · applied_at
   Gmail is authoritative               Cedar is authoritative
        │                                        │
        │  same names, two physical stores, because Gmail owns email's.
        │  cedar_inbox_labels.gmail_label_id is the bridge that already exists.
        └────────────────┬───────────────────────┘
                         ▼
            services/inbox/inbox-label.ts
              applyInboxLabel(itemId, userId, reason)   → returns { applied }
              removeInboxLabel(itemId, userId, reason)
                         ▲
        ┌────────────────┼──────────────────────────┬──────────────────────┐
        │                │                          │                      │
  archive / snooze   NEW MESSAGE AT INGEST      slack mentions      linkedin reciprocity
  (user action)      re-apply unless is_muted   extractSlackMentions  isReciprocal
                     ▲ THE UN-DONE RULE, and
                       it is Gmail's own rule
                         │
                         ▼
   feed: AND EXISTS (… label_id = 'INBOX' AND user_id = $1)   ← same predicate email uses
                         │
        ┌────────────────┴──────────────────┬─────────────────────────┐
        ▼                                   ▼                         ▼
  RETIRED: cedar_inbox_item_state    linkedinAutoCreateDeals    assignAopToChannel
   done   → no INBOX label            → cold 2-way-open deals    Conversation
   star   → STARRED label             → conversations exist      → aop_id on the column
   unread → derived (last_read_at)                │                       │
   snooze → snoozed_until on the                  └───────────┬───────────┘
            per-user container row                            ▼
   draft  → channel_container_users.draft   InboxRow: AOP badge + label chips + popover

 ══ TRACK B — LinkedIn admission classifier (DEFERRED) ═════════════════════════

   NOT email: Gmail already triages it (IMPORTANT 170,806).
   NOT slack: admission IS the link — linked ⇒ external ⇒ in; sections do the rest.

        a NEW LinkedIn chat, at drain time (never re-decided)
                    │
                    ▼
        linkedin-admission.ts    reciprocity · known contact  ── admit, no model call
                    │            (Phase 5's rules, run first)
                    ▼
              the remainder — one call, cheapest model that holds up
        resolveAdmissionModel()  glm · kimi · haiku, A/B'd against sonnet,
                    │            same shape as taxonomy-model.ts
                    ▼
        verdict recorded on channel_container_users
          admit → applyInboxLabel(reason:'classified')
          hold  → NO label, and the verdict is WRITTEN so a cold chat
                  is never re-classified on every drain
```

### 3.2 Step-by-step walkthrough

**A. INBOX is a label**

1. **The vocabulary is universal; the store stays split, on purpose.** `INBOX` and `STARRED` mean the same thing on all four channels. Email keeps writing them to `crm_thread_labels` through Gmail, because Gmail is authoritative there and a second Cedar row would be a second opinion. Channels write them to `cedar_inbox_item_labels`. `cedar_inbox_labels.gmail_label_id` is already the column that maps a Cedar label to its Gmail counterpart — the bridge was designed in and never used.

   **Why not one physical table.** Email's labels are keyed by `connection_id` and are a mirror of a system we do not control; channel labels are keyed by `user_id` and we own them outright. Forcing them into one table means either giving up the Gmail mirror's write path or inventing a fake connection for Slack. The vocabulary is what has to be shared, and it is.

2. **`cedar_inbox_labels` gains `kind`.** `'system'` for `INBOX` / `STARRED` (reserved names, never deletable, never renameable) and `'user'` for the ones a rep makes. Two values, not three: Track B's classifier decides admission, so its verdict is an `applied_by` reason rather than a label of its own. `ensureSystemLabels(orgId)` seeds the two system rows idempotently.
   - Data after this step:
     ```json
     [
       { "id": "INBOX",   "organizationId": "org_1", "userId": null, "name": "Inbox",   "kind": "system" },
       { "id": "STARRED", "organizationId": "org_1", "userId": null, "name": "Starred", "kind": "system" }
     ]
     ```
3. **`applyInboxLabel(itemId, userId, reason)`** — new, in [inbox-label.ts](apps/server/src/services/inbox/inbox-label.ts). Inserts `(item_id, channel, 'INBOX', user_id)` with `applied_by` and `applied_at`, `ON CONFLICT DO NOTHING`, and returns `{ applied: true }` only when the row was genuinely new. **That boolean is the genuine-transition signal** — what a notification or a log fires from, rather than every re-apply. Track B's classifier runs *before* it, deciding whether the call is made at all.
   - Data after this step:
     ```json
     { "item_id": "slack:T01:C0AB", "channel": "slack", "label_id": "INBOX",
       "user_id": "u_1", "applied_by": "mention", "applied_at": "2026-08-11T09:12:00Z" }
     ```
4. **`removeInboxLabel(itemId, userId, reason)`** — deletes that row. **That is what archive is.** `inbox.setItemState`'s `done` ([inbox.ts:136](apps/server/src/trpc/routes/inbox.ts)) becomes exactly this call, which makes it structurally identical to `markDone` ([mail.ts:950](apps/server/src/trpc/routes/mail.ts)) instead of a parallel mechanism that happens to produce the same effect.
5. **The un-done rule: ingest re-applies the label.** When a new message lands on a container, ingest calls `applyInboxLabel(…, reason: 'reply')` for every member unless `channel_container_users.is_muted` — the column already exists. This is Gmail's own behaviour (an archived thread returns when a reply arrives; a muted one does not), and it **deletes the timestamp comparison outright**. There is no `removed_at`, no recency predicate, no `shouldReapply`: the label is either on the row or it is not, and a new message puts it back.
6. **The feed's predicate becomes the one email already uses.**
   ```sql
   AND EXISTS (SELECT 1 FROM cedar_inbox_item_labels l
                WHERE l.item_id = <itemIdExpr> AND l.user_id = $1 AND l.label_id = 'INBOX')
   ```
   replacing `notDoneOrSnoozed` ([feed-containers.ts:79](apps/server/src/services/inbox/feed-containers.ts)). An index on `(user_id, label_id, item_id)` makes it a covering lookup.
7. **Per-user, because 213 Slack containers have two members.** The key is `(item_id, label_id, user_id)`, which the table already has. A rep archiving `#ext-cedar-acme` removes only their own row; their teammate's stays. LinkedIn and WhatsApp containers are always single-member, so per-user costs them nothing.
8. **`cedar_inbox_item_state` is retired entirely.** `done` is the absence of the `INBOX` label. `starred` is the `STARRED` label. `unread` is already derived from `last_read_at` and stays derived — a counter is what produced the 444-unread bug, and Gmail's `UNREAD` label is not a shape worth copying back. `snoozed_until` moves to `channel_container_users` because it needs a timestamp a label cannot hold: snooze = remove `INBOX` + set `snoozed_until`, and a sweep re-applies `INBOX` when it passes. `draft` moves to `channel_container_users.draft` from the two per-channel chat tables. That leaves **one** per-user row and **one** label table, down from three stores.

**B. Triggers — why the label was applied**

9. **`extractSlackMentions`** — new, in [slack-mentions.ts](apps/server/src/services/crm/slack-mentions.ts). Pure: scans `text` for `<@U…>`, `<!here>`, `<!channel>`, `<!subteam^S…>`, ignoring tokens inside code fences.
   - Data after this step:
     ```json
     { "userIds": ["U099", "U123"], "broadcast": ["here"] }
     ```
10. **`slackInboxTriggers`** — new, in [membership-triggers.ts](apps/server/src/services/inbox/membership-triggers.ts), called from `ingestSlackMessages` ([slack-events.ts:298](apps/server/src/services/crm/slack-events.ts)) once per container per batch. **Gated on the container being LINKED** — `conversation_id IS NOT NULL`, which is 785 of 820 containers and is what "external" means here. The gate has to be positive: `isPositivelyInternal` ([linking.ts:297](apps/server/src/services/channels/linking.ts)) only writes `internal` on positive evidence, so "not internal" would wave through every `pending` and `ambiguous` container instead of keeping them out. Inside that gate it applies `INBOX` when `connectedSlackUserId` ([slack-events.ts:360](apps/server/src/services/crm/slack-events.ts)) is mentioned or the message is `@here`/`@channel` → `'mention'`; the container is a DM → `'dm'`; the message is inbound → `'reply'`. **This is the whole of Slack's control surface, on purpose** — a mention is an inbox trigger, not a triage verdict, and a busy external channel is sorted with a split inbox rather than classified.
11. **`linkedinInboxTriggers`** — same file, called after the LinkedIn drain promotes. Applies `INBOX` when the newest message is inbound **and** either the counterpart is a known contact (`getConversationsForPerson` ([channel-message-events.ts:280](apps/server/src/services/crm/channel-message-events.ts)) ≥ 1) → `'known_contact'`, or the chat is two-way open (`isReciprocal` ([channel-deal-binding.ts:126](apps/server/src/services/crm/channel-deal-binding.ts))) → `'reciprocal'`. Cold one-directional never gets the label; InMail never does either, matching the feed's existing exclusion. Sized live: 115 of 316 chats qualify today, 43 have no bodies and cannot be judged until catch-up or the reconciler fills them.

**C. Supply — LinkedIn conversations, then AOP**

12. **The commit gate is flipped by setting, per org, after a dry run.** `linkedinAutoCreateDeals` already exists and defaults off; no code change turns it on. What is missing is the evidence to do it safely — a reporter listing accumulated `would-create` verdicts. **This gates step 13:** with 0 LinkedIn containers linked, an AOP hook on linking has nothing to run on.
13. **`assignAopToChannelConversation`** — new, in [channel-aop.ts](apps/server/src/services/crm/channel-aop.ts). Called from `linkContainerToConversation` ([containers.ts:183](apps/server/src/services/channels/containers.ts)) — one link store, so one call site covers all three channels — when the target conversation's `aop_id` is NULL. Reuses `selectAopForConversation` and `setupConversationForAop`, and deliberately does **not** call `syncAopLabel`: a LinkedIn chat has no Gmail thread to stamp, and `attachAopIds` ([feed.ts:487](apps/server/src/services/inbox/feed.ts)) reads the column, which is the source of truth.
14. **The row renders the vocabulary.** `inbox.setItemLabels` ([inbox.ts:185](apps/server/src/trpc/routes/inbox.ts)) widens to four channels and refuses to touch `kind = 'system'` labels through the user-facing path; `attachItemLabels` ([feed.ts:545](apps/server/src/services/inbox/feed.ts)) stops excluding email; `InboxRow` ([InboxRow.tsx:66](apps/mail/modules/inbox/components/InboxRow.tsx)) renders the AOP badge from `item.aopId` plus `RenderLabels` ([render-labels.tsx:13](apps/mail/modules/threads/threadList/components/render-labels.tsx)), hiding system labels the way Gmail hides `INBOX` from the chip row.
    - Data after this step:
      ```json
      { "id": "li:chat_abc", "channel": "linkedin", "aopId": "aop_deals",
        "labelIds": ["INBOX", "lbl_7"], "inboxAppliedBy": "reciprocal" }
      ```

**D. LinkedIn admission classifier (deferred; designed, not scheduled)**

15. **The scope is one channel, and the other three are decided rather than deferred.** Email is triaged by Gmail and Cedar reads that answer already — a second classifier over it would compete with the better-informed one and would have to be reconciled with `IMPORTANT` every time they disagreed. Slack's admission decision **is** the link: `conversation_id IS NOT NULL` means an external counterparty was positively resolved, and per §3.2 step 10 nothing else is asked of it. WhatsApp follows Slack. **LinkedIn is the exception because it is the only channel where a brand-new conversation arrives with no CRM history at all** — 316 containers, 0 linked, so there is nothing to look the counterparty up by.

16. **It classifies ADMISSION, not action.** The verdict is `admit` or `hold`, and its only effect is whether `applyInboxLabel(…, reason: 'classified')` is called. There is no `respond`/`read`/`ignore` vocabulary, no third label `kind`, and no per-item action state — which is what deletes `setItemAction`, the partial unique index, `actionClause`, `inbox.actionCounts` and the triage queue from this design outright. A rep who disagrees archives the row, and archive is already `removeInboxLabel`.

17. **`classifyLinkedinAdmission`** — new, in [linkedin-admission.ts](apps/server/src/services/inbox/linkedin-admission.ts). **One decision per chat.** For LinkedIn a container *is* a chat — `container.external_id` IS the Unipile `chat_id` ([linkedin-evidence.ts:51](apps/server/src/services/channels/adapters/linkedin-evidence.ts)) — so the per-chat grain and the per-container key are the same key, and the verdict is never taken at the person, the account or the batch level. Two chats with the same counterparty are judged separately, because the second one can be the real conversation. Runs **only** on a chat with no recorded verdict, and **only after** Phase 5's deterministic rules decline it: `isReciprocal` and `getConversationsForPerson` admit ~115 of 316 chats with no model call, InMail is refused outright, and the 43 body-less chats are not judgeable and stay unclassified until catch-up fills them. That leaves roughly 150 chats to ever reach a model, once each.
    - Data after this step:
      ```json
      { "containerId": "…", "verdict": "hold", "reason": "one-directional cold outreach, no reply",
        "model": "glm-5.2", "decidedAt": "2026-08-31T09:12:00Z" }
      ```

18. **The verdict is written whichever way it goes.** `hold` writes no label, so the absence of a label cannot distinguish "judged and held" from "not yet judged" — and without that distinction every drain re-classifies every cold chat forever. The verdict lands on `channel_container_users` next to the rest of the per-user state rather than in a new table; LinkedIn containers are always single-member, so the per-user key costs nothing and keeps one shape.

19. **The model is chosen by eval, not by assertion.** [taxonomy-model.ts](apps/server/src/services/crm/taxonomy-model.ts) already has exactly this shape — a keyed `MODEL_SPECS` map over `glm-5.2` and `kimi-k2.6` (open-weight, Understudy-gateway) plus `haiku` and a `sonnet` control, one registered workload per candidate so spend and latency are attributable per model. `resolveAdmissionModel` reuses it rather than inventing a second resolver, and the cheapest candidate that matches the control on a labelled set is the one that ships. At ~150 first-time calls per seat this is a small bill either way; the reason to run the eval is that a wrong `hold` is invisible — the rep never sees the chat it suppressed.

### 3.3 Schema

```sql
-- ═══ TRACK A — the label registry gains a kind, and reserved system labels ═══
ALTER TABLE cedar_inbox_labels ADD COLUMN kind text NOT NULL DEFAULT 'user';       -- NEW
ALTER TABLE cedar_inbox_labels ADD CONSTRAINT cedar_inbox_labels_kind_ck
  CHECK (kind IN ('system','user'));                                               -- NEW
-- Existing, unchanged: id text PK · organization_id uuid NOT NULL
--   user_id text (NULL = org-shared) · name text NOT NULL · color jsonb
--   gmail_label_id text  ◄── the Cedar→Gmail bridge, already here, unused
--   is_folder boolean NOT NULL DEFAULT false · filter_query jsonb · created_at
--   UNIQUE (organization_id, user_id, name)
-- Seeded per org by ensureSystemLabels(): id 'INBOX' and 'STARRED', kind 'system',
-- user_id NULL, gmail_label_id equal to the id (Gmail's own reserved names).

-- ═══ TRACK A — item↔label membership, already per-user, still empty ═══
ALTER TABLE cedar_inbox_item_labels ADD COLUMN applied_by text;                    -- NEW
ALTER TABLE cedar_inbox_item_labels ADD COLUMN applied_at timestamptz NOT NULL DEFAULT now();  -- NEW
ALTER TABLE cedar_inbox_item_labels ADD CONSTRAINT cedar_inbox_item_labels_applied_by_ck
  CHECK (applied_by IS NULL OR applied_by IN
    ('mention','dm','reply','known_contact','reciprocal','classified','gmail','rule','user'));  -- NEW
--                                            ▲ TRACK B — the classifier's admit verdict
ALTER TABLE cedar_inbox_item_labels ADD CONSTRAINT cedar_inbox_item_labels_channel_ck
  CHECK (channel IN ('email','linkedin','whatsapp','slack'));                      -- NEW
CREATE INDEX idx_ciil_user_label_item
  ON cedar_inbox_item_labels (user_id, label_id, item_id);                         -- NEW
-- Existing, unchanged: item_id text NOT NULL · channel text NOT NULL
--   label_id text NOT NULL REFERENCES cedar_inbox_labels(id) ON DELETE CASCADE
--   user_id text NOT NULL · PRIMARY KEY (item_id, label_id, user_id)
-- PER-USER IS LOAD-BEARING: 213 of 820 Slack containers have two members.

-- ═══ TRACK A — the per-user container row keeps only what a label cannot hold ═══
-- snoozed_until needs a timestamp; draft needs a document; is_muted governs the
-- re-apply rule; last_read_at derives unread. done and starred become labels.
ALTER TABLE channel_container_users DROP COLUMN done;                              -- DEL (0 of 1,466 written)
ALTER TABLE channel_container_users DROP COLUMN starred;                           -- DEL (0 of 1,466 written)
-- Existing, unchanged: container_id uuid · user_id text · conversation_id uuid
--   link_state text · link_candidates jsonb · linked_at · last_read_at
--   last_message_from_self boolean · snoozed_until timestamptz · draft jsonb
--   is_muted boolean · created_at · updated_at

-- cedar_inbox_item_state is dual-written for one release, then DROPPED:
--   done → absence of the INBOX label · starred → the STARRED label
--   unread → already derived from last_read_at · snoozed_until → the container row

-- ═══ TRACK A — Slack mentions ═══
-- No mention column exists on this table today; the legacy crm_slack_events.mentioned_user_ids
-- was never populated on any of its 7,563 rows.
ALTER TABLE crm_slack_messages ADD COLUMN mentioned_slack_user_ids text[] NOT NULL DEFAULT '{}'; -- NEW
ALTER TABLE crm_slack_messages ADD COLUMN mention_broadcast        text[] NOT NULL DEFAULT '{}'; -- NEW
CREATE INDEX idx_crm_slack_messages_mentions
  ON crm_slack_messages USING gin (mentioned_slack_user_ids);                                    -- NEW

-- ═══ EMAIL — unchanged, and that is the point ═══
-- crm_thread_labels (thread_id, connection_id, label_id, latest_message_at) already holds
-- 364,589 INBOX rows and 1,515 STARRED. Gmail stays authoritative; the names are shared.

-- ═══ TRACK B — the LinkedIn admission verdict (DEFERRED) ═══
-- PER CHAT: for LinkedIn container_id is the chat (external_id IS the Unipile chat_id), so
-- this key is already the per-chat key. Four columns on a table Track A already owns; no new
-- table, no new label kind, and
-- nothing at all for email or Slack — Gmail triages the first, the link decides the second.
-- The verdict is stored for BOTH outcomes: a `hold` writes no label, so without a row here
-- every drain would re-classify every cold chat forever.
ALTER TABLE channel_container_users ADD COLUMN admission_verdict    text;
ALTER TABLE channel_container_users ADD COLUMN admission_reason     text;
ALTER TABLE channel_container_users ADD COLUMN admission_model      text;
ALTER TABLE channel_container_users ADD COLUMN admission_decided_at timestamptz;
ALTER TABLE channel_container_users ADD CONSTRAINT ccu_admission_verdict_ck
  CHECK (admission_verdict IS NULL OR admission_verdict IN ('admit','hold'));
-- The work queue: LinkedIn containers never yet decided. Partial, so it stays tiny.
CREATE INDEX idx_ccu_undecided ON channel_container_users (user_id, container_id)
  WHERE admission_decided_at IS NULL;
```

```ts
// ═══ TRACK A — apps/server/src/services/inbox/types.ts ═══
export type InboxLabelKind = 'system' | 'user';                                  // NEW
export type InboxLabelReason =                                                   // NEW
  | 'mention' | 'dm' | 'reply' | 'known_contact' | 'reciprocal' | 'classified'
  | 'gmail' | 'rule' | 'user';
export const SYSTEM_LABELS = { INBOX: 'INBOX', STARRED: 'STARRED' } as const;    // NEW

export interface InboxItem {
  // …unchanged: id · channel · sortedAt · counterpart · snippet · subject · unread ·
  //   starred · hasDraft · snoozedUntil · done · lastFromSelf · conversationId ·
  //   aopId · labelIds · participantCount · threadMeta · ref
  inboxAppliedBy?: InboxLabelReason;    // NEW — "why is this here"
}

// ═══ TRACK A — apps/server/src/services/inbox/inbox-label.ts ═══               // NEW FILE
export interface ApplyLabelResult {
  applied: boolean;   // true ONLY on a genuinely new row — the transition hook
  itemId: string;
  userId: string;
  reason: InboxLabelReason;
}
export function applyInboxLabel(db: DB, i: { itemId: string; channel: InboxChannel;
  userId: string; reason: InboxLabelReason }): Promise<ApplyLabelResult>;
export function removeInboxLabel(db: DB, i: { itemId: string; userId: string;
  reason: InboxLabelReason }): Promise<void>;
export function ensureSystemLabels(db: DB, organizationId: string): Promise<void>;

// ═══ TRACK A — apps/server/src/services/crm/slack-mentions.ts ═══             // NEW FILE
export interface SlackMentions { userIds: string[]; broadcast: ('here' | 'channel')[] }

// ═══ TRACK B — apps/server/src/services/inbox/linkedin-admission.ts ═══      // NEW FILE
export type AdmissionVerdict = 'admit' | 'hold';
export interface AdmissionDecision {
  verdict: AdmissionVerdict;
  reason: string;
  /** null when a deterministic rule decided it and no model was called. */
  model: string | null;
}
export function classifyLinkedinAdmission(db: DB, i: { containerId: string;
  userId: string }): Promise<AdmissionDecision>;
```

## 4) Implementation phases

**Prerequisite, outside this document:** finish channel-sync-architecture §4 Phase 6's cutover — the webhook producer, the SQS consumer, and re-pointing the two crons at `runDrain` as a safety net rather than the delivery path. Phase 3's re-apply rule and Phase 4's mention trigger both hang off ingest, and are only as timely as the drain underneath them.

Two tracks. **Track A (Phase 0 and Phases 1–8)** is the work; Phase 0 comes first, because every later phase adds behaviour to `assembleInboxFeed` and would inherit its email source's divergence. **Track B (Phases 9–10)** is the LinkedIn admission classifier and is **not scheduled** — it is specified so Track A's schema does not have to change to accept it.

### Phase 0 — One email read for both lists _(Track A)_

**Goal:** The unified feed's email source goes through the same entry point `mail.listThreads` goes through, so `All` and `Email` cannot disagree — and the feed stops carrying a second implementation of the email read.

Every phase below adds behaviour to `assembleInboxFeed`. An email source that already diverges from the mail list is a divergence each of them inherits, so this is the floor rather than a cleanup.

**The scope is contained, and measured.** `fetchEmailItems` has exactly **one** caller — `assembleInboxFeed` ([feed.ts:808](apps/server/src/services/inbox/feed.ts)) — and `assembleInboxFeed` has exactly **one** production caller: `inbox.listItems` ([inbox.ts:93](apps/server/src/trpc/routes/inbox.ts)). Everything else is tests; the headless `src/inbox/feed-cli.ts` its header advertises **does not exist**. `emailThreadToItem` ([feed-core.ts:90](apps/server/src/services/inbox/feed-core.ts)) already takes `IGetThreadsResponse['threads'][number]` — the exact shape the router returns — so the normalizer needs no change and `fetchEmailItems` survives as a thin adapter, not a read path.

- [x] Give the `headChanged` latch a per-surface key. `takePendingHeadChange` ([reconcile-dispatch.ts](apps/server/src/services/mail/list/reconcile-dispatch.ts)) **consumed** a per-connection flag, so two callers stole each other's signal and one list never trimmed its stale page-2+ cursors. `mail.listThreads` takes an optional `surface`, defaulted to the email list; `markPendingHeadChange` fans a divergence out to every surface, because the reconcile behind it is deduplicated ACROSS surfaces and the joiner would otherwise never be told.
- [x] Make the two lists resolve the active tab the same way. `useThreads` took `activeInbox` from the persisted `settings.activeInboxId` ([use-inboxes.ts:263](apps/mail/modules/threads/hooks/use-inboxes.ts)); the feed resolved its inbox from the URL slug ([mail.tsx:78](apps/mail/modules/threads/mail.tsx)). Both now walk one URL-only ladder — [route-inbox.ts](apps/mail/modules/threads/lib/route-inbox.ts) behind [use-route-inbox.ts](apps/mail/modules/threads/hooks/use-route-inbox.ts) — which also hands the feed the system FOLDER splits (`/mail/sent`) it used to answer with no query at all.
- [x] Make a first draft save reach the mirror. `surfaceDraftInInbox` was dispatched with `void` ([drafts.ts:87](apps/server/src/trpc/routes/drafts.ts)), so the mutation resolved before the label landed and the composer's save-time refetch read a mirror without the draft; it is awaited, which is safe because the call swallows its own failures. `patchEmailThreadLabels` ([email-threads.ts:530](apps/server/src/services/crm/email-threads.ts)) now inserts the join row already carrying `latest_message_at` instead of inserting NULL and backfilling by a second statement — a NULL ordering key is invisible to the page-first index scan ([list-threads-from-db.ts:470](apps/server/src/services/mail/list/list-threads-from-db.ts)), so the two-statement shape left every freshly labelled thread unlistable in between. **No upsert is added:** `crm_thread_labels.thread_id` carries an FK to `crm_email_threads`, so a label row cannot exist without its thread row at all (0 NULL ordering keys org-wide, measured) — the label write for an unmirrored thread fails outright rather than landing unsorted, and the row it needs is written by the hydrate inside `patchStoredThreadLabels`.

**Not in this phase.** Moving the merge itself to the client — four per-channel queries combined in `inboxSlice` — stays out. Two things block it and neither is incidental: the server merges on one `(sortedAt, id)` cursor with an exact `limit + 1` over-fetch per source ([feed-core.ts:64](apps/server/src/services/inbox/feed-core.ts)), so independent infinite queries could only render down to the oldest item common to every loaded channel; and `conversationFilter`, `mailOnly`, `splitChannels` and `emailLabelTerms` are resolved server-side from `inboxId` and **deliberately never sent** ([mail.ts:1222](apps/server/src/trpc/routes/mail.ts)) — accepting a rule off the wire would run an arbitrary filter on every page load. Sharing the read is what removes the divergence; sharing the merge is **Phase 8**, which is where both constraints are answered rather than waved at.

**Tests:**

- [x] Prove the two lists cannot disagree — structurally rather than by comparison. There is no second email read left to compare against: `mail.listThreads` is the only one, and `routeInboxParity.test.tsx` asserts both surfaces derive an identical `compiledQuery`/`queryHash`/`inboxId` for the same route.
- [x] The kill switch and the passthrough fallback are the mail read path's, covered by its own tests ([route-list-threads](apps/server/src/services/mail/list/__tests__/)) — the feed reaches them because it has no email read of its own, which is a stronger guarantee than a test.
- [x] Add a drafts case: [drafts-create-surfaces-in-inbox.test.ts](apps/server/src/trpc/routes/__tests__/drafts-create-surfaces-in-inbox.test.ts) drives the real router and asserts the mutation does not resolve while the label write is pending. Both surfaces read the same `mail.listThreads`, so one assertion now covers both.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox src/services/mail/list`

**As built.** Three of this phase's tasks were dissolved rather than done: Phase 8 retired `assembleInboxFeed` outright, taking `fetchEmailItems`, `inboxCompiledQuery` and the header comment that claimed a parity it did not have. The unified feed no longer reads mail at all, so "one email read for both lists" is now true by construction instead of by repointing.

**The premise on the draft mirror row was wrong, and checking it is what found the real defect.** `crm_thread_labels.thread_id` carries an FK to `crm_email_threads(thread_id)` and that table's `latest_message_at` is `NOT NULL`, so the NULL ordering key described above is unreachable — with no mirror row the label INSERT fails outright, and there are **0** such rows org-wide. The hydrate inside `patchStoredThreadLabels` already writes the row. What was worth fixing is the two-statement window in which a labelled row has no sort key; the key is now written by the INSERT. An upsert here was rejected deliberately: it would fabricate a `history_id` the label patch does not have, and a stub carrying a history floor is self-sealing under historyId-equality change detection.

**Found, not fixed:** 4,222 of 5,230 draft-only threads created in the last 30 days carry `DRAFT` but no `INBOX` row, and weekly coverage has sat near 20% since June. Labelling works — every one has `DRAFT` — so `INBOX` is simply never applied, which points at the agent draft-creation paths rather than the composer. That is the bulk of the reported symptom and wants its own change.

### Phase 1 — The label vocabulary _(Track A)_

**Goal:** `INBOX` and `STARRED` exist as reserved labels, and every container currently visible carries `INBOX`.

- [ ] Add `kind` + its CHECK to `cedar_inbox_labels`, and `applied_by` / `applied_at` / the channel CHECK / `idx_ciil_user_label_item` to `cedar_inbox_item_labels`; mirror in the drizzle schema.
- [ ] Add [apps/server/src/services/inbox/inbox-label.ts](apps/server/src/services/inbox/inbox-label.ts) with `ensureSystemLabels`, `applyInboxLabel` (returning `applied`), `removeInboxLabel`, `getInboxLabelState`.
- [ ] Seed the two system rows per org, with `gmail_label_id` set to the matching Gmail reserved name so the bridge is populated from day one.
- [ ] Backfill: for every `(container, member)` the feed shows today, insert an `INBOX` row with `applied_by = 'rule'`; for every `cedar_inbox_item_state` row that hides an item, insert nothing. Backfill `STARRED` from the same table.
- [ ] Refuse `kind = 'system'` label ids through `inbox.setItemLabels` ([inbox.ts:185](apps/server/src/trpc/routes/inbox.ts)) — system labels move only through `inbox-label.ts`.

**Tests:**

- [ ] Add [apps/server/src/services/inbox/**test**/inbox-label.test.ts](apps/server/src/services/inbox/__test__/inbox-label.test.ts): `applied` is true once and false on re-apply; remove then apply returns true again; `ensureSystemLabels` is idempotent; a system label cannot be renamed, deleted, or set through `setItemLabels`.
- [ ] Read-only DB assertion: after backfill, the `INBOX` row count equals the feed's visible item count per user, and the `STARRED` count equals the starred rows in `cedar_inbox_item_state`.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox/__test__/inbox-label.test.ts`

### Phase 2 — The feed reads the label; the three stores collapse to one _(Track A)_

**Goal:** Archive is a label removal, and `cedar_inbox_item_state` has nothing left to hold.

- [ ] Swap `notDoneOrSnoozed` ([feed-containers.ts:79](apps/server/src/services/inbox/feed-containers.ts)) for the `EXISTS … label_id = 'INBOX'` predicate.
- [ ] Route `inbox.setItemState`'s `done` through `removeInboxLabel` and `starred` through the `STARRED` label ([inbox.ts:136](apps/server/src/trpc/routes/inbox.ts)); **dual-write** `cedar_inbox_item_state` for one release.
- [ ] Move `snoozed_until` to `channel_container_users`: snooze = `removeInboxLabel` + set the timestamp; add the sweep that re-applies `INBOX` once it passes.
- [ ] Re-point `saveChannelDraft` / `clearChannelDraft` ([inbox.ts:110](apps/server/src/trpc/routes/inbox.ts), [inbox.ts:129](apps/server/src/trpc/routes/inbox.ts)) at `channel_container_users.draft`, and drop the `LEFT JOIN linkedin_chats lc` the old draft location forced ([feed-containers.ts:104](apps/server/src/services/inbox/feed-containers.ts)) plus its WhatsApp twin.
- [ ] Surface `inboxAppliedBy` on `InboxItem` and populate it in `assembleInboxFeed` ([feed.ts:560](apps/server/src/services/inbox/feed.ts)).
- [ ] Drop `cedar_inbox_item_state`, and drop the now-unwritten `done` / `starred` columns from `channel_container_users`, after one clean release.
- [ ] Delete `hideDoneOrSnoozed` ([feed.ts:69](apps/server/src/services/inbox/feed.ts)), the three legacy sources behind it, and the `cedar-channel-container-reads` flag.

**Tests:**

- [ ] Add [apps/server/src/services/inbox/**test**/label-feed-parity.test.ts](apps/server/src/services/inbox/__test__/label-feed-parity.test.ts): for a live seat, the item id set is identical before and after the predicate swap.
- [ ] Extend [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts): archiving hides the item; starring survives a refetch; a snoozed item returns when the sweep runs; a draft saved through the new path renders.
- [ ] Add a two-member Slack case: one member archives a shared container and the other still sees it — the 213-container case, asserted rather than assumed.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox`

### Phase 3 — The un-done rule: ingest re-applies INBOX _(Track A)_

**Goal:** A new message brings an archived channel back, by rule rather than by timestamp coincidence — and a muted one stays gone.

- [ ] Call `applyInboxLabel(…, reason: 'reply')` for every member from the ingest path of all three channels, skipping containers where `is_muted`.
- [ ] Scope it: re-apply only where the container is **linked** (`conversation_id IS NOT NULL`), or where a Phase 4/5 trigger fires. Stated positively on purpose — `internal` is only written on positive evidence ([linking.ts:297](apps/server/src/services/channels/linking.ts)), so "not internal" would admit every `pending` and `ambiguous` container.
- [ ] Add `channels.mute` / `unmute` to the channels router, writing `is_muted`, and a row action for it.
- [ ] Emit `createStructuredLog('info','inbox.label', { itemId, labelId, reason, applied })` on every apply and remove, so "why is this in my inbox" is answerable from Axiom.

**Tests:**

- [ ] Add [apps/server/src/services/inbox/**test**/reapply-on-message.test.ts](apps/server/src/services/inbox/__test__/reapply-on-message.test.ts): archive → new message → the item is back with `applied_by = 'reply'`; the same with `is_muted` → still gone; an unlinked channel — `internal`, `pending` or `ambiguous` alike — never acquires the label.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox/__test__/reapply-on-message.test.ts`

### Phase 4 — Slack mention detection and Slack triggers _(Track A)_

**Goal:** Slack is **external-only** — a channel reaches the inbox only if it is linked to a CRM conversation — and inside that set, being tagged applies `INBOX`.

- [ ] Add [apps/server/src/services/crm/slack-mentions.ts](apps/server/src/services/crm/slack-mentions.ts) with the pure `extractSlackMentions`.
- [ ] Add `mentioned_slack_user_ids`, `mention_broadcast` and the GIN index to `crm_slack_messages`; mirror in [crm-schema.ts](apps/server/src/db/crm-schema.ts).
- [ ] Populate both columns in `ingestSlackMessages` ([slack-events.ts:298](apps/server/src/services/crm/slack-events.ts)).
- [ ] Backfill across the 38,849 existing rows — 9,238 carry a `<@U…>` token, 181 a broadcast.
- [ ] Add `slackInboxTriggers` to [apps/server/src/services/inbox/membership-triggers.ts](apps/server/src/services/inbox/membership-triggers.ts) per §3.2 step 10, gated positively on `conversation_id IS NOT NULL` rather than on `link_state <> 'internal'`, and call it from `ingestSlackMessages` once per container per batch using `connectedSlackUserId` ([slack-events.ts:360](apps/server/src/services/crm/slack-events.ts)).

**Tests:**

- [ ] Add [apps/server/src/services/crm/**test**/slack-mentions.test.ts](apps/server/src/services/crm/__test__/slack-mentions.test.ts): `<@U099>`, `<!here>`, `<!subteam^S1|@team>`, and code-fenced false positives.
- [ ] Add [apps/server/src/services/inbox/**test**/slack-inbox-triggers.test.ts](apps/server/src/services/inbox/__test__/slack-inbox-triggers.test.ts): a mention in a **linked** channel applies `INBOX` with `applied_by = 'mention'`; **a mention in an unlinked or `internal` channel does not** — external-only is the outer gate and a mention cannot open it; an unmentioned inbound message on a linked channel does; a DM on a linked container always does; an archived channel returns on a new mention.
- [ ] Read-only DB assertion: the backfill's populated-row count tracks `count(*) WHERE text ~ '<@U'` to within the code-fence exclusions.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/crm/__test__/slack-mentions.test.ts src/services/inbox/__test__/slack-inbox-triggers.test.ts`

### Phase 5 — LinkedIn triggers on reciprocity _(Track A)_

**Goal:** An inbound LinkedIn message applies `INBOX` only when the chat is reciprocal or the counterpart is already known.

- [ ] Export `isReciprocal` ([channel-deal-binding.ts:126](apps/server/src/services/crm/channel-deal-binding.ts)) without changing its behaviour.
- [ ] Add `linkedinInboxTriggers` to [membership-triggers.ts](apps/server/src/services/inbox/membership-triggers.ts), reusing `isReciprocal` and `getConversationsForPerson` ([channel-message-events.ts:280](apps/server/src/services/crm/channel-message-events.ts)); call it from the LinkedIn drain after promotion.
- [ ] Keep the InMail exclusion consistent with the feed's — an InMail chat never gets the label.
- [ ] Confirm the 43 body-less chats are picked up by `channels.catchUp` on open and by `processChannelReconcile`; `isReciprocal` reads bodies and cannot judge a container that has none.

**Tests:**

- [ ] Add [apps/server/src/services/inbox/**test**/linkedin-inbox-triggers.test.ts](apps/server/src/services/inbox/__test__/linkedin-inbox-triggers.test.ts): cold one-directional → no label; after a reply each way → `applied_by = 'reciprocal'`; known contact one-directional → `'known_contact'`; InMail → never.
- [ ] Read-only DB assertion: the trigger's would-apply set matches the measured 115 two-way-open chats.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox/__test__/linkedin-inbox-triggers.test.ts`

### Phase 6 — Open the LinkedIn conversation supply _(Track A)_

**Goal:** A cold LinkedIn thread that goes two-way open and shows interest becomes a deal, so there is something for an AOP to attach to.

No code change turns this on — `linkedinAutoCreateDeals` ([channel-deal-binding.ts:153](apps/server/src/services/crm/channel-deal-binding.ts)) already exists and already defaults off. This phase ships the evidence to flip it. **It gates Phase 7:** all 316 LinkedIn and 118 WhatsApp containers are unlinked.

- [ ] Add [apps/server/src/scripts/report-linkedin-would-create.ts](apps/server/src/scripts/report-linkedin-would-create.ts) listing accumulated `would-create` verdicts per org, with the counterparty and reciprocity counts. Expect a subset of the 115 two-way-open chats.
- [ ] Extend the `messaging.bind` structured log ([channel-deal-binding.ts:141](apps/server/src/services/crm/channel-deal-binding.ts)) with the setting's value, so `created` volume is attributable per org.
- [ ] Flip the setting for one internal org, verify against the report, then roll forward.
- [ ] Confirm each created conversation carries its chat's events — `attachChannelMessagesToConversation` ([channel-message-events.ts:218](apps/server/src/services/crm/channel-message-events.ts)) should take `crm_events.conversation_id` off 0 for the first time.

**Tests:**

- [ ] Add [apps/server/src/services/crm/**test**/linkedin-commit-gate.test.ts](apps/server/src/services/crm/__test__/linkedin-commit-gate.test.ts): with the setting off, a cold + reciprocal + interested chat returns `would-create` and writes nothing; with it on, exactly one conversation is created and the chat's events attach; a second drain cycle creates no duplicate.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/crm/__test__/linkedin-commit-gate.test.ts`

### Phase 7 — AOP and label chips on the row _(Track A)_

**Goal:** A Slack or LinkedIn row carries the same vocabulary an email row does.

- [ ] Add [apps/server/src/services/crm/channel-aop.ts](apps/server/src/services/crm/channel-aop.ts) with `assignAopToChannelConversation`, reusing `selectAopForConversation` ([aop-selection.ts:126](apps/server/src/mastra/utils/aop-selection.ts)) and `setupConversationForAop`, and deliberately **not** calling `syncAopLabel`.
- [ ] Call it from `linkContainerToConversation` ([containers.ts:183](apps/server/src/services/channels/containers.ts)) when the target conversation's `aop_id` is NULL — one call site covers all three channels.
- [ ] Widen `inbox.setItemLabels` ([inbox.ts:185](apps/server/src/trpc/routes/inbox.ts)) to four channels; stop excluding email in `attachItemLabels` ([feed.ts:545](apps/server/src/services/inbox/feed.ts)).
- [ ] Render the AOP badge and `RenderLabels` ([render-labels.tsx:13](apps/mail/modules/threads/threadList/components/render-labels.tsx)) chips on [InboxRow.tsx:66](apps/mail/modules/inbox/components/InboxRow.tsx), filtering out `kind = 'system'` the way Gmail hides `INBOX`.
- [ ] Add a labels popover to the row's hover actions, reusing [labels-popover.tsx](apps/mail/modules/labels/components/labels-popover.tsx). Buttons carry `cursor-pointer`; standard Tailwind text sizes only.

**Tests:**

- [ ] Add [apps/server/src/services/crm/**test**/channel-conversation-aop.test.ts](apps/server/src/services/crm/__test__/channel-conversation-aop.test.ts): a container linked to an AOP-less conversation gives it an `aop_id`; no Gmail label write is attempted; an existing `aop_id` is never overwritten.
- [ ] Add [apps/mail/modules/inbox/**tests**/inbox-row-chips.test.tsx](apps/mail/modules/inbox/__tests__/inbox-row-chips.test.tsx): a row renders its AOP badge and user labels; `INBOX` and `STARRED` never appear as chips.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/crm/__test__/channel-conversation-aop.test.ts`

### Phase 8 — The client composes the feed _(Track A)_

**Goal:** Every channel is fetched by its own route, cached under its own key, and merged in the store — so one list is assembled from four independently-refreshable sources instead of one server-rendered page.

This is the phase Phase 0 defers to. Phase 0 makes email's read shared; this makes the *composition* shared, and it is what lets a `mail.listThreads` refetch update the email rows of the unified list without refetching Slack, LinkedIn and WhatsApp alongside them. `assembleInboxFeed` is not deleted — it is **split**: the per-source fetchers become individually addressable and `mergeAndPage` ([feed-core.ts:64](apps/server/src/services/inbox/feed-core.ts)) moves to a store selector. Both are already pure, which is what makes the split cheap.

**The two constraints, answered.**

- **Paging four cursors.** Independent infinite queries cannot simply be concatenated: a `fetchNextPage` on Slack would inject rows into the middle of a list the rep has already scrolled past. The merge selector therefore renders only down to a **watermark** — the NEWEST of the per-channel tails, taken over the channels that can still be paged. The invariant: a channel that is not exhausted only ever returns items strictly older than its own oldest loaded item, so an item is safe exactly when it is newer than or equal to every such tail. Everything older is held, not dropped. `loadMore` fetches the next page of whichever channels are *setting* the watermark, so the rendered list only ever grows downward and never reorders above the fold.
- **Scope the client must not hold.** `conversationFilter` stays server-side and stays unsent — the reason it is resolved from `inboxId` rather than accepted off the wire ([mail.ts:1222](apps/server/src/trpc/routes/mail.ts)) does not weaken because the merge moved. Each per-channel route keeps taking `inboxId` and resolving its own filter through `getInboxFeedScope`, exactly as `mail.listThreads` already does. The client is told only **which channels participate**, never what the rule is.

- [x] Add `inbox.getFeedScope({ inboxId, labelIds })` returning `{ channels, mailOnly }`, with `conversationFilter` **stripped at the router** by a `.strict()` output schema rather than by discipline. `channels` is the RESOLVED participation set, not the record's raw opt-in — see the note below.
- [x] Add `inbox.listChannelItems({ channel, inboxId, compiledQuery, cursor, unreadOnly, linkedOnly })` over the existing sources. It calls feed.ts's `fetchLinkedinItems` / `fetchWhatsappItems` / `fetchSlackItems` rather than the container readers underneath them, so the `cedar-channel-container-reads` kill switch still governs this reader too.
- [x] Email uses `mail.listThreads` directly — no email branch in `listChannelItems`. `emailThreadToItem` (now [feed-merge.ts](apps/server/src/services/inbox/feed-merge.ts)) already takes that route's exact output shape, so it moves to the client unchanged and email rows keep seeding `threadSlice` through `batchPopulateThreadMetadata`.
- [x] Rework `inboxSlice` ([inboxSlice.ts](apps/mail/modules/inbox/store/inboxSlice.ts)) from one server-ordered `inboxOrder` into per-channel item sets plus a `selectMergedFeed` selector that runs `compareItemsDesc` and the watermark. `inboxSelectionId` and the shared `bulkSelected` are unchanged — the unified id is what they already key on.
- [x] Replace `useInboxItems` ([use-inbox-items.ts](apps/mail/modules/inbox/hooks/use-inbox-items.ts)) with one query per participating channel, each `enabled` on the scope's channel list, and derive `hasNextPage` from the watermark-setting channels rather than from a single server cursor.
- [x] Delete `assembleInboxFeed`'s merge half and `inbox.listItems` once the surfaces are moved; keep the source functions, which are now the routes' bodies. **Done in a follow-up change:** `assembleInboxFeed`, the email source under it (`fetchEmailItems` and its folder→query mapping) and `resolveEmailLabelTerms` are gone with the route; `modules/inbox/types.ts` now takes `InboxItem` straight from `@zero/server/inbox/types` rather than inferring it from a router output, and `compose-feed` became SELF-verifying instead of diffing against the retired path.

**Tests:**

- [x] Add [apps/mail/tests/modules/inbox/mergeWatermark.test.ts](apps/mail/tests/modules/inbox/mergeWatermark.test.ts) — the mail app runs **jest** over `apps/mail/tests/`, not colocated vitest: the merged order matches the server's for the same inputs; nothing below the watermark renders; paging one channel never reorders or duplicates rows already on screen; a channel returning zero items does not stall the watermark.
- [x] Add a scope test asserting `inbox.getFeedScope` **never** serializes a `conversationFilter` — enforced by the output schema, asserted on the key set, in [inbox-channel-routes.test.ts](apps/server/src/trpc/routes/__tests__/inbox-channel-routes.test.ts).
- [x] Prove it headlessly instead of in a unit test: `mail-admin compose-feed` runs the real composition over HTTP as the operator and diffs it against `inbox.listItems`. The gate is ASYMMETRIC — see the note below.
- [x] `timeout 300 pnpm --filter @zero/mail run test -- tests/modules/inbox`, plus the watermark invariant in [feed-merge.test.ts](apps/server/src/services/inbox/__test__/feed-merge.test.ts).

**As built — four things the plan did not anticipate.**

1. **`getFeedScope` returns the RESOLVED participation set, and the rule is now shared.** The plan had it return the inbox record's `channels`, which is a split's OPT-IN and is empty for the plain unibox. A client reading that as "which channels participate" queries email alone — `compose-feed` rendered 25 rows, every one of them email. The rule (`assembleInboxFeed`'s `want()`) moved into [feed-scope.ts](apps/server/src/services/inbox/feed-scope.ts) and is shared by `assembleInboxFeed`, `listChannelItems` and `getFeedScope`. The client re-derives nothing, which is the point: a browser copy would be a fourth copy of a rule whose only failure mode is the copies disagreeing, and it cannot see the inputs anyway.
2. **The parity gate is asymmetric, because the old path is lossy.** `assembleInboxFeed` reads the mirror through `listThreadsFromDb`; the client reads email through `mail.listThreads`, which is the router. On the operator's live inbox a 20-row `inbox.listItems` page carried 5 email rows and omitted **9 threads newer than chat rows it did include**. So the gate is "no server row may vanish, and the client must be correctly sorted" — not index equality, which would fail a correct client against a broken baseline. **Phase 8 therefore closes Phase 0's email gap for the unified feed as a side effect.**
3. **`setChannelFeeds` writes the whole map, not one channel.** `setInboxFeed` reconciled `focusedIndex` against `selectedThreadId`; with four queries settling independently a per-channel write would run that reconciliation once per settle and jitter the keyboard cursor, and would strand a departed channel's rows when the badge changes.
4. **`linkedOnly` is a route parameter.** `assembleInboxFeed` passes `conversationLinkedOnly: input.channel === 'all'`. Hard-coding it to `true` in the route silently emptied the dedicated Slack/WhatsApp tabs of unlinked containers — the one place every container is meant to be visible.

**Fixed on retirement, and two bugs deeper than expected.** `mergeAndPage` truncated a single-source feed at page two, because the sources resume at `<= cursorSortedAt` and the `limit + 1` that would prove "more remains" is spent re-reading the boundary row. `listChannelItems` carried a local correction for it; that correction now lives in `mergeAndPage` itself as a required `sourcesSaturated` argument, so it is right for every caller and cannot be forgotten by a new one.

Retargeting the real-DB tests onto the per-channel route then surfaced a SECOND hole, which the old server-merged feed had hidden the same way: the sources ordered by `last_message_at` alone while the pager resolves an equal-`sortedAt` band by comparing item ids, so a `LIMIT` landing mid-band took an arbitrary subset of it and the skipped rows sorted above the cursor the page then minted — gone for good. Measured on the operator's LinkedIn feed, where a backfill left dozens of chats sharing a timestamp to the millisecond: three rows vanished from a paged walk that a single wide read returned. Every source's `ORDER BY` now carries the item-id tiebreak, `COLLATE "C"` so byte order matches the comparator's.

### Phase 9 — The LinkedIn admission classifier _(Track B — deferred)_

**Goal:** A brand-new LinkedIn chat that the deterministic rules cannot judge gets one cheap model call, once **per chat**, and the answer is recorded either way.

Nothing here touches email or Slack. Email is triaged by Gmail and read back through the Important tab; Slack's admission decision is Phase 4's link gate. If this phase never ships, Phase 5's rules still admit ~115 of 316 chats and the rest simply stay out — which is the current behaviour, not a regression.

- [ ] Add `admission_verdict` / `admission_reason` / `admission_model` / `admission_decided_at` + the CHECK and `idx_ccu_undecided` to `channel_container_users`; mirror in [outbound-schema.ts:1484](apps/server/src/db/outbound-schema.ts).
- [ ] Add [apps/server/src/services/inbox/linkedin-admission.ts](apps/server/src/services/inbox/linkedin-admission.ts) with `classifyLinkedinAdmission`, called from the LinkedIn drain **after** `linkedinInboxTriggers` (Phase 5) has declined the chat — never before, so a reciprocal or known-contact chat costs no model call.
- [ ] Write the verdict on **both** outcomes; `admit` additionally calls `applyInboxLabel(…, reason: 'classified')`. A container with `admission_decided_at` set is never re-classified, and a rep's archive is never overwritten by a later `admit`.
- [ ] Refuse to classify what cannot be judged: InMail (matching the feed's existing exclusion) and the 43 chats with no message bodies, which stay `NULL` until `channels.catchUp` or `processChannelReconcile` fills them.
- [ ] Emit `createStructuredLog('info','inbox.admission', { containerId, verdict, reason, model, ruleDecided })` so a suppressed chat is answerable from Axiom — a wrong `hold` is invisible in the product by construction.

**Tests:**

- [ ] Add [apps/server/src/services/inbox/**test**/linkedin-admission.test.ts](apps/server/src/services/inbox/__test__/linkedin-admission.test.ts): a reciprocal chat is admitted by rule with **no model call**; a cold one-directional chat reaches the model once and never again; a recorded `hold` survives a second drain; InMail and a body-less chat are never classified; an archived container is not re-admitted.
- [ ] Read-only DB assertion: the undecided set (`idx_ccu_undecided`, LinkedIn only) is bounded by 316 − 115 rule-admitted − 43 body-less, so the model is called on the order of 150 chats per seat, once each.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox/__test__/linkedin-admission.test.ts`

### Phase 10 — Pick the model by eval _(Track B — deferred)_

**Goal:** The cheapest model that matches a `sonnet` control on a labelled set is the one that runs — established by measurement, not by assertion.

[taxonomy-model.ts](apps/server/src/services/crm/taxonomy-model.ts) already solved this exact problem for taxonomy extraction: a keyed `MODEL_SPECS` map, one registered Understudy workload per candidate so spend and latency are attributable per model, and a `direct` escape hatch for candidates with no workload yet. Reuse the shape rather than write a second resolver.

- [ ] Add `resolveAdmissionModel` alongside `resolveTaxonomyModel` ([taxonomy-model.ts:83](apps/server/src/services/crm/taxonomy-model.ts)), with `glm-5.2` and `kimi-k2.6` (open-weight, via the Understudy gateway), `haiku`, and `sonnet` as the control; register an `inbox-admission-<key>` workload for each before any production routing.
- [ ] Hand-label a set from the measured population — the 115 rule-admitted chats are free positives; draw the negatives from the cold one-directional remainder.
- [ ] Add [apps/server/src/scripts/eval-linkedin-admission.ts](apps/server/src/scripts/eval-linkedin-admission.ts) reporting agreement with the control, per-candidate cost and latency, and **false `hold` rate weighted above false `admit`** — a suppressed real conversation is the expensive error; an extra row in the inbox is one archive.
- [ ] Ship the cheapest candidate that clears the bar; keep the key overridable per org so a regression is a setting change, not a deploy.

**Tests:**

- [ ] Add [apps/server/src/services/inbox/**test**/admission-model.test.ts](apps/server/src/services/inbox/__test__/admission-model.test.ts): every key resolves to a distinct workload; an unregistered key fails loudly rather than silently falling back to the control.
- [ ] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox/__test__/admission-model.test.ts`

## 5) Verification

- `timeout 300 pnpm --filter @zero/server exec vitest run src/services/inbox src/services/crm` green.
- `pnpm --filter @zero/server channel-health` reports `ok` — Phases 3–6 read the very tables it audits.
- Read-only DB assertions after each phase:
  - Phase 0: for one compiled query and page depth, the email id set from `assembleInboxFeed({ channel: 'email' })` and from `mail.listThreads` is identical — asserted on a seat holding at least one draft-only thread, which is the case that separates them today.
  - Phase 1: `INBOX` row count per user equals the feed's visible item count; `STARRED` equals `cedar_inbox_item_state`'s starred rows.
  - Phase 2: the item id set from `assembleInboxFeed` is unchanged across the predicate swap; on one of the 213 two-member Slack containers, one member's archive leaves the other's `INBOX` row intact.
  - Phase 3: an archived container that receives a message has an `INBOX` row with `applied_by = 'reply'`; a muted one has none.
  - Phase 4: `count(*) FROM crm_slack_messages WHERE mentioned_slack_user_ids <> '{}'` tracks the 9,238 rows carrying a token.
  - Phase 5: the containers labelled `applied_by = 'reciprocal'` match the 115 two-way-open chats.
  - Phase 6: `count(*) FROM crm_events WHERE event_type = 'linkedin' AND conversation_id IS NOT NULL` is above 0 for the first time.
  - Phase 7: every channel item on an AOP-bearing conversation reports an `aopId`; no system label renders as a chip.