---
status: walked + e2e-covered
coverage_depth: deep
last_walked:
  '2026-07-18 by Codex — reconciled to the current durable message store,
  dispatch policy seam, /messages browser flow, notification-drawer projection,
  and real-dev-infra Playwright coverage.'
specs:
  - apps/oshun/web/e2e/messages-center.spec.ts
  - apps/oshun/web/e2e/messages-center-bff.spec.ts
---

# Journey: Messages center dispatch, follow, and subscription control

The shipped journey begins at the admin/system dispatch seam, not at an email,
SMS, or push provider. A message enters the BFF's required durable
customer-message snapshot, appears in `/messages` and the shell notification
drawer, records a deep-link click, can be marked read or archived, and honors a
channel × category unsubscribe until the user re-subscribes. Delayed work also
survives restart, but an admin call still has to invoke replay.

## Personas

- **Signed-in recipient** — uses `/messages` as the in-app history and acts on a
  domain deep link.
- **Notification-drawer user** — expects the shell badge and drawer to agree
  with the full inbox.
- **Subscription-conscious user** — disables one channel/category pair and later
  restores it without affecting other pairs.
- **Messaging operator or producer** — calls the admin-scoped dispatch seam on
  behalf of an upstream domain event.

## Pre-conditions

- The recipient has an authenticated customer session.
- The producer has `admin:*` or `admin:messaging` and a valid message payload.
- Deployable BFF startup requires and hydrates the customer message-center
  snapshot before traffic. Messages, delayed jobs, receipts, suppressions,
  revisions, and deletion fences share that store; acknowledged mutations fail
  closed if its sink cannot commit.
- External email, SMS, voice, and OS-push transports are outside this journey.
  The request's `channel` labels the intended channel but does not prove that a
  provider delivered it.

## Steps

### 1. Dispatch into the message-center seam

- Call `POST /v1/admin/customer-messages/dispatch` with recipient, channel,
  category, priority, subject, body, and registered deep links.
- The route applies active crisis-frame and quiet-hours decisions before the
  message store.
- The message-center engine rejects duplicate ids and a currently suppressed
  channel/category pair.
- A normal accepted dispatch durably commits a message with an initial `queued`
  receipt before returning. It does not call an external channel adapter.

### 2. Open the in-app inbox

- Navigate to `/messages`; the client fetches `/api/messages/inbox`, which
  proxies the authenticated BFF snapshot.
- The header reports complete total and unread counts. The list contains the
  newest 25 rows by default, while an explicit lower limit returns a smaller
  newest-first window.
- Filters, search, snooze display state, and grouping are computed by the shell
  model in the browser. Snooze is lost on reload. Server mark-read, archive,
  unsubscribe, and re-subscribe remain separate mutations.

### 3. Inspect receipts honestly

- Expand **Delivery receipts** on a row.
- A direct dispatch is guaranteed to start with `queued`; it must not be
  described as externally delivered on that evidence alone.
- The explicit quiet-hours replay path appends `sent` and `delivered` markers
  when it replays the queued message. Those markers are produced by the BFF
  replay boundary, not a provider callback.
- Following a registered deep link appends `clicked`.

### 4. Follow a registered deep link

- Click a row link. The browser uses
  `/api/messages/:messageId/follow?url=<encoded-url>`.
- The BFF verifies that the requested URL is one of that recipient's registered
  links, records `clicked`, and resolves the internal target.
- A link owned by another user or absent from the message returns `404` without
  mutating the owner's row.
- Follow does not mark the message read; the unread chip remains until the
  explicit read action.

### 5. Mark read and reconcile the shell

- Click **Mark read**. The browser posts `{ messageId }` to
  `/api/messages/mark-read`, then refreshes the inbox.
- The unread count decrements and a shell inbox-change event prompts the badge
  to reconcile.
- The notification drawer projects the same customer-message rows through
  `/v1/notifications`; drawer `mark_read` writes back through
  `/v1/notifications-write` to the same store.
- When its inbox request succeeds, the shell badge shows customer-message unread
  count rather than a combined count of every domain-feed row also visible in
  the drawer.

### 6. Archive a row

- Click **Archive**. The browser posts to `/api/messages/archive`, refreshes,
  and removes the archived row from `latestMessages`.
- Repeating archive is idempotent and preserves the existing archive timestamp.

### 7. Unsubscribe one pair

- In **Manage notifications**, choose a channel and category, then click
  **Unsubscribe**.
- `/api/messages/unsubscribe` durably records a suppression policy for that
  exact user, channel, and category.
- Success returns the component to idle without a success toast or inbox
  refresh. The active policy is not hydrated back into the page after reload;
  its local topic state returns to defaults even while the BFF still suppresses
  that pair. Errors use the shared action alert.
- Row-level topic controls block categories labeled required, while the general
  manage form can still submit those categories to the current server. Voice is
  also collapsed to webhook in the browser display model.

### 8. Prove suppression and isolation

- Re-dispatch the same channel/category pair. The BFF returns `422 suppressed`
  and creates no inbox row.
- Dispatch a different channel or category to prove the policy does not widen
  unintentionally.
- Another user's inbox, follow, mark-read, and archive requests cannot observe
  or mutate the recipient's message.

### 9. Re-subscribe and restore dispatch

- Click **Re-subscribe** with the same channel/category values.
- The suppression policy clears through an awaited durable mutation.
- A subsequent same-pair dispatch returns `201`; after refresh, the new row
  appears in both `/messages` and the notification projection.

## Post-conditions

- The recipient can inspect, follow, mark read, and archive acknowledged
  messages after restart.
- `/messages`, the shell badge, and the notification drawer converge on the same
  customer-message store.
- One exact channel/category opt-out suppresses subsequent dispatches until
  re-subscribe.
- No outcome in this journey proves delivery by email, SMS, voice, or an OS push
  provider.
- Account-level continuity export includes the user's message-center partition;
  account deletion erases and fences that exact subject without removing an
  adjacent user's state.

## Failure modes

- **Missing auth or scope** — customer reads/mutations return `401`; producer
  dispatch without messaging-admin scope returns `403`.
- **Malformed payload** — returns `400` with validation issues.
- **Duplicate message id** — returns `409` without a second row.
- **Unsubscribed pair** — returns `422 suppressed` and writes no row.
- **Active non-essential crisis frame** — returns
  `202 dispatch.suppressed.crisis_frame`; no inbox row is written.
- **Quiet hours** — returns `202 dispatch.delayed.quiet_hours` only after
  storing a durable delayed dispatch; a separate replay call is required.
- **Unknown or cross-user link/message** — returns `404` without leaking
  ownership.
- **Inbox load or action request fails** — the component keeps the last snapshot
  and exposes an alert; it does not claim the mutation succeeded.
- **Snapshot persistence failure** — the route returns `503` and keeps the last
  committed state; failed replay leaves the delayed job queued.
- **Erased subject** — stale sends, delayed schedules, and recipient mutations
  return a conflict instead of resurrecting deleted continuity state.
- **Replay never invoked** — a due durable job remains queued indefinitely;
  there is no automatic timer or distributed claim lease.

## E2E coverage

- [`apps/oshun/web/e2e/messages-center-bff.spec.ts`](../../apps/oshun/web/e2e/messages-center-bff.spec.ts)
  drives real HTTP dispatch, inbox, follow, mark-read, archive idempotency,
  unsubscribe/suppression/re-subscribe, bounded snapshots, dedupe, privacy
  isolation, quiet-hours replay, crisis suppression, and auth/validation gates.
- [`apps/oshun/web/e2e/messages-center.spec.ts`](../../apps/oshun/web/e2e/messages-center.spec.ts)
  drives the live browser and Next proxies: BFF-seeded rows, receipts, follow,
  shell badge and drawer convergence, mark-read, archive, unsubscribe,
  suppressed re-dispatch, re-subscribe, restored dispatch, and component error
  states.
- The focused message-center durability unit and gated real-Postgres suites
  cover candidate-before-publish rollback, restart restoration, exact-subject
  erasure fences, adjacent-user preservation, send/delete ordering, and replay
  failures that retain delayed work.
- Provider-side transport, notification permission, OS tray behavior, and an
  upstream domain event producing the dispatch are not exercised.

## Per-view files touched

- [`customer/02-home-discovery/messages.md`](../customer/02-home-discovery/messages.md)
  — inbox and subscription controls.
- [`shell/05-notifications.md`](../shell/05-notifications.md) — badge and drawer
  projection.
- [`shell/03-pwa-behavior.md`](../shell/03-pwa-behavior.md) — generic service
  worker push behavior, outside the proven dispatch loop.
- The linked Tara, Veritas, Nyx, or Nisaba surface selected by each message's
  registered deep link.

## Cross-references

- `apps/oshun/bff/src/routes/customer-message-center.ts` — producer and
  recipient HTTP seams.
- `apps/oshun/bff/src/customer/customer-message-center-store.ts` — durable
  snapshot wrapper, delayed queue, ordered mutations, and deletion fence.
- `apps/oshun/bff/src/data-export/bundle-builder.ts` — continuity export of the
  exact user's message-center partition.
- `libs/oshun/customer-message-center/src/message-center.ts` — receipt,
  unsubscribe, inbox, and deep-link rules.
- `apps/oshun/web/src/components/MessageCenter.tsx` — browser state machine.
- [`messages-quiet-hours-and-channel-binding.md`](./messages-quiet-hours-and-channel-binding.md)
  — preference and channel-binding boundary.

## Open questions

- [ ] Connect the dispatch seam to real provider adapters and ingest provider
      delivery/bounce/open callbacks without fabricating receipt stages.
- [ ] Add an automatic scheduler with a safe cross-instance claim/lease for the
      already durable quiet-hours jobs.
- [ ] Define cross-instance snapshot writer arbitration rather than assuming a
      database-backed snapshot is itself a multi-writer transaction log.
- [ ] Define canonical audit-event names and persist them; this route currently
      exposes decisions and receipts, not the previously documented audit
      sequence.
- [ ] Add explicit pagination or cursor navigation for histories beyond the
      newest snapshot window.
- [ ] Reconcile the customer-message-only badge with the drawer's combined
      domain-feed projection.
- [ ] Hydrate active suppressions into the page and align required-topic and
      voice-channel semantics across both controls and the BFF contract.
