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 inpackages/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
coreand is read here, never written. - Cross-channel fallback (push fails → email), which is F2 and noted (DEC-E4).
Behaviour (normative)
- Resolution runs the levels in order, and any level saying no ends it: platform operative → tenant enabled and configured → module enabled → event routing exists.
- Then the recipient gates: consent for that channel ∧ not suppressed ∧ preference center allows it ∧ (marketing only) quiet hours and frequency caps.
- 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.
- 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.
- 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.
- 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.
- The result is a list of channels, not one. An event may legitimately go to in-app and email.
- 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)
API (normative)
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)
- 100% branch coverage on
resolveChannels, with a fixture per level and per gate. - A suppressed recipient receives nothing, including transactional.
- A recipient who opted out of marketing still receives transactional.
- Quiet hours are evaluated in the recipient’s timezone, verified across a date line.
- Every exclusion returns a distinct typed reason, and the reason is recorded.
- Disabling a channel at the module level stops that module’s sends and leaves other modules unaffected.
simulatewrites nothing and returns both the resolved channels and the reasons for the rest.- Negative: no configuration anywhere disables the consent or suppression gate.
Execution
Single slice, synchronous command. The pure function lives inpackages/core beside the stacking
evaluator; the configuration tables and endpoints in backend/api. This is part of TS-002.