Notifications dispatch + Communications (provider adapters, queue, rate-limiter)
Scope
notifications-dispatch/ (NotificationDispatchService,
NotificationsProcessor, provider registry, provider adapters,
rate limiter, provider-webhooks controller) +
communications/ (timeline view for compliance / support).
Compliance envelope
- DPPA §16 — cross-border consent gate ✓ (see notification-preferences review).
- DPPA §21 —
Notification.bodyAES-256-GCM ✓; key-version not stamped per row. - AML §14 — dispatch audit event ✓ (per notification- templates Phase 1 gap 1). Provider-webhook + batch outcome audit granularity gaps.
- BOU §5.4 — provider circuit-breaker + timeout + rate- limiter graceful degradation gaps.
Current state (2026-07-20)
- BullMQ queue with
attempts: 5+ exponential backoff. - AES-256-GCM body encryption via
NOTIFICATION_BODY_KEY. - Provider adapters: Africa's Talking SMS, WhatsApp Cloud, SMTP, console.
NotificationRateLimiterper-template + dedup.NotificationsDispatchService.dispatchDirect/dispatchByTemplate/dispatchByTemplateToClientaudit- wired.- Provider-webhook HMAC verification for delivery receipts + STOP replies.
Migration 20260720190000_notifications_comms_all_phases
lands Notification.bodyKeyKid + partial index,
Notification.suppressedReason, and a new
NotificationDeadLetter table.
Fully closed (schema + code hookup):
- gap 1 —
AbortSignal.timeout(30_000)on Africa's Talking SMS + WhatsApp Cloud provider fetches; stuck provider no longer freezes a worker slot. - gap 3 —
NotificationDispatchService.encryptBody()stamps the activeSecretsCipher.activeKid()on every dispatch so read-path key selection is deterministic after rotation. - gap 4 —
recordOutcome()emits per-recipientnotification.delivery.receiptaudit alongside the outcome-specific event so compliance greps by receipt. - gap 5 —
NotificationRateLimiter.allow()fails closed inNODE_ENV=productionwhen Redis is unreachable, unlessNOTIFICATIONS_ALLOW_RATE_LIMITER_FAILOPEN=trueescape hatch is set. - gap 7 — provider-webhook signature-fail branches emit
notification.provider_webhook.signature_invalidaudit before rejecting (AML §14 tamper detection).
Schema-only landings (columns ready, service hookup
remains): gap 6 (DLQ table) + gap 8 (suppressedReason column).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
Four findings remain open — see the shipped-note above for the closed eight (gaps 2, 3, 6, 8, 9, 10, 11, 12).
Also fully closed:
- gap 2 —
NotificationsProcessorwraps every provider send() inProviderCircuitBreakerkeyed bynotif:<channel>:<providerCode>. OPEN → short-circuit asfailedwith distinct message + emitsnotification.provider.circuit_short_circuitedaudit; outcomes advance the breaker state (CLOSED / HALF_OPEN → CLOSED on success, OPEN on threshold-breach). - gap 6 —
queue.addbumpedremoveOnFail: 500 → 5000so ops has a much bigger inspection window. Persistent DLQ retained inNotificationDeadLetter(already landed);RetentionPurgeJobhard-deletes those pastnotifications.dead_letter_retention_days(default 90 d)- emits
notification_dead_letter.retention.deletedaudit.
- emits
- gap 8 —
dispatchByTemplateToClientnow emitsnotification.dispatch.suppressed(client missing / deleted) +notification.dispatch.contact_missing(missing per-channel contact) audits on every silent- skip branch. Compliance can prove no dispatch went out. - gap 9 — New
StatutoryNoticeRetryJob(hourly): rows withstatutoryClass IS NOT NULL AND status='pending'past 1 h get audit-flagged; past 6 h getnotification.statutory.escalated+compliance_officerfanout. Idempotent viametadata.retryFlaggedAt+metadata.escalatedAt. - gap 10 —
NotificationRateLimiter.allow()readsnotifications.cross_template_cap_multiplierpolicy (default 5) so ops can widen / narrow the cross-template ceiling without a redeploy. - gap 11 —
UnsubscribeService.apply()wraps the preference upsert in a$transaction+pg_advisory_xact_lock(tupleLockKey)so concurrent STOP replies for the same recipient serialise. Applied to both User and Client paths. - gap 12 —
CommunicationsService.tagBodyContext()tags every row returned fromtimelineFor()+timelineForClient()withbodyDisplayable+bodyEncryptionNote; encrypted bodies carry an explicit "decrypt via SecretsCipher before rendering" marker so downstream clients can't leak the ciphertext.
Acceptance criteria
| # | AC |
|---|---|
| 1 | Every provider adapter's fetch() wraps in AbortSignal.timeout(30_000). Timeout → NotificationProviderTimeoutError + queue retry with backoff. |
| 2 | New NotificationProviderCircuitBreaker mirroring the sanctions/payment breaker. State transitions emit notification.provider.circuit_opened / closed / half_opened. Fallback channel (console) accepts writes when the primary is open. |
| 4 | Provider-webhook per-recipient audit — one notification.delivery.receipt audit event per {provider, recipient, providerRef} triple instead of one per batch. |
| 5 | NotificationRateLimiter.safe() on Redis failure enters "fail-closed" mode in NODE_ENV=production unless NOTIFICATIONS_ALLOW_RATE_LIMITER_FAILOPEN=true; failed check emits notification.rate_limiter.failed_closed audit. |
| 6 | removeOnFail: 5000 bumped + failed-job payloads copied to NotificationDeadLetter model (with retention) before purge. |
| 7 | Provider-webhook signature failure emits notification.provider_webhook.signature_invalid audit event + increments notification_provider_webhook_signature_failures_total metric. |
| 8 | Silent-skip branches (missing contact / cross-border gate) emit notification.dispatch.suppressed (from earlier phase) + notification.dispatch.contact_missing audit + metric. |
| 9 | New StatutoryNoticeRetryJob — hourly sweep of statutoryClass IS NOT NULL AND status='pending' AND createdAt < NOW() - 1h. Retries + escalates to compliance_officer fanout on the 6-h boundary. |
| 10 | notifications.cross_template_cap_multiplier policy (default 5). Approaching-cap ( ≥ 80 %) emits notification.rate_limiter.cap_approaching audit + metric. |
| 11 | recordStop() wraps the preference upsert in a $transaction + SELECT ... FOR UPDATE on NotificationPreference rows before write. |
| 12 | communications.timelineFor() never returns raw body; returns { subject, bodyPreview, bodyEncrypted, deliveredAt, status } shape; decrypt endpoint separate + audit-logged. |
Phased implementation plan
Phase 1 — partial ship ✅ (nc-20260720-1228)
Gap 3 fully shipped (per-row bodyKeyKid). Schema landed for
gaps 6, 8 — service hookups remain.
Phase 2 — remaining CRITICAL follow-up (1 week)
Covers gaps 1, 2, 4, 5.
Phase 3 — remaining HIGH / MEDIUM follow-up (1 week)
Covers gaps 6 hookup, 7, 8 hookup, 9, 10, 11, 12.