Journey at a glance
ActorsSigned-in recipientNotification-drawer userSubscription-conscious userMessaging operator or producer
Systems exercised
messages.md03-pwa-behavior.md05-notifications.mdAutomation2 E2E spec(s) · verdict: pass· 2026-07-02
Journey flow#
Generated from the authored steps below — click a node to jump to that section.
flowchart TD
s1["1. Dispatch into the message-center seam"]
s2["2. Open the in-app inbox"]
s3["3. Inspect receipts honestly"]
s4["4. Follow a registered deep link"]
s5["5. Mark read and reconcile the shell"]
s6["6. Archive a row"]
s7["7. Unsubscribe one pair"]
s8["8. Prove suppression and isolation"]
s9["9. Re-subscribe and restore dispatch"]
s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9
click s1 href "#1-dispatch-into-the-message-center-seam"
click s2 href "#2-open-the-in-app-inbox"
click s3 href "#3-inspect-receipts-honestly"
click s4 href "#4-follow-a-registered-deep-link"
click s5 href "#5-mark-read-and-reconcile-the-shell"
click s6 href "#6-archive-a-row"
click s7 href "#7-unsubscribe-one-pair"
click s8 href "#8-prove-suppression-and-isolation"
click s9 href "#9-re-subscribe-and-restore-dispatch"
s9 --> v(["verdict: pass"])
click v href "../results/messages-center-deliver-and-unsubscribe.html"
style v stroke:#3fb950,color:#3fb950
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
/messagesas 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:*oradmin:messagingand 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
channellabels 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/dispatchwith 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
queuedreceipt 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
sentanddeliveredmarkers 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
404without 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; drawermark_readwrites back through/v1/notifications-writeto 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 fromlatestMessages. - 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/unsubscribedurably 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 suppressedand 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/messagesand 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 returns403. - Malformed payload — returns
400with validation issues. - Duplicate message id — returns
409without a second row. - Unsubscribed pair — returns
422 suppressedand 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_hoursonly after storing a durable delayed dispatch; a separate replay call is required. - Unknown or cross-user link/message — returns
404without 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
503and 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.tsdrives 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.tsdrives 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— inbox and subscription controls.shell/05-notifications.md— badge and drawer projection.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— 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.