messages.mdprofile-notifications.mdprofile.md05-notifications.mdJourney flow#
Generated from the authored steps below — click a node to jump to that section.
This journey joins two shipped but distinct controls: durable profile bindings for email, SMS, and WhatsApp addresses, and a split notification-preference model whose durable consumer profile owns the master quiet-hours flag while a second durable snapshot owns the custom window and domain matrix. The editor writes those authorities through separate requests, and the in-app message dispatch seam consults the preference record before placing delayed work in a required durable snapshot. Real provider transports exist in the separate, snapshot-backed reminder subsystem, but the profile-binding handoff remains incomplete: production verification codes are not sent, automatic producers choose push rather than the copied email/SMS/WhatsApp fields, and manually scheduled external reminders carry a separately supplied recipient.
Personas#
- Signed-in customer — verifies a contact channel and later removes it.
- Quiet-hours customer — chooses a timezone, window, and weekday mask for non-essential messages.
- Safety-critical recipient — still expects incident messages during the quiet window.
- Telegram user — uses a separate Login Widget identity-binding flow, not the profile email/SMS/WhatsApp row.
Pre-conditions#
- The customer has an authenticated profile session.
/profilecan reachGET /v1/profile/channelsand notification-preference routes.- Deployable BFF startup requires and hydrates the channel-binding snapshot before traffic. Bind, verify, and unbind acknowledge only after their ordered candidate commits; snapshot failure preserves the last committed binding and verification-attempt count.
- The durable consumer profile owns
quietHoursEnabled. The custom quiet-hours window, digest, domain, and persona records live in a separately durablenotificationPreferencesStore. Production startup requires and hydrates both notification snapshots; preference/read-state writes fail closed when the sink cannot commit. The normal editor writes the two quiet-hours authorities sequentially, not atomically. - Production BFF startup requires and wires the reminder snapshot before accepting reminder mutations. Schedules, delivery ownership, in-app inbox rows, and subject-erasure fences persist together. When the optional worker is enabled, shared Redis supplies an expiring cross-replica cycle lease; a single-process deployment uses the explicit local lease.
- The message-center dispatch seam is available for policy verification. It is required and hydrated before production traffic; messages, receipts, suppressions, delayed work, and deletion fences commit together. It is not an external email, SMS, WhatsApp, or Telegram transport, and no automatic worker claims its delayed jobs.
- Production also requires and hydrates the Telegram user-state snapshot. Captures, voice preference, delivery suppression, link completion, Login Widget bind, and webhook unlink acknowledge only after their ordered candidate commits. Profile and saved-content deletion use independent exact-subject Telegram fences.
Steps#
1. Find the mutable controls#
/profile/notificationsis a read-only projection with a Change delivery settings link./profile?path=notificationsopens the quiet-hours and notification matrix.- The Notification channels rows live directly on
/profileinsideProfileSettingsPanel, not in/messages.
2. Request an email, SMS, or WhatsApp binding#
- Enter an address in one of the three profile rows and click Send code.
- The browser posts to
POST /v1/profile/channels/:channel/bind. - The BFF stores a salted code hash and a
pendingbinding. The plaintext code is never persisted. - In non-production, the route returns a
previewcode so the local flow can be completed. In production it reportsdelivery: missing-config; no channel transport currently sends the code.
3. Verify ownership#
- Submit the code to
POST /v1/profile/channels/:channel/verify. - The domain reducer handles verified, wrong-code, expired, locked, and not-pending outcomes. A wrong code leaves the binding pending.
- A verified binding is returned by the secret-free list endpoint and survives BFF restart. Pending salted code hashes also survive, so a deploy does not force the member to request a new code.
- The binding is an ownership record. The current profile flow does not write a separate Iris consent record, tenant channel allowlist decision, capability profile, or provider opt-in receipt.
- The reminder composition root copies verified email/SMS/WhatsApp addresses
into automatic reminder recipients, but every current automatic producer
selects
push; those copied fields are therefore not used by the provider router. An admin-scheduled external reminder supplies its recipient separately and is gated only on a verified row for that channel. Its schedule is durably committed before acknowledgment, but persistence does not validate address equality.
4. Treat Telegram as a separate seam#
/telegram/login-widgetverifies the Telegram HMAC payload and creates a non-sensitive Telegram-derivediris:<telegram-id>identity binding only after the Telegram snapshot commits.- Tampered or missing hashes fail closed, and the Telegram action lookup marks sensitive actions as requiring web step-up.
- This flow does not create one of the profile channel-binding rows and does not
authenticate a current profile member. The authenticated account deep-link can
issue a durable token that the webhook durably completes for that profile
subject; webhook
/unlinkis durable too. The cited browser cases still do not prove public-identity/profile convergence, a profile-inventory row, bot provider delivery, or revocation from/profile.
5. Configure quiet hours#
- In
/profile?path=notifications, enable quiet hours and select start, end, timezone, and active weekdays. - The browser patches both the durable
/v1/preferencesmaster flag and the durable/v1/notifications/preferenceswindow. A following GET returns the normalized weekday mask and current window. - The custom window survives BFF restart through a required write-before-publish snapshot, with revision and erasure-fence coverage. The master flag lives in a different snapshot, so a failed second request or direct single-route caller can still make the two durable authorities drift.
6. Configure the domain matrix#
- Each launch domain durably persists enabled/priority-only state plus
reminder | update | event | insightandinApp | push | emailDigestselections. PATCH /v1/notifications/preferences/domains/:domainIdmerges only that domain and enforces domain scope.- This matrix does not expose per-binding SMS/WhatsApp/Telegram categories, and the admin customer-message dispatch route does not consult the whole domain matrix.
7. Dispatch inside the quiet window#
- A non-essential message sent through
POST /v1/admin/customer-messages/dispatchreads the recipient's quiet-hours preference. - Inside the active window it returns
202 dispatch.delayed.quiet_hours, after committing a durable delayed job, and creates no inbox row yet. - A safety-critical
supportorincidentmessage bypasses quiet hours and enters the durable inbox through an awaited mutation.
8. Replay after the window#
- Call
POST /v1/admin/customer-messages/replay-dueat or after the next eligible time. - The BFF appends
queued → sent → deliveredreceipts and writes the inbox row. - If an active crisis frame now suppresses the recipient, replay holds the job instead.
- Successful replay commits the message, BFF-authored receipts, and delayed-row removal together. Snapshot failure keeps the job queued; gate exceptions and semantic failures become durable holds/failures. This remains an explicit admin boundary with no background timer, distributed claim lease, or external provider acknowledgment.
9. Remove a profile binding#
- Click Remove, then type the literal
channel-revokephrase. - The browser sends the same phrase in
x-oshun-channel-revoke-step-up; the BFF returns428when it is missing for a verified binding. - A successful delete awaits an
unboundcandidate snapshot; persistence failure returns an error and leaves the last committed row visible. - The phrase is a static confirmation string, not a primary-credential step-up. Account deletion can durably erase and fence profile bindings, custom preferences, device sessions/tokens, notification read state, reminder schedules/delivery ids/inbox rows, and the exact message-center history/ suppressions/delayed partition. It also erases/fences the exact subject's Telegram profile and capture partitions. Profile, saved-content, and session exports include the corresponding secret-safe records. This profile-channel route does not invoke the other erasers or cancel provider subscriptions, other bindings, Telegram state, device tokens, Mini App sessions, or cached chat state.
Post-conditions#
- Email, SMS, or WhatsApp can have a secret-free
verifiedprofile binding after the non-production preview-code round trip. - The durable master switch and durable custom quiet-hours/domain preferences are readable through one projection, though they are not one atomic record.
- The message-center dispatch seam delays a non-essential message, bypasses a safety-critical one, and can explicitly replay a due delayed job.
- The separate reminder subsystem has real credential-gated provider transports plus write-through schedule/inbox/ledger durability and account-level erasure, but delivery to the stored profile-bound email/SMS/WhatsApp address, provider opt-in, distributed worker ownership, and channel-unbind revocation have not been proven.
- Deployable startup requires database-backed profile bindings, notification preferences, message-center continuity, and Telegram user state. Automatic delayed-job claiming, cross-identity convergence, and external provider delivery remain outside the journey.
Failure modes#
- Production bind request — records a pending binding but returns
missing-config; without a transport, the user cannot receive the code. - Wrong, expired, or repeatedly wrong code — never produces
verified. - Missing revoke phrase — verified-binding delete returns
428 step_up_required. - Static phrase replay — the current revoke guard is not strong authentication.
- Partial quiet-hours write — the master flag and custom window persist in separate stores; one request can commit while the other fails.
- Binding snapshot failure — bind/verify/unbind returns a service error and does not publish the candidate address, attempt, or removal.
- Profile subject erased — stale bind/verify work is rejected rather than recreating a fenced member's binding after restart.
- Telegram snapshot failure — capture, preference, suppression, bind, completion, or unlink fails without publishing the candidate. A failed Telegram erasure leaves the prior record active rather than installing a fence that was not persisted.
- Telegram subject mismatch — deleting one authenticated profile subject
does not erase a distinct public Login Widget
iris:<telegram-id>subject. - Message-center persistence failure — dispatch/replay returns an error and preserves the last committed view; failed replay retains its durable delayed job.
- Replay never invoked — a due message remains queued indefinitely.
- Active crisis frame before replay — non-essential delivery stays held; incident priority can bypass.
- Domain matrix assumption — selecting a domain modality does not prove a newly bound SMS or WhatsApp target will be selected by this dispatch seam.
- Automatic reminder assumption — verified email/SMS/WhatsApp fields are
copied into automatic reminder recipients, but current producers choose
push, so those fields are not the selected delivery target. - Manual external reminder mismatch — the admin schedule payload supplies its own recipient; verification is checked by channel, not by equality with the stored address.
- Reminder lease outage — shared deployments fail a tick closed when Redis cannot grant the cycle lease; the schedule remains for a later tick.
E2E coverage#
apps/oshun/web/e2e/profile-notifications-index.spec.tscovers the read-only summary, editor handoff, mobile layout, offline replay, and fail-closed projection.apps/oshun/web/e2e/profile-notification-preferences.spec.tsandapps/oshun/web/e2e/notifications-preferences-api.spec.tscover quiet hours, normalized active days, digest settings, the six-domain matrix, isolation, and auth/validation gates.apps/oshun/web/e2e/profile-channel-bindings.spec.tscovers preview-code request/verify/reload/remove, wrong-code persistence,428revoke protection, mobile touch targets, and service-worker behavior.- The focused channel-binding unit suite covers verified/pending restart, write/attempt/deletion rollback, exact-subject fences, adjacent-user preservation, both mutation/deletion race orders, and missing-sink rejection; a one-case real-Postgres companion is environment-gated.
apps/oshun/web/e2e/telegram-webhook-delivery.spec.tsandapps/oshun/web/e2e/telegram-binding-bff.spec.tscover the separate Telegram HMAC and sensitive-action boundary. Only the webhook suite's final two Mini App HMAC-rejection cases directly belong to this journey; its other bot-delivery cases belong to the Telegram assistant journey.apps/oshun/web/e2e/messages-center-bff.spec.tscovers quiet-hours delay/replay, safety-critical bypass, and crisis-frame hold over real HTTP.- Focused message-center durability suites cover restart restoration, candidate-before-publish rollback, exact-subject erasure fences, adjacent-user preservation, send/delete ordering, and durable replay failure handling; the real-Postgres case is environment-gated.
- The focused six-case Telegram user-state suite covers capture/link rollback, restart, independent profile and saved-content fences, adjacent-user preservation, mutation/delete ordering, failed-erasure rollback, and required durability. Its one-case real-Postgres companion is environment-gated.
Per-view files touched#
customer/09-account/profile.md— channel bindings and mutable preferences.customer/09-account/profile-notifications.md— read-only summary.shell/05-notifications.md— quiet-hours and modality model.customer/02-home-discovery/messages.md— in-app dispatch result.
Cross-references#
apps/oshun/bff/src/routes/channel-bindings.ts— profile bind, verify, and remove routes.apps/oshun/bff/src/messaging-channels/channel-bindings-store.ts— durable binding snapshot, ordered mutations, revisions, and erasure fence.apps/oshun/bff/src/messaging-channels/channel-bindings-store.test.ts— 21 lifecycle and durability cases.apps/oshun/bff/src/messaging-channels/channel-bindings-durability.integration.spec.ts— gated real-Postgres restart and signed-profile erasure proof.apps/oshun/bff/src/data-export/bundle-builder.ts— profile, saved-content, and session export of bindings, Telegram profile/captures, and device tokens.apps/oshun/bff/src/data-deletion/profile-state-eraser.tsandsession-state-eraser.ts— exact-subject profile and session composition.apps/oshun/bff/src/telegram/user-state-store.ts— required Telegram snapshot, durable link/capture/preference/suppression mutations, export, and independent erasure fences.apps/oshun/bff/src/telegram/user-state-durability.spec.tsanduser-state-durability.integration.spec.ts— rollback, restart, ordering, export, and gated real-Postgres erasure proof.apps/oshun/bff/src/notifications/preferences-store.ts— durable custom preference snapshot, revisions, and erasure fence.apps/oshun/bff/src/notifications/state-durability.test.ts— persistence failure, restart, ordering, and exact-subject erasure proof.apps/oshun/bff/src/routes/customer-message-center.ts— quiet-hours and crisis decisions.apps/oshun/bff/src/customer/customer-message-center-store.ts— required durable message/receipt/suppression/delayed snapshot and erasure fence.apps/oshun/bff/src/data-export/bundle-builder.ts— exact-subject continuity export for the message-center partition.apps/oshun/bff/src/reminders/reminders-route.ts— split quiet-hours reads, verified-channel gate, write-through snapshot, erasure fence, automatic worker, and provider cycle.libs/oshun/messaging-channels/src/provider-config-env.ts— real credential-gated provider router outside the message-center path.messages-center-deliver-and-unsubscribe.md— inbox lifecycle after dispatch.
Open questions#
- Wire production code delivery for email, SMS, and WhatsApp, including provider receipts and retry policy.
- Replace the literal revoke phrase with genuine account step-up.
- Make the separately durable master flag and custom preferences one atomic customer intent, add a safe cross-instance claim for the already durable delayed jobs, and run an automatic replay worker.
- Connect domain/category preferences and verified bindings to explicit provider selection, address equality, and fallback rules.
- Add tenant allowlists, provider capability/residency disclosure, consent receipts, and full revocation cascades.
- Decide whether Telegram should appear in the same customer binding inventory or remain a distinct identity/channel surface.