V1 Web PWA · Journey

Journey: Messages quiet hours and channel verification

A source-reconciled journey record for V1 Web PWA — shipped behavior, state boundaries, failure modes, and the automation evidence available today.

automated · 9 specs verdict: partial· 2026-05-29
9sections12 minread

On this page
Journey at a glance
ActorsSigned-in customerQuiet-hours customerSafety-critical recipientTelegram user
Automation9 E2E spec(s) · verdict: partial· 2026-05-29

Journey flow#

Generated from the authored steps below — click a node to jump to that section.

flowchart TD s1["1. Find the mutable controls"] s2["2. Request an email, SMS, or WhatsApp binding"] s3["3. Verify ownership"] s4["4. Treat Telegram as a separate seam"] s5["5. Configure quiet hours"] s6["6. Configure the domain matrix"] s7["7. Dispatch inside the quiet window"] s8["8. Replay after the window"] s9["9. Remove a profile binding"] s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9 click s1 href "#1-find-the-mutable-controls" click s2 href "#2-request-an-email-sms-or-whatsapp-binding" click s3 href "#3-verify-ownership" click s4 href "#4-treat-telegram-as-a-separate-seam" click s5 href "#5-configure-quiet-hours" click s6 href "#6-configure-the-domain-matrix" click s7 href "#7-dispatch-inside-the-quiet-window" click s8 href "#8-replay-after-the-window" click s9 href "#9-remove-a-profile-binding" s9 --> v(["verdict: partial"]) click v href "../results/messages-quiet-hours-and-channel-binding.html" style v stroke:#d6a531,color:#d6a531

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.
  • /profile can reach GET /v1/profile/channels and 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 durable notificationPreferencesStore. 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/notifications is a read-only projection with a Change delivery settings link.
  • /profile?path=notifications opens the quiet-hours and notification matrix.
  • The Notification channels rows live directly on /profile inside ProfileSettingsPanel, 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 pending binding. The plaintext code is never persisted.
  • In non-production, the route returns a preview code so the local flow can be completed. In production it reports delivery: 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-widget verifies the Telegram HMAC payload and creates a non-sensitive Telegram-derived iris:<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 /unlink is 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/preferences master flag and the durable /v1/notifications/preferences window. 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 | insight and inApp | push | emailDigest selections.
  • PATCH /v1/notifications/preferences/domains/:domainId merges 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/dispatch reads 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 support or incident message bypasses quiet hours and enters the durable inbox through an awaited mutation.

8. Replay after the window#

  • Call POST /v1/admin/customer-messages/replay-due at or after the next eligible time.
  • The BFF appends queued → sent → delivered receipts 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-revoke phrase.
  • The browser sends the same phrase in x-oshun-channel-revoke-step-up; the BFF returns 428 when it is missing for a verified binding.
  • A successful delete awaits an unbound candidate 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 verified profile 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.ts covers the read-only summary, editor handoff, mobile layout, offline replay, and fail-closed projection.
  • apps/oshun/web/e2e/profile-notification-preferences.spec.ts and apps/oshun/web/e2e/notifications-preferences-api.spec.ts cover quiet hours, normalized active days, digest settings, the six-domain matrix, isolation, and auth/validation gates.
  • apps/oshun/web/e2e/profile-channel-bindings.spec.ts covers preview-code request/verify/reload/remove, wrong-code persistence, 428 revoke 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.ts and apps/oshun/web/e2e/telegram-binding-bff.spec.ts cover 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.ts covers 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#

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.ts and session-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.ts and user-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.