Story NOTIF-002: Inbound STOP detection (capture side of mitigation #5)¶
Module: notifications
Slice: NOTIF-002
Side: [BACKEND]
Version target: V0.0.0
Sub-version: v0.0.0 (architecture-backed)
Priority: 5
Depends on: NOTIF-001a (need notification rows to derive tenant from phone), NOTIF-001b (status webhook controller shares the HMAC verifier), NOTIF-001c (suppression table exists)
Can develop concurrently with: NOTIF-003, NOTIF-004 (no shared code path)
Merge order: Any order after dependencies
Estimated complexity: M
PRD User Stories: NOTIF-US-07 (AC-07.1 STOP detection, AC-07.4 per-tenant scope — adapted to phone-only resolution for v0.0.0)
Wireframe: N/A — backend only
Objective¶
Implement POST /api/v1/notifications/webhooks/{providerId}/inbound — the inbound SMS endpoint that captures customer STOP replies. Verify the HMAC signature, normalize the body, and upsert a phone-scoped suppression row on the right tenant. Tenant resolution uses a 14-day lookback against notification.customer_phone_e164 (architecture §6.4) — this closes ARTCI mitigation #5 on the WRITE side, partnering with NOTIF-001c on the READ side.
Backend Scope¶
Entities¶
No new tables — extends behavior on suppression (from NOTIF-001c).
Migrations¶
None new.
Service Layer¶
InboundWebhookController—POST /api/v1/notifications/webhooks/{providerId}/inbound:- HMAC signature verified via the per-provider adapter (reuses the
verifySignature(rawBody, signatureHeader)API introduced in NOTIF-001b). Invalid signature → 401 + structured WARN log (NOTIF_WEBHOOK_REJECTED audit lands at v0.1.0 per NOTIF-ADR-06). - Content-Length cap 8 KB (architecture §12 R3); Content-Type allow-list
application/json. - Delegates to
StopDetector.process(providerId, body). StopDetector:- Normalize:
body.trim().toUpperCase().stripAccents()(per architecture §6.4 + FR-11). - Match:
normalized ∈ {"STOP", "ARRET", "NON", "NO"}. Else: 200 no-op (conversational text dropped — architecture §6.4). - Resolve tenant(s):
SELECT DISTINCT tenant_id FROM notification WHERE customer_phone_e164 = ? AND enqueued_at > now() - INTERVAL '14 days'. (Architecture §6.4: scope is "per tenant the phone appears under in the last 14 days".) - If zero tenants resolve → log + drop, NO suppression row created (architecture §NOTIF-ADR-08).
- For each resolved tenant →
SuppressionService.upsert(tenant_id, customer_id=NULL, phone_e164, channel='SMS', reason='customer_stop'). Upsert is idempotent: duplicate STOP for the same(tenant, phone, SMS)does NOT create a duplicate row (active-row UNIQUE index from NOTIF-001c). SuppressionService.upsert(...)— new write-side method on the service introduced read-only in NOTIF-001c. UsesON CONFLICT DO NOTHINGagainst the active-row UNIQUE partial index.- WA
customer_blockevents — NOT in v0.0.0. TheInboundWebhookControlleraccepts themeta_wa_cloud_ciprovider path but treats any non-STOP-like payload as 200 no-op (true WA suppression source lands V1).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /api/v1/notifications/webhooks/{providerId}/inbound |
{from_phone_e164, body, received_at} per §3.3 |
200 (always, even on no-op or drop) / 401 (invalid signature) / 415 / 413 | Provider HMAC signature |
Validation Rules¶
- HMAC signature REQUIRED (header per provider adapter scheme). Invalid → 401.
from_phone_e164REQUIRED.bodyREQUIRED (may be empty after normalization).- Content-Length ≤ 8192. Content-Type
application/json.
Multi-Tenant Considerations¶
- Tenant resolution NEVER trusts the request body (provider does not know about tenants). It is derived from a 14-day lookback on
notification.customer_phone_e164— the only way phone-to-tenant routing can happen in v0.0.0. - Per-tenant scope (AC-07.4): if the same phone appears under 2 tenants in the lookback window, the STOP is recorded under EACH tenant the phone matches in those 14 days. Per architecture §6.4 final paragraph this is "v0.0.0-acceptable" — V1 may tighten.
- Zero-tenant resolution: phone is unknown to NOTIF → log + drop, no suppression row created (NOTIF-ADR-08 — avoids leaking cross-tenant suppression by creating a global row).
- Both V1 DB profiles:
notification+suppressiononplatformDataSource(NOTIF-ADR-01).
Audit & Logging¶
- No
NOTIF_*audit event in v0.0.0 for STOP capture — the existingNOTIF_SUPPRESSED_OPT_OUTfrom NOTIF-001c will fire the next time dispatch attempts the suppressed phone, which is the audit trail that matters for ARTCI mitigation #5. - Structured INFO log line on STOP success:
{tenant_id, phone_hashed, normalized_body, resolved_via:'14d_lookback'}(phone hashed viaPiiHasher). - Structured WARN on invalid signature, on zero-tenant-drop, and on bodies > 8 KB.
NOTIF_WEBHOOK_REJECTEDaudit event ships at v0.1.0 (architecture §7 last row + OQ-NOTIF-04). Until then, logs are retained via Coolify and back-filled when the catalog admits the event.
Frontend Scope¶
N/A — backend only.
Acceptance Criteria¶
AC-002.1: STOP body normalized + suppression row upserted
Given a notification was sent in the last 14 days from tenant_T to phone_P
When the SMS provider POSTs `/webhooks/letstalk_sms_ci/inbound` with `{from_phone_e164:'+225...', body:' stop '}` and a valid HMAC header
Then the response is 200
And a `suppression` row exists with `(tenant_id=T, customer_id=NULL, phone_e164=P, channel='SMS', reason='customer_stop', removed_at IS NULL)`
And the body normalizations pass for any of {"STOP", "ARRET", "ARRÊT", "NON", "NO"} with leading/trailing whitespace and case variants
AC-002.2: Per-tenant scope — STOP lands on every tenant the phone appears under in 14d
Given two tenants A and B have BOTH sent a notification to phone_P in the last 14 days
When an inbound STOP arrives for phone_P
Then a `suppression` row is created under tenant_A AND a `suppression` row is created under tenant_B
And neither row is created under any other tenant
And the architecture §6.4 final paragraph rule is met (v0.0.0-acceptable per-tenant scope)
Given a phone_P with NO notification in the last 14 days
When an inbound STOP arrives for phone_P
Then no suppression row is created (NOTIF-ADR-08 log + drop)
And the response is 200 (no provider retry storm)
AC-002.3: Duplicate STOP is idempotent
Given a `suppression` row already exists for `(tenant_T, NULL, phone_P, SMS, customer_stop)`
When a second inbound STOP arrives for the same phone within the 14-day window
Then no duplicate row is created (ON CONFLICT DO NOTHING against the active-row UNIQUE index)
And the response is 200
And the original `suppressed_at` timestamp is unchanged
Compliance Rules¶
- ARTCI mitigation #5 (no-resend after opt-out) — capture side completed by this slice (combined with NOTIF-001c's enforcement side). Status
VÉRIFIÉfor the mechanism. Critical-rule #3 (audit completeness) is satisfied by the downstreamNOTIF_SUPPRESSED_OPT_OUTthat fires on the NEXT dispatch attempt. - Law n°2013-450 right of objection — TV-03: STOP-based right of objection accepted as the V1 mechanism without per-customer pre-consent. Status
PRÉLIMINAIRE— emit[LEGAL-REVIEW]warning at NOTIF-US-08 milestone (V1). - Critical-rule #1 — tenant isolation enforced via the 14-day lookback (NOT a global suppression).
- NOTIF-ADR-08 — zero-tenant-resolution → log + drop, no suppression created. This is the V1 mitigation for cross-tenant phone collisions.
Standards & Conventions¶
docs/standards/critical-rules.mddocs/standards/multi-tenant-model.mddocs/standards/compliance-discipline.mddocs/standards/api-guidelines.md(webhook signature, idempotency)docs/standards/database-guidelines.md(ON CONFLICT DO NOTHING, partial UNIQUE indexes)docs/standards/java-spring-guidelines.mddocs/standards/multi-datasource-patterns.mddocs/standards/tech-stack.md
Testing Requirements¶
Unit Tests¶
StopDetectorNormalizationTest— every variant of{"stop", "STOP", " ARRET ", "Arrêt", "non", "NO"}matches; "I want to STOP" does NOT match (FR-11: body must equal exactly one token).StopDetectorZeroTenantDropTest— phone with no notification in 14d → no DB write, INFO+drop log.SuppressionServiceUpsertIdempotencyTest— second STOP from same phone within window does not duplicate the row.
Integration Tests¶
InboundStopE2ETest(WireMock + Testcontainers) — full HMAC-signed inbound STOP → suppression row created → nextPOST /dispatchfor same (tenant, customer/phone) short-circuits to OPT_OUT (cross-checks NOTIF-001c).InboundStopMultiTenantTest— same phone under 2 tenants in 14d → 2 suppression rows after STOP (AC-002.2).InboundStopInvalidSignatureTest— invalid HMAC → 401, no DB write, WARN log.
What QA Will Validate¶
- Curl a STOP via the WireMock inbound stub, observe the
suppressionrow and the next dispatch returning OPT_OUT. - Negative test: STOP from a phone never seen by NOTIF → no DB rows mutated; the system logs the drop and returns 200.
- Multi-tenant negative: SMS sent only by tenant A → STOP creates a row under A only.
Out of Scope¶
- WA
customer_blockcapture from Meta — V1 (no source in v0.0.0). - Suppression visible in operator timeline (AC-07.5) — V1.
- Platform-admin suppression removal (AC-07.6) — V1.
NOTIF_WEBHOOK_REJECTEDaudit catalog entry — v0.1.0 (NOTIF-ADR-06).- Per-provider scoped suppression (v0.0.0 scope only
(tenant, phone, channel)) — V1. - Conversational handling beyond STOP — V3.
Definition of Done¶
- [ ]
POST /api/v1/notifications/webhooks/{providerId}/inboundimplemented and HMAC-verified - [ ]
StopDetectorbody normalization handles all FR-11 token variants - [ ]
SuppressionService.upsert(...)is idempotent via the active-row UNIQUE partial index - [ ] All 3 ACs pass
- [ ] Tenant isolation verified (14-day lookback; multi-tenant scope works; zero-tenant drops cleanly)
- [ ] Both V1 DB profiles tested
- [ ] PII hashed in every log line touching the phone
- [ ] No silent failures: invalid signature → 401 + WARN; unknown phone → INFO + drop + 200; valid STOP → row + 200
- [ ] Integration test confirms the next
POST /dispatchafter a STOP returns OPT_OUT (cross-cuts NOTIF-001c)