Story NOTIF-001c: Suppression table + pre-dispatch lookup + NOTIF_SUPPRESSED_OPT_OUT emission¶
Module: notifications
Slice: NOTIF-001 sub-slice c (of NOTIF-001 in architecture §11)
Side: [BACKEND]
Version target: V0.0.0
Sub-version: v0.0.0 (architecture-backed)
Priority: 4
Depends on: NOTIF-001a, AUDIT-002 (catalog extension for NOTIF_SUPPRESSED_OPT_OUT — architecture §11 R1)
Can develop concurrently with: NOTIF-001b (no shared code path)
Merge order: Before NOTIF-001b reaches production (no real SMS goes out until suppression is honored — ARTCI mitigation #5)
Estimated complexity: S
PRD User Stories: NOTIF-US-07 (AC-07.3 pre-dispatch honoring); NOTIF-US-09 (AC-09.3 NOTIF_OPTED_OUT — renamed NOTIF_SUPPRESSED_OPT_OUT in v0.0.0 catalog)
Wireframe: N/A — backend only
Objective¶
Introduce the suppression table and the pre-dispatch suppression lookup. Before NOTIF-001a inserts a delivery_attempt, consult suppression for (tenant_id, customer_id|phone, channel). If every viable channel is suppressed, set the notification to OPT_OUT and emit the NOTIF_SUPPRESSED_OPT_OUT audit event — no provider call is made. This closes ARTCI mitigation #5 (no-resend after opt-out) for the dispatch side; the inbound STOP capture that populates the table lives in NOTIF-002.
Backend Scope¶
Entities¶
suppression (full v0.0.0 schema — architecture §2.1):
- suppression_id UUID PK
- tenant_id UUID NOT NULL
- customer_id UUID NULL (some STOPs identify by phone only in v0.0.0 — see NOTIF-002)
- phone_e164 TEXT NOT NULL (supports phone-only opt-out — mitigation #5)
- channel TEXT NOT NULL — SMS / WA
- reason TEXT NOT NULL — customer_stop / customer_block / admin_override
- suppressed_at TIMESTAMPTZ NOT NULL
- removed_at TIMESTAMPTZ NULL — platform-admin removal (V1 only; column exists Day 1)
Migrations¶
V0_0_0__notif_create_suppression.sql— createssuppressiontable + indexes:- UNIQUE
(tenant_id, COALESCE(customer_id, '00000000-0000-0000-0000-000000000000'::uuid), phone_e164, channel) WHERE removed_at IS NULL— active-row uniqueness per architecture §2.2. (tenant_id, phone_e164) WHERE removed_at IS NULL— STOP webhook lookup (phone-only path used by NOTIF-002).
Service Layer¶
SuppressionService.isSuppressed(UUID tenantId, UUID customerId, String phoneE164, Channel ch): boolean— returns true iff an active row exists matching(tenant_id, channel)AND(customer_id = ? OR (customer_id IS NULL AND phone_e164 = ?))withremoved_at IS NULL.- Hook into
NotificationService.dispatch(...)(NOTIF-001a) — extends the existing dispatch path: - Before inserting the SMS
delivery_attempt, callSuppressionService.isSuppressed(tenant, customer, phone, SMS). - If SMS is suppressed AND the customer has no other viable channel in v0.0.0 (v0.0.0 sends SMS only at T0 — WA is the cascade in NOTIF-003, which itself re-checks suppression at firer time per architecture §6.2), then DO NOT insert a
delivery_attempt, setnotification.status='OPT_OUT', emitNOTIF_SUPPRESSED_OPT_OUToutbox event +notification.opted_outSpring application event, and return 202{notification_id, status:'OPT_OUT'}(per architecture §2.3 transitionPENDING → OPT_OUT). - If SMS is not suppressed → proceed with the existing NOTIF-001a / NOTIF-001b path.
SuppressionServicereads only — writes happen in NOTIF-002 (STOP webhook).
API Endpoints¶
None new. (This story modifies POST /api/notifications/dispatch behavior — same endpoint, same contract.)
Validation Rules¶
None new. (Existing validation from NOTIF-001a continues to apply BEFORE the suppression lookup; an invalid request is still rejected with 400 and no suppression query is run — that path consumes no cap, no audit, per OQ-NOTIF-08.)
Multi-Tenant Considerations¶
- Suppression rows are STRICTLY tenant-scoped — critical-rule #1. Every lookup carries
tenant_id. AC-07.4 (per-tenant scope) enforced architecturally: there is no global suppression in v0.0.0. - BYO-DB profile:
suppressionlives onplatformDataSourcealong with the rest of NOTIF (NOTIF-ADR-01 / TV-05).
Audit & Logging¶
NOTIF_SUPPRESSED_OPT_OUT— emitted when dispatch is short-circuited by an active suppression. Mandatory fields per audit-log §B.3 NOTIF row + thesuppressed_channelsarray:{tenant_id, agency_id, country_code, customer_id, correlation_id, suppressed_channels: ['SMS'] (v0.0.0), result:'INFO'}. PII hashed viaPiiHasher.- Spring application event
notification.opted_outpublished (consumed by OP in V1). - The audit catalog admits
NOTIF_SUPPRESSED_OPT_OUT— this entry is the v0.0.0 scope of OQ-NOTIF-04 (architecture §0 + §7) and is shipped by AUDIT-002. This story HARD-DEPENDS on AUDIT-002 being merged first.
Frontend Scope¶
N/A — backend only.
Acceptance Criteria¶
AC-001c.1: Active suppression short-circuits dispatch
Given an active row in `suppression` for `(tenant_id, customer_id, phone_e164, channel='SMS', removed_at IS NULL)`
When `POST /api/notifications/dispatch` is called for that `(tenant_id, customer_id)` with the same phone
Then NOTIF returns 202 with `{notification_id, status:'OPT_OUT'}`
And no `delivery_attempt` row is created
And no SMS submission is attempted
And outbox contains a `NOTIF_SUPPRESSED_OPT_OUT` event with `suppressed_channels:['SMS']` and PII-hashed payload
And the Spring application event `notification.opted_out` is published
AC-001c.2: Per-tenant scope — no cross-tenant leak
Given an active suppression for `(tenant_A, customer_X, phone_P, SMS)`
When tenant_B (different tenant_id) dispatches to the same physical phone_P for a different customer
Then dispatch proceeds normally (no OPT_OUT, no NOTIF_SUPPRESSED_OPT_OUT)
And tenant_A's suppression row is NOT visible to tenant_B (no cross-tenant lookup)
AC-001c.3: Phone-only suppression (customer_id IS NULL) honored
Given an active suppression with `customer_id=NULL, phone_e164='+225...', channel='SMS', tenant_id=T`
When dispatch is called with that phone for tenant T (with any customer_id)
Then dispatch short-circuits as in AC-001c.1
And the audit event records the suppression_id as the source
Compliance Rules¶
- ARTCI mitigation #5 (no-resend after opt-out) — non-negotiable; closure of this mitigation depends on this slice + NOTIF-002 (capture side). Status
VÉRIFIÉfor the mechanism. Architecture §0. - Law n°2013-450 right of objection (TV-03) — STOP suppression mechanism is the V1-accepted compensating control. Status
PRÉLIMINAIRE— emit[LEGAL-REVIEW]warning at NOTIF-002 / NOTIF-US-08 milestones, not here. - Critical-rule #1 — per-tenant scope enforced via
tenant_idfilter on everysuppressionquery. - Critical-rule #3 —
NOTIF_SUPPRESSED_OPT_OUTaudit emission is mandatory whenever suppression triggers; AUDIT rejects unknownevent_typeso AUDIT-002 catalog extension MUST be merged first.
Standards & Conventions¶
docs/standards/critical-rules.mddocs/standards/multi-tenant-model.mddocs/standards/compliance-discipline.mddocs/standards/database-guidelines.md(partial unique indexes withCOALESCEfor nullable columns)docs/standards/java-spring-guidelines.mddocs/standards/multi-datasource-patterns.mddocs/standards/tech-stack.md
Testing Requirements¶
Unit Tests¶
SuppressionServiceLookupTest— active row hits;removed_at IS NOT NULLrow misses; customer_id match + phone-only match.NotificationServiceSuppressionShortCircuitTest— full path: suppression → OPT_OUT + audit emitted + nodelivery_attempt.
Integration Tests¶
SuppressionTenantIsolationTest— tenant_A's suppression does not affect tenant_B (AC-001c.2 verbatim).SuppressionAuditPayloadTest— outboxNOTIF_SUPPRESSED_OPT_OUTpayload carries hashed phone +suppressed_channels:['SMS']+correlation_id.PhoneOnlySuppressionTest—customer_id IS NULLrow matches when dispatch carries any customer_id with that phone (AC-001c.3).
What QA Will Validate¶
- An opt-out customer cannot be reached: 100 sequential
POST /dispatchto a suppressed(tenant, customer)produce 0 SMS in the WireMock log, 100NOTIF_SUPPRESSED_OPT_OUTaudit events, 0NOTIF_SENDINGevents. - Cross-tenant negative test: same phone in 2 tenants, suppressed only in 1 → only 1 OPT_OUT, the other tenant's dispatch reaches WireMock normally.
Out of Scope¶
- Inbound STOP webhook (suppression WRITE path) — NOTIF-002.
- Suppression visible in operator timeline (AC-07.5) — V1.
- Platform-admin suppression removal (AC-07.6 /
NOTIF_SUPPRESSION_REMOVEDaudit) — V1. - WA suppression at cascade time (re-check in
WhatsAppCascadeFirer) — NOTIF-003. - WhatsApp
customer_blockevents from Meta — V1 (no WA suppression source in v0.0.0; WA cascade simply re-checks SMS suppression — see NOTIF-003).
Definition of Done¶
- [ ]
V0_0_0__notif_create_suppression.sqlFlyway migration applied cleanly - [ ]
SuppressionService.isSuppressed(...)implemented and unit-tested - [ ]
NotificationService.dispatch(...)short-circuits to OPT_OUT before anydelivery_attemptinsert when suppression hits - [ ] All 3 ACs pass
- [ ] AUDIT-002 catalog extension confirmed in place (CI test fails loudly if
NOTIF_SUPPRESSED_OPT_OUTis rejected by AUDIT) - [ ]
notification.opted_outSpring application event published - [ ] Tenant isolation verified (cross-tenant negative test in CI)
- [ ] Both V1 DB profiles tested —
suppressiononplatformDataSourceregardless of tenant profile - [ ] PII hashed in audit payload via
PiiHasher - [ ]
correlation_idpropagated toNOTIF_SUPPRESSED_OPT_OUTevent - [ ] No silent failures: a suppression hit ALWAYS produces a
NOTIF_SUPPRESSED_OPT_OUTaudit event