Skip to main content

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 §21Notification.body AES-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.
  • NotificationRateLimiter per-template + dedup.
  • NotificationsDispatchService.dispatchDirect / dispatchByTemplate / dispatchByTemplateToClient audit- wired.
  • Provider-webhook HMAC verification for delivery receipts + STOP replies.

Partial ship — 2026-07-20 (nc-20260720-1228)

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 active SecretsCipher.activeKid() on every dispatch so read-path key selection is deterministic after rotation.
  • gap 4 — recordOutcome() emits per-recipient notification.delivery.receipt audit alongside the outcome-specific event so compliance greps by receipt.
  • gap 5 — NotificationRateLimiter.allow() fails closed in NODE_ENV=production when Redis is unreachable, unless NOTIFICATIONS_ALLOW_RATE_LIMITER_FAILOPEN=true escape hatch is set.
  • gap 7 — provider-webhook signature-fail branches emit notification.provider_webhook.signature_invalid audit 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 — NotificationsProcessor wraps every provider send() in ProviderCircuitBreaker keyed by notif:<channel>:<providerCode>. OPEN → short-circuit as failed with distinct message + emits notification.provider.circuit_short_circuited audit; outcomes advance the breaker state (CLOSED / HALF_OPEN → CLOSED on success, OPEN on threshold-breach).
  • gap 6 — queue.add bumped removeOnFail: 500 → 5000 so ops has a much bigger inspection window. Persistent DLQ retained in NotificationDeadLetter (already landed); RetentionPurgeJob hard-deletes those past notifications.dead_letter_retention_days (default 90 d)
    • emits notification_dead_letter.retention.deleted audit.
  • gap 8 — dispatchByTemplateToClient now emits notification.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 with statutoryClass IS NOT NULL AND status='pending' past 1 h get audit-flagged; past 6 h get notification.statutory.escalated + compliance_officer fanout. Idempotent via metadata.retryFlaggedAt + metadata.escalatedAt.
  • gap 10 — NotificationRateLimiter.allow() reads notifications.cross_template_cap_multiplier policy (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 from timelineFor() + timelineForClient() with bodyDisplayable + bodyEncryptionNote; encrypted bodies carry an explicit "decrypt via SecretsCipher before rendering" marker so downstream clients can't leak the ciphertext.

Acceptance criteria

#AC
1Every provider adapter's fetch() wraps in AbortSignal.timeout(30_000). Timeout → NotificationProviderTimeoutError + queue retry with backoff.
2New 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.
4Provider-webhook per-recipient audit — one notification.delivery.receipt audit event per {provider, recipient, providerRef} triple instead of one per batch.
5NotificationRateLimiter.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.
6removeOnFail: 5000 bumped + failed-job payloads copied to NotificationDeadLetter model (with retention) before purge.
7Provider-webhook signature failure emits notification.provider_webhook.signature_invalid audit event + increments notification_provider_webhook_signature_failures_total metric.
8Silent-skip branches (missing contact / cross-border gate) emit notification.dispatch.suppressed (from earlier phase) + notification.dispatch.contact_missing audit + metric.
9New 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.
10notifications.cross_template_cap_multiplier policy (default 5). Approaching-cap ( ≥ 80 %) emits notification.rate_limiter.cap_approaching audit + metric.
11recordStop() wraps the preference upsert in a $transaction + SELECT ... FOR UPDATE on NotificationPreference rows before write.
12communications.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.