# Messaging Channels

Messaging Channels is the V1 subsystem that takes an _outbound intent_ — a
ritual reminder, a grounded answer, a transactional receipt, a crisis hotline —
and decides **whether**, **on which channel**, **in which content variant**, and
**under whose consent and residency** it is allowed to reach a member, then
hands a normalized result back to the caller. It serves every customer-facing
domain that wants to leave the web app (Telegram chat and Mini Apps, WhatsApp,
email, push, SMS, Slack, Discord) plus the in-app message center. It sits
between the domain runtimes and the real provider APIs in the V1 architecture
set hubbed at [../ARCHITECTURE.md](../ARCHITECTURE.md). Product scope:
[`V1/features.md` § Messaging Channels and Conversational Surfaces](../features.md#messaging-channels-and-conversational-surfaces).

> **Read this for _shipping_ vs. _spec_.** This area is implemented to an
> unusually high degree. The **routing core, channel boundary, audit envelope,
> credential seam, and the email / Web Push / Telegram transports are real,
> in-repo, and tested** — including a dependency-free RFC 5321 SMTP client and a
> Web Push transport verified byte-for-byte against the RFC 8291 Appendix A
> known-answer test. The honest aspirational edge: **end-to-end _outbound_
> Telegram delivery in the BFF e2e is `unimplemented`** (the policy core and
> grounding are real; the live send loop is the gap, per the completeness
> audit). The **WhatsApp / SMS / payment paths are pure planners** whose actual
> provider HTTP calls only fire when real credentials are present. Nothing in
> this area is a result-faking stub: where an integration is absent, the code
> reports `missing-config` or throws `*_not_configured` rather than fabricating
> a send.

Everything below is grounded in `libs/oshun/messaging-channels/` (the channel
library), `apps/oshun/telegram-bot/` and `apps/oshun/telegram-miniapp/` (the two
Telegram apps), `apps/oshun/bff/src/routes/telegram.ts` (the webhook), and
`libs/oshun/customer-message-center/` (the in-app inbox).

## Where this subsystem sits

- **Purpose:** a single tier-aware routing layer that picks the right outbound
  channel per **consent, residency, retention, capability, quiet-hours,
  frequency-cap, crisis-state, and audience tier**, then drives a real provider
  transport — or honestly suppresses / reports a gap.
- **Library:** `libs/oshun/messaging-channels`, published as
  `@oshun/messaging-channels`. Its barrel `src/index.ts` re-exports the
  registry, dispatcher, boundary, transports, the SMTP and Web Push transports,
  the per-channel modules (`telegram/`, `whatsapp/`, `push/`, `sms/`,
  `discord/`, `slack/`, `email/`), the reminder producers/scheduler/worker, the
  V3 deep-link and session-reminder helpers, and `provider-config-env`.
- **Apps:** `@oshun/telegram-bot` (a real grammY app) and
  `apps/oshun/telegram-miniapp` (a Next app rendering the seven Studio surfaces
  inside Telegram).
- **Inbox substrate:** `libs/oshun/customer-message-center` — the
  customer-visible message center state model, plus its BFF wiring under
  `apps/oshun/bff/src/customer/` and
  `apps/oshun/bff/src/routes/customer-message-center.ts`.

## The channel registry — a per-channel policy matrix

The prose summary of "channel abstraction and tier-aware routing" is backed by a
concrete, frozen policy matrix in `src/registry.ts`. There are **twelve**
channel ids, not the three or four the older feature docs name:

```
CHANNEL_IDS = [
  'telegram-bot', 'telegram-channel', 'telegram-miniapp',
  'whatsapp-business', 'discord-bot', 'slack-app', 'sms-twilio',
  'push-fcm', 'push-apns', 'push-expo', 'push-webpush', 'email-ses',
]
```

Each `CHANNEL_REGISTRY` entry is a `ChannelEntry` carrying the policy attributes
the dispatcher consults, drawn from these enumerations:

| Enumeration          | Values                                                                          |
| -------------------- | ------------------------------------------------------------------------------- |
| `CAPABILITY_KINDS`   | `text`, `rich-cards`, `inline-buttons`, `voice`, `files`, `payments`, `miniapp` |
| `COST_CLASSES`       | `free`, `low`, `metered`, `high`                                                |
| `RESIDENCY_PROFILES` | `global`, `eu-only`, `us-only`, `apac-only`, `self-hosted`                      |
| `CONSENT_PROFILES`   | `platform-tos-implied`, `verified-opt-in-required`, `double-opt-in-required`    |
| `RETENTION_PROFILES` | `channel-default`, `short-30d`, `standard-365d`, `long-3y`, `indefinite`        |

A representative slice of the registry shows how policy differs per channel —
this is the data that makes routing _tier- and residency-aware_ rather than a
generic fan-out:

| Channel id                                  | Capabilities (subset)                                             | Cost      | Consent profile            | Retention         | Crisis-capable |
| ------------------------------------------- | ----------------------------------------------------------------- | --------- | -------------------------- | ----------------- | -------------- |
| `telegram-bot`                              | text, rich-cards, inline-buttons, voice, files, payments, miniapp | `low`     | `verified-opt-in-required` | `channel-default` | yes            |
| `telegram-channel`                          | text, rich-cards, files                                           | `low`     | `verified-opt-in-required` | `channel-default` | no             |
| `whatsapp-business`                         | text, rich-cards, inline-buttons, voice, files                    | `metered` | `double-opt-in-required`   | `short-30d`       | yes            |
| `sms-twilio`                                | text                                                              | `metered` | `double-opt-in-required`   | `short-30d`       | yes            |
| `push-fcm` / `-apns` / `-expo` / `-webpush` | text, rich-cards, inline-buttons                                  | `free`    | `verified-opt-in-required` | `short-30d`       | no             |
| `discord-bot`                               | text, rich-cards, inline-buttons, voice, files                    | `free`    | `platform-tos-implied`     | `channel-default` | no             |
| `slack-app`                                 | text, rich-cards, inline-buttons, files                           | `free`    | `platform-tos-implied`     | `channel-default` | no             |
| `email-ses`                                 | text, rich-cards, files                                           | `low`     | `verified-opt-in-required` | `standard-365d`   | no             |

Two registry helpers, `lookupChannel(id)` and `listChannelsWith(capability)`,
let the dispatcher and callers query the matrix. The `crisisCapable` flag is
load-bearing: a `crisis-hotline` intent is only ever allowed to route to a
channel that can safely carry hotline copy (`telegram-bot`, `telegram-miniapp`,
`whatsapp-business`, `sms-twilio`).

> **Naming note on `email-ses`.** The registry id is `email-ses`, but there is
> **no dedicated SES API transport** in the lib. The real email transports are
> `sendEmailViaSendgrid` (the SendGrid HTTPS API) and `sendEmailViaSmtp` (the
> hand-written RFC 5321 SMTP client, usable against AWS SES SMTP, Postmark, or
> the dev-stack Mailpit sink). The `email-ses` id is a naming artifact, and
> DKIM/SPF/DMARC are deploy-config concerns, not code in this library.

## The dispatcher — tier-aware routing as a pure function

`src/dispatcher.ts` exports
`dispatchMessage(input: DispatchInput): DispatchDecision` — a **pure function**
(pure-data in, pure-data out; the actual provider call is the runtime's job). It
is the concrete enforcement of "tier-aware routing."

### Tiers and the hard tier guard

```
TIERS = ['contemplative', 'curated-creator', 'aaa-creator', 'operator-admin']
```

Critically,
`MESSAGING_ALLOWED_TIERS = new Set(['contemplative', 'curated-creator'])`. The
very first check in `dispatchMessage` rejects any `aaa-creator` or
`operator-admin` intent with
`{ kind: 'suppress', reason: 'tier-not-allowed-on-messaging' }`. **No AAA or
operator-admin intent can reach a member's channel** — that capability is fenced
off in one place, by design, so high-volume creator or operator tooling cannot
accidentally spray messaging surfaces.

### Intents and content classes

```
INTENT_KINDS = [
  'transactional-receipt', 'crisis-hotline', 'reminder',
  'session-notification', 'newsletter', 'engagement-recap',
]
```

Each intent maps to a `ContentClass` via `INTENT_CONTENT_CLASSES`
(`contentClassForIntent`), e.g. `reminder → ritual-reminder`,
`newsletter → editorial-briefing`, `engagement-recap → grounded-answer`,
`crisis-hotline → crisis`. The caller may override with an explicit
`contentClass`.

### Variants and capability fall-through

A `DispatchInput` carries an ordered list of `ContentVariant`s, each with a
`requiredCapability`, `bodyTextPlain`, `disclosureCopy`, and a
`provenanceFooter`. For a chosen channel, `chooseVariantForChannel` walks the
variants and returns the first whose `requiredCapability` the channel supports;
if none match but the channel supports `text`, it falls through to the explicit
`text` variant (or coerces the first variant to `text`). This is how a rich-card
answer gracefully degrades to a plain-text SMS without the caller having to
branch.

### The decision ladder

`dispatchMessage` evaluates guards in this order, returning a typed `suppress`
reason at the first failure (only `crisis-hotline` bypasses the suppression /
quiet-hours / frequency guards, via `CRISIS_SUPPRESSION_BYPASS`):

1. **Tier guard** → `tier-not-allowed-on-messaging`
2. **Crisis-state suppression** → `crisis-suppression`
3. **Quiet hours** → `quiet-hours`
4. **Frequency cap** → `frequency-cap`
5. **Empty tenant allowlist** → `tenant-allowlist-empty`
6. **Per-channel pass** over `preferredChannels`: must be in `tenantAllowlist`,
   in `userOptIns`, satisfy residency (`residencyMatches`), be `crisisCapable`
   if the intent is `crisis-hotline`, and allow the content class
   (`channelAllowsContentClass`, consulting `CHANNEL_BOUNDARIES`). The first
   channel that also yields a feasible variant returns
   `{ kind: 'dispatch', channel, contentClass, chosenVariant }`.
7. If nothing dispatches, a diagnostic tail returns the _most specific_ reason:
   `no-opt-in`, `no-channel-meets-residency`, `content-class-not-allowed`, or
   `no-channel-meets-capability`.

### Tenant allowlist resolution

`resolveTenantChannelAllowlist(input: TenantChannelAllowlistInput)` derives the
tenant's permitted channel set from a `residencyProfile` plus disclosure-accept
flags. The defaults encode real residency posture: `eu-only` →
`['whatsapp-business', 'email-ses']`; `us-only` → `['email-ses', 'sms-twilio']`;
`self-hosted` → `['email-ses']`; otherwise the global default. Telegram and push
channels are only added when the corresponding **data-residency disclosure** has
been accepted (`telegramDataResidencyDisclosureAccepted`,
`pushDataResidencyDisclosureAccepted`); Slack requires
`institutionalMessagingEnabled` and Discord requires
`creatorCommunityMessagingEnabled`. `tenantPolicyAllowsChannel` re-checks each
of these so an explicit allowlist cannot smuggle a channel past residency
policy.

## The channel boundary and audit envelope

`src/boundary.ts` is the real implementation of what the architecture overview
describes narratively as "boundary, residency, retention, consent, audit." It
declares a `CHANNEL_BOUNDARIES` table — one `ChannelBoundary` per channel id —
plus the audit and memory-ingestion shapes.

### `ContentClass` and per-channel allowances

```
type ContentClass =
  | 'transactional' | 'grounded-answer' | 'ritual-reminder'
  | 'editorial-briefing' | 'identity-verification' | 'billing' | 'crisis'
```

Each `ChannelBoundary` lists which `ContentClass`es the channel may carry, its
`residency`, `retention`, the human-readable `consentRequired` string, whether
`provenancePreserved` is true, a `disclosureMechanism` (`body-copy`,
`body-copy-and-link`, or `payload-metadata`), and the `auditEvents` it emits.
For example, `telegram-bot` allows `grounded-answer`, `ritual-reminder`,
`identity-verification`, and `crisis` (with audit events `telegram.receive`,
`telegram.send`, `telegram.crisis-suppression`). `sms-twilio` allows
`identity-verification`, `billing`, `crisis`, `ritual-reminder` and explicitly
sets `provenancePreserved: false` (SMS cannot carry a provenance footer), so its
`disclosureMechanism` is `body-copy`.

### `ChannelAuditEnvelope`

Every outbound send produces a full audit envelope via
`buildChannelAuditEnvelope`. Its fields are:

| Field                          | Meaning                                     |
| ------------------------------ | ------------------------------------------- |
| `channelId`                    | which of the 12 channels                    |
| `recipientId`                  | the bound recipient                         |
| `intentClass`                  | the `ContentClass` actually delivered       |
| `persona`                      | the persona that authored the content       |
| `policyHash`                   | hash of the policy that authorized the send |
| `provenanceBundleId`           | link to the `ProvenanceBundle`              |
| `residencyTag`                 | the `ResidencyProfile` applied              |
| `retentionClass`               | the `RetentionProfile` applied              |
| `disclosureVerificationResult` | `'passed'` or `'failed'`                    |

### Memory ingestion gating (the durable-write boundary)

`decideMemoryIngestion` returns a `MemoryIngestionDecision` whose
`durableWriteAllowed` is true **only** when the user opted into chat memory
_and_ the channel is in `DURABLE_CHAT_INGESTION_CHANNELS` (`telegram-bot`,
`telegram-miniapp`, `whatsapp-business`, `discord-bot`, `slack-app`). Push and
SMS payloads are excluded by construction — they can never write durable
assistant memory. The decision also carries channel-specific `redactionRules`
(e.g. `strip-initdata`, `strip-miniapp-session-token` for the Mini App;
`strip-subscription-endpoint` for Web Push) and the inbound provenance. This is
the seam where the messaging boundary defers to the Iris memory substrate — see
[Iris — Assistant Memory Substrate](./substrate-iris.md). Two more boundary
helpers round out consent control: `consentRevocationCascade` (revoke binding,
terminate Mini App sessions, cancel scheduled sends, redact chat cache, post a
revocation notice) and `crisisCrossChannelSuppression`.

## Real provider transports

`src/transports.ts` is where the audit's earlier finding — "Email/SMS/Push/
WhatsApp were payload builders that never sent" — was closed. Each transport
POSTs the correctly-shaped request to the provider's HTTP API and returns a
normalized `MessageTransportResult { ok, status, providerMessageId, error? }`.
The `TransportFetch` is **injectable** so the transport is unit-testable without
network or credentials; **credentials are deploy config (env), not code**. On
any non-`ok` response, `failure(response)` returns `{ ok: false }` with the
provider body — never a fabricated success.

| Export                     | Provider endpoint                                                  |
| -------------------------- | ------------------------------------------------------------------ |
| `sendEmailViaSendgrid`     | `https://api.sendgrid.com/v3/mail/send`                            |
| `sendSmsViaTwilio`         | `https://api.twilio.com/2010-04-01/Accounts/<sid>/Messages.json`   |
| `sendPushViaFcm`           | `https://fcm.googleapis.com/v1/projects/<p>/messages:send`         |
| `sendPushViaApns`          | `https://api.push.apple.com/3/device/<token>` (ES256 provider JWT) |
| `sendPushViaExpo`          | `https://exp.host/--/api/v2/push/send`                             |
| `sendWhatsAppViaMetaCloud` | `https://graph.facebook.com/v21.0/<phoneNumberId>/messages`        |
| `sendSlackViaWebApi`       | `https://slack.com/api/chat.postMessage`                           |
| `sendDiscordViaBot`        | `https://discord.com/api/v10/channels/<id>/messages`               |

Two transports deserve a dedicated callout because they are full, RFC-grade
protocol implementations rather than thin HTTP wrappers:

### SMTP — `src/smtp-email-transport.ts` (RFC 5321)

A real, **dependency-free** SMTP client over `node:net` / `node:tls`. It speaks
the protocol directly — greeting → `EHLO` → optional `STARTTLS` → optional
`AUTH` → `MAIL FROM` → `RCPT TO` → `DATA` → `QUIT` — parses multiline replies,
**dot-stuffs** the message body, and returns the same `MessageTransportResult`
the HTTP transports return. It **never fabricates success**: any non-2xx reply,
a dropped connection, or a reply timeout (default 15s) yields `{ ok: false }`
with the offending status/text. The socket factory (`SmtpConnectionFactory`) and
the STARTTLS upgrade (`SmtpTlsUpgrade`) are injectable, so the entire protocol
state machine is tested against an in-process SMTP server with real sockets.
This is what lets the dev stack exercise the signup email-verification
round-trip through **Mailpit** (the plain SMTP sink on `:1025`) end-to-end
without a real provider.

### Web Push — `src/web-push-transport.ts` (RFC 8030 / 8291 / 8188 / 8292)

The W3C Web Push transport for PWAs. A
`PushSubscription { endpoint, keys: { p256dh, auth } }` is **not** an FCM/APNs
token, so delivery must: ECDH a fresh ephemeral P-256 key against the client's
`p256dh`, derive the IKM (RFC 8291 §3.4) then the `aes128gcm` content-encryption
key and nonce (RFC 8188), AES-128-GCM-encrypt and frame the payload, sign a
VAPID ES256 JWT (RFC 8292), and POST the binary body. Because this library is
client-transpiled (so `node:crypto` is unavailable), every primitive is the
audited **noble** suite: `@noble/curves` (P-256 ECDH + ES256), `@noble/hashes`
(HKDF/SHA-256), `@noble/ciphers` (AES-128-GCM), plus the Web Crypto CSPRNG for
the salt. The encryption is **verified byte-for-byte against the RFC 8291
Appendix A known-answer test** in `web-push-transport.test.ts`. Exported
building blocks include `deriveWebPushEncryptionKeys`, `webPushEcdhSecret`,
`encryptWebPushPayload`, `audienceForEndpoint`, `buildVapidAuthorizationHeader`,
and `sendPushViaWebPush`. A non-2xx push-service response is reported as
`{ ok: false }`, never a faked send.

> **APNs needs HTTP/2.** Node's global `fetch` is HTTP/1.1; APNs requires
> HTTP/2. `src/http2-transport-fetch.ts` exports `createHttp2TransportFetch` for
> exactly this. `provider-config-env.ts` even surfaces a boot warning
> (`apnsHttp2BootWarning`) when APNs credentials are configured but no HTTP/2
> transport is wired into the delivery path — a silent runtime gap turned into a
> loud startup string.

## The credential seam — fail-loud, never faked

`src/provider-config-env.ts` is the seam between deploy-time secrets and the
transports. `buildMessageProviderConfigFromEnv(env)` reads `OSHUN_*` environment
variables and includes a channel in the `MessageProviderConfig` **only when
every credential it needs is present**. A channel with missing credentials is
simply omitted — so `deliverDispatchedMessage` (in `src/delivery.ts`) returns
`{ delivered: false, reason: 'missing-config', detail: '<channel>' }` for it
rather than silently faking a send. **No secret is ever logged, defaulted, or
invented.**

Concretely: email requires `OSHUN_MESSAGING_EMAIL_FROM` plus a transport
(`OSHUN_SMTP_HOST` takes precedence over `OSHUN_SENDGRID_API_KEY`); SMS requires
`OSHUN_TWILIO_ACCOUNT_SID` + `_AUTH_TOKEN` + `_FROM`; FCM push requires
`OSHUN_FCM_ACCESS_TOKEN` + `_PROJECT_ID`; APNs requires the four `OSHUN_APNS_*`
values (team id, key id, `.p8` private key PEM, bundle id); Web Push requires
all three VAPID values; WhatsApp requires access token, phone number id,
template, and language. `configuredChannelsFromEnv` reports exactly which
channels are live. `deliverWithEnvProviders` composes this config with the
delivery router so a reminder scheduler or notification worker can turn a
dispatch decision plus a recipient into a real send, with credentials supplied
purely by the environment. The full result space — `missing-config` /
`missing-recipient` / `suppressed` / `policy-blocked` / `missing-attribution` /
`provider-error` — is honest at every branch.

## Telegram (bot, channel, Mini App)

Telegram is the most fully built channel family, spanning three registry ids.

### Mini App `initData` verification — `src/telegram/security.ts`

Verification uses the **noble** HMAC/SHA-256 primitives directly. The two
verifiers differ exactly as Telegram's spec requires:

- `verifyTelegramMiniAppInitData` derives the secret as
  `hmacSha256('WebAppData', botToken)`, then compares the supplied `hash`
  against `hmacSha256Hex(secret, dataCheckString)` in constant time.
- `verifyTelegramLoginWidgetPayload` instead uses `secret = sha256(botToken)`.

Both build the `dataCheckString` by sorting all params except `hash` and joining
`key=value\n`, reject stale or future-skewed `auth_date` and replayed
`query_id`/`id`, and return a typed reason (`missing-hash` / `missing-subject` /
`expired` / `tampered` / `replay`). `issueTelegramMiniAppSession` then mints an
HS256 JWT scoped to the allowed Telegram scopes (`content:read`,
`notebook:save`, `ritual:play`, `sky:view`, `qa:ask`), and
`requiresPrimaryCredentialChallenge` flags the sensitive actions (e.g.
`admin:access`, `consent:change`, `billing:manage`, `generation:promote-aaa`)
that demand a step-up.

### Bot commands and grounding — `src/telegram/bot.ts`

`TelegramUpdateKind` covers `message`, `callback_query`, `inline_query`,
`edited_message`, and `my_chat_member`. **All nine documented commands** are
implemented: `/start`, `/menu`, `/today`, `/save`, `/sources`, `/voice`,
`/quiet`, `/stop`, `/help`. The handler is candid about what is not yet wired:
the side-effecting commands (`/save`, `/quiet`, `/stop`, `/voice`) return honest
"not connected yet" copy (e.g. _"Saving from Telegram is not connected yet, so I
will not pretend it saved."_) rather than claiming a side effect. Every reply
builds a `ChannelAuditEnvelope`. When the Sophia grounder cannot ground an
answer (no sources / retrieval unavailable), the bot emits
`TELEGRAM_ABSTENTION_COPY` — _"I can't ground that in sources right now, so I
won't guess…"_ — instead of guessing. Voice messages route through a
`VoiceProvider` from `src/telegram/stt-provider.ts`, which is a **fail-closed
seam**: `createSttVoiceProvider` performs the real Telegram `getFile` → download
→ STT POST chain and `unconfiguredVoiceProvider` throws `stt_not_configured` —
it never fabricates a transcript. (Any doc still calling the Telegram voice-STT
path a "stub" is stale.)

### The grammY app — `apps/oshun/telegram-bot`

`@oshun/telegram-bot` is a real grammY application (`package.json` declares
`grammy ^1.42.0`; `main` is `src/index.ts`). `TELEGRAM_BOT_ENVIRONMENTS` defines
`dev` / `staging` / `prod` with env-var tokens and secrets, and the
`UNCONFIGURED_PROD_BOT_TOKEN = 'prod-token'` sentinel makes
`createOshunGrammyBot` **fail fast** in prod if the real token is unset — a
misconfigured prod bot refuses to boot rather than run on a placeholder.

### The Mini App surfaces — `apps/oshun/telegram-miniapp`

The Next app renders **all seven** Studio surfaces inside Telegram, defined in
`src/app/surface-data.ts` as `SurfaceSlug`s: `today` (Tara ritual), `sophia`
(grounded Q&A), `veritas` (a `veritas-claim` with sources, **counterclaims**,
confidence, save, and share), `nyx` (sky), `arete` (check-in), `nisaba`
(reader), and `illustration` (an `illustration-card`).
`src/lib/telegram-webapp.ts` bridges the `Telegram.WebApp` host. These surfaces
are the "curated Studio surfaces in chat" the architecture overview refers to.

### The BFF webhook

`apps/oshun/bff/src/routes/telegram.ts` registers `POST /telegram/webhook`,
guarded by `requireWebhookSecret()` against the
`x-telegram-bot-api-secret-token` header (a bad secret returns
`401 invalid-webhook-secret`). The webhook handler in
`apps/oshun/bff/src/telegram/webhook.ts` wires grounding via
`createTelegramSophiaGrounder({ retriever, minimumSources: 1 })` — the policy
core and grounding are real. **The honest gap:** end-to-end _outbound_ Telegram
delivery in the BFF e2e is noted `unimplemented` in the completeness audit; the
live send loop is the piece that is not yet exercised end-to-end.

## WhatsApp, SMS, Slack, Discord

These four channels ship **real, pure-data planners** with entitlement and
cost/consent logic; the actual outbound HTTP fires through the transports above
only when credentials are present.

### WhatsApp — `src/whatsapp/index.ts`

`WhatsAppUseCase` ∈ `receipt`, `reminder`, `password-reset`, `login-code`,
`billing-alert`, `scheduled-event-nudge`. `planWhatsAppTemplateDelivery`
enforces the Meta **24-hour customer-initiated session window**
(`SESSION_WINDOW_SECONDS = 24 * 60 * 60`) and falls back to email when no
approved template exists, recording a `CostLedgerEntry` per message.
`ingestWhatsAppStatusWebhook` maps provider statuses
(`sent`/`delivered`/`read`/`failed`/`opted_out`).

### SMS — `src/sms/index.ts`

`SmsUseCase` ∈ `login-code`, `password-reset`, `billing-alert`,
`crisis-hotline`, `verified-reminder`. `planSmsDelivery` **throws** if a US send
lacks A2P 10DLC registration, computes segment count, and prices the send from
`SMS_PRICING_MINOR_USD` (a per-country table in USD minor units: US/CA `1`, BR
`4`, GB `4`, IE/ES/AU/MX `5`, IT `7`, DE/FR/NZ `8`, IN/ZA `3`, with a `6`
fallback), emitting an `SmsCostLedgerEntry`.

### Slack — `src/slack/index.ts`

`assertSlackEntitlement` requires an _installed_ binding whose
`metisInstitutionId` starts with `metis-institution:` and that holds
`REQUIRED_SLACK_SCOPES = ['commands', 'chat:write']`. `renderSlackOshunCommand`
handles `/oshun today|save|ask` with **real `todayItems`** — no fabricated list.
This is the institutional surface the architecture overview mentions ("V1
includes Slack notification sinks for tenants").

### Discord — `src/discord/index.ts`

`DiscordServerBinding.community` is constrained to
`'yemaya-aaa-creator-community'` or `'project-obsidian-production'`,
`REQUIRED_DISCORD_SCOPES = ['applications.commands', 'bot']`, and
`assertDiscordEntitlement` is **deny-by-default** for an unbound server.

## Push surface (broader than FCM + APNs)

The push surface is wider than the older feature docs state. The registry has
four push channel ids — `push-fcm`, `push-apns`, `push-expo`, `push-webpush` —
backed by `sendPushViaFcm`, `sendPushViaApns`, `sendPushViaExpo`, and
`sendPushViaWebPush`. The payload builder in `src/push/index.ts` (`PushPlatform`
= `'fcm-android' | 'fcm-web' | 'apns-ios'`, `buildPushPayload`) shapes the
platform-specific bodies, while **Expo** (React Native clients registering an
`ExponentPushToken[...]`) and **Web Push** (PWAs with a VAPID subscription) are
first-class additional delivery paths, not afterthoughts.

## V3 cross-domain integration

Two helpers wire V3 programming into messaging.

- **Deep links — `src/v3-deep-links.ts`.**
  `V3_SHARE_LINK_CANONICAL_HOST = 'app.oshun.com'` (origin
  `https://app.oshun.com`), with a custom scheme `oshun://v3`
  (`V3_SHARE_LINK_CUSTOM_SCHEME = 'oshun'`, `V3_SHARE_LINK_CUSTOM_HOST = 'v3'`)
  and path prefix `/v3`. Shares route over
  `V3_MESSAGING_SHARE_CHANNELS = ['telegram', 'whatsapp', 'push', 'email', 'sms']`.
  `buildV3MessagingSharePayload` produces both the canonical HTTPS URL and the
  custom-scheme URL.
- **Session reminders — `src/v3-session-reminders.ts`.** A pre-session reminder
  may only be delivered inside the **30-minute** pre-session window
  (`V3_SESSION_REMINDER_MAX_LEAD_TIME_MINUTES = 30`); outside it,
  `planV3SessionReminderDelivery` suppresses with
  `outside-thirty-minute-window`. Push provider selection
  (`fcm`/`apns`/`expo`/`webpush`) resolves to the right `ChannelId`.

## The in-app message center

`libs/oshun/customer-message-center` is the customer-visible inbox substrate the
architecture overview gestures at. `src/message-center.ts` defines the real
state model:

- `CUSTOMER_MESSAGE_CHANNELS = ['in-app', 'email', 'push', 'sms', 'voice']`
- `CUSTOMER_MESSAGE_CATEGORIES` (10): `onboarding`, `milestone`, `reengagement`,
  `billing`, `privacy`, `support`, `incident`, `content-update`, `social`,
  `system`
- `CUSTOMER_MESSAGE_RECEIPT_STAGES = ['queued', 'sent', 'delivered', 'opened', 'clicked', 'failed', 'bounced', 'suppressed']`
- `CUSTOMER_MESSAGE_PRIORITIES = ['low', 'normal', 'high', 'critical']`

The BFF surfaces it through
`apps/oshun/bff/src/routes/customer-message-center.ts` and
`apps/oshun/bff/src/customer/customer-message-center-store.ts`.

## Honest status — what is real, what is gated

- **Real and tested:** the registry policy matrix, the pure-function dispatcher
  (tier guard, intent→content-class, capability fall-through, residency and
  consent gates), the channel boundary + audit envelope + memory-ingestion gate,
  the credential→transport seam with `missing-config` fail-loud, the
  SendGrid/Twilio/FCM/APNs/Expo/Meta/Slack/Discord transports, the
  dependency-free SMTP client, and the Web Push transport (RFC 8291 Appendix A
  KAT). Telegram `initData` verification, all 9 bot commands, the abstention
  copy, the fail-closed voice-STT seam, all 7 Mini App surfaces, and the
  webhook-secret guard are in-repo.
- **Planner-level, credential-gated:** WhatsApp, SMS, and the payment paths are
  pure planners whose real provider HTTP calls only fire when credentials are
  set — by design, they report a gap rather than fake a send.
- **Aspirational edge:** **end-to-end _outbound_ Telegram delivery in the BFF
  e2e is `unimplemented`** (policy core and grounding are real; the live send
  loop is the gap). DKIM/SPF/DMARC remain deploy-config concerns, and the
  `email-ses` id has no dedicated SES API transport (SendGrid + SMTP cover
  email).

Cross-cutting controls referenced here — consent, residency, retention,
provenance, and audit — connect to
[Security, Privacy, and Compliance](./security-privacy-compliance.md),
[Trust, Safety, and Privacy](./trust-safety-and-privacy.md), and the grounding
substrate [Sophia — Grounding Substrate](./substrate-sophia.md). Backlog items
for this area live in [`V1/TODOS.md`](../TODOS.md); cross-domain dependencies in
[`V1/DEPENDENCIES.md`](../DEPENDENCIES.md).

## Related

- [Sophia — Grounding Substrate](./substrate-sophia.md) — the grounder behind
  Telegram/Mini App grounded answers.
- [Iris — Assistant Memory Substrate](./substrate-iris.md) — the durable-write
  boundary that `decideMemoryIngestion` defers to.
- [Product Surfaces](./product-surfaces.md) — the seven Studio surfaces the Mini
  App renders in chat.
- [Security, Privacy, and Compliance](./security-privacy-compliance.md) —
  consent, residency, retention, and audit posture.
- [Support, Entitlements, Billing, and the Aje Entitlement Bridge](./support-billing-and-crypto.md)
  — the billing/payment context for transactional and billing intents.
- [`V1/features.md` § Messaging Channels and Conversational Surfaces](../features.md#messaging-channels-and-conversational-surfaces)
  — companion product-scope page.
- [../ARCHITECTURE.md](../ARCHITECTURE.md) — the V1 architecture hub.
