# Journey: Messages quiet hours and channel verification

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`](../../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`](../../apps/oshun/web/e2e/profile-notification-preferences.spec.ts)
  and
  [`apps/oshun/web/e2e/notifications-preferences-api.spec.ts`](../../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`](../../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`](../../apps/oshun/web/e2e/telegram-webhook-delivery.spec.ts)
  and
  [`apps/oshun/web/e2e/telegram-binding-bff.spec.ts`](../../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`](../../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

- [`customer/09-account/profile.md`](../customer/09-account/profile.md) —
  channel bindings and mutable preferences.
- [`customer/09-account/profile-notifications.md`](../customer/09-account/profile-notifications.md)
  — read-only summary.
- [`shell/05-notifications.md`](../shell/05-notifications.md) — quiet-hours and
  modality model.
- [`customer/02-home-discovery/messages.md`](../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.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`](./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.
