> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Channel Catalog and Cascade Resolution

> After this ships, any module can ask "may I notify this person about this, and how" and get an answer that already accounts for our operational state, the tenant's configuration and the person's own choices.

## Context

Every module needs to notify somebody, and without a single answer to that question each one
invents its own — one checks consent, another forgets quiet hours, a third sends anyway because its
message felt important. That is how a platform ends up with four delivery paths and one complaint.

ADR-019 makes it one pure function over four configuration levels and a set of recipient gates.
Pure matters: `resolveChannels` does no I/O, so it is exhaustively unit-testable, and a change to
the rules is a change to one tested function rather than to four call sites.

The channel row and the port exist from day one even when the adapter arrives later (DEC-E1). A
tenant can see that WhatsApp is coming; they simply cannot enable it yet.

## Scope *(normative)*

* The four configuration levels: platform, tenant, module, event routing.
* `resolveChannels(event, tenant, module, recipient)` as a pure function in `packages/core`.
* Recipient gates: consent, suppression, preference center, and for marketing quiet hours and caps.
* Category per routing entry: `transactional | marketing | product`.
* A typed reason for every exclusion, recorded when a message is not sent.
* Management endpoints for each configuration level.

## Non-scope *(normative)*

* Rendering — FS-MSG-0002. Resolution decides whether and where, not what.
* Queueing and delivery — FS-MSG-0005 and FS-MSG-0006.
* Consent storage, which belongs to `core` and is read here, never written.
* Cross-channel fallback (push fails → email), which is F2 and noted (DEC-E4).

## Behaviour *(normative)*

1. Resolution runs the levels **in order**, and any level saying no ends it: platform operative →
   tenant enabled and configured → module enabled → event routing exists.
2. Then the recipient gates: **consent for that channel** ∧ **not suppressed** ∧ preference center
   allows it ∧ (marketing only) quiet hours and frequency caps.
3. **Transactional is not opt-out** (DEC-E3). It passes the preference center, quiet hours and caps,
   but **never** passes suppression: a hard bounce means the address does not work, and category
   does not change that.
4. Every "no" returns a **typed reason**, and the reason is recorded. "Not sent" without a reason
   makes support impossible and makes a compliance question unanswerable.
5. The function is **pure**: no database, no cache, no clock read. Its inputs are the event, the
   already-loaded configuration and the recipient snapshot. Testability is the point.
6. Quiet hours are evaluated in the **recipient's timezone** where known, falling back to the
   tenant's. A quiet-hours rule in the wrong timezone is worse than none.
7. The result is a **list of channels**, not one. An event may legitimately go to in-app and email.
8. FORBIDDEN: any send path that does not call this function · a tenant setting that disables
   suppression or consent checking · a category that bypasses the cascade.

## Data *(normative)*

| Table                               | Key invariants                                                                                                               |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `messaging.platform_channel_status` | one row per channel; `is_operative`; mirrors an operational flag; ours, not the tenant's                                     |
| `messaging.tenant_channel_settings` | (`tenant_id`, `channel_code`); `is_enabled`; `credentials_ref`; enabling requires the entitlement and configured credentials |
| `messaging.module_channel_settings` | (`tenant_id`, `module`, `channel_code`); `is_enabled`                                                                        |
| `messaging.event_channel_routing`   | (`tenant_id`, `event_name`, `channel_code`); `template_id`; `category`; `is_enabled`                                         |

## API *(normative)*

| Endpoint                                 | Class      | Permission                          | Budget    |
| ---------------------------------------- | ---------- | ----------------------------------- | --------- |
| `GET/PUT /v1/messaging/channel-settings` | Management | `messaging.channels.{read\|update}` | p95 \<1 s |
| `GET/PUT /v1/messaging/event-routing`    | Management | `messaging.routing.{read\|update}`  | p95 \<1 s |
| `POST /v1/messaging/routing/simulate`    | Management | `messaging.routing.simulate`        | p95 \<1 s |

`simulate` answers "if this event happened for this contact right now, what would we send, and why
not the rest" — the single most useful support tool in the module.

## Events *(normative)*

None emitted. This feature consumes every module's events and decides what happens next.

## Acceptance criteria *(normative)*

1. 100% branch coverage on `resolveChannels`, with a fixture per level and per gate.
2. A suppressed recipient receives nothing, including transactional.
3. A recipient who opted out of marketing still receives transactional.
4. Quiet hours are evaluated in the recipient's timezone, verified across a date line.
5. Every exclusion returns a distinct typed reason, and the reason is recorded.
6. Disabling a channel at the module level stops that module's sends and leaves other modules
   unaffected.
7. `simulate` writes nothing and returns both the resolved channels and the reasons for the rest.
8. **Negative:** no configuration anywhere disables the consent or suppression gate.

## Execution

Single slice, synchronous command. The pure function lives in `packages/core` beside the stacking
evaluator; the configuration tables and endpoints in `backend/api`. This is part of TS-002.

## Open questions

| # | Question                                                                            | Decides | By              |
| - | ----------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Default quiet hours for a new tenant — 21:00–09:00 local, or none until configured? | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
