Channel catalog (core.notification_channels, parametric)
email · in_app · webhook · push · sms (adapter F1b behind provider ADR) · whatsapp (F2) · live_activity (F2). The channel row and the NotificationChannelPort exist from day 1 even when the adapter arrives later.
Cascading resolution (levels 0–3 + recipient)
resolveChannels(event, tenant, module, recipient) — PURE function in packages/core, unit-tested:
0. Platform: adapter exists and is operative (Vercel Flags operational flag, mirrored in messaging.platform_channel_status).
- Tenant: channel active for this tenant (entitlement + configured credentials/sender domain) —
messaging.tenant_channel_settings. - Module: channel enabled for the emitting module in this tenant —
messaging.module_channel_settings. - Event type: routing entry (channels, per-channel template, category) —
messaging.event_channel_routing. Recipient gate: consent for the channel ∧ not inmessaging.suppressions∧ preference center ∧ (marketing only: quiet hours + frequency caps). Result: one job per resolved channel ontonotif.{channel}.{rail}(standards/jobs.md).
Categories & recipient controls
- Category per event routing:
transactional | marketing | product. Transactional is NO-opt-out (only suppression by hard bounce). - Member preference center: per channel AND per category. Quiet hours: tenant default, campaign override. Frequency caps: marketing only.
Traceability (end-to-end)
- Chain: origin event → rule/campaign → notification job →
messaging.sends→messaging.send_status_history(queued→sent→delivered→opened→clicked / bounced / complained / failed) → outgoing webhooks. All linked bycorrelation_id+origin_event_id. - Provider status ingestion via provider webhooks (Resend today); each future channel ships its status adapter.
- Hard bounces and complaints write to
messaging.suppressionsautomatically. EVERY send checks suppressions — no exceptions.