Story NOTIF-003: WhatsApp cascade at T+5d (with AUTH reissue + cancel-on-click)¶
Module: notifications
Slice: NOTIF-003
Side: [BACKEND]
Version target: V0.0.0
Sub-version: v0.0.0 (architecture-backed)
Priority: 6
Depends on: NOTIF-001a/b/c/d merged; AUTH must ship SignedLinkService.reissueForCascade(originalLinkId, ttl) Spring named interface (OQ-NOTIF-13 — cross-module dependency); CP must publish the customer.session_opened Spring application event (OQ-NOTIF-14 — cross-module dependency)
Can develop concurrently with: NOTIF-002, NOTIF-004 (no shared code path)
Merge order: After AUTH reissueForCascade and CP customer.session_opened are merged
Estimated complexity: L
PRD User Stories: NOTIF-US-09 partial (audit emission with channel=WA); architecture §11 NOTIF-003 native ACs
Wireframe: N/A — backend only
Objective¶
Implement the WhatsApp cascade scheduled task. Every 5 minutes, scan notification rows in AWAITING_CASCADE with wa_scheduled_for <= now(), re-check suppression, ask AUTH for a freshly-reissued signed link, submit to the WhatsApp Cloud API (or BSP per OQ-NOTIF-01), and emit the cascade audit/event chain. Cancel rows where the customer has already clicked (via the customer.session_opened event from CP). This is the SMS-first → WA-at-T+5d cascade that NOTIF-ADR-02 locks (overriding the parallel-send V0 envelope wording).
Backend Scope¶
Entities¶
Extends behavior on notification and delivery_attempt (uses fields wa_scheduled_for, cascade_signed_link_id, cascade_signed_link_url, wa_dispatched_at, customer_clicked_at already declared in NOTIF-001a).
Migrations¶
None new.
Service Layer¶
WhatsAppProviderinterface — symmetric toSmsProviderfrom NOTIF-001b; carriesString providerId()discriminator (registered viaWaProviderRegistryintroduced in NOTIF-001d).MetaWaCloudAdapter implements WhatsAppProvider(OR<BSP>WaAdapter— concrete choice pending OQ-NOTIF-01). Configured via env vars per architecture §14.1:NOTIF_WA_PROVIDER_BASE_URL,NOTIF_WA_PHONE_NUMBER_ID,NOTIF_WA_ACCESS_TOKEN,NOTIF_WA_TEMPLATE_NAME(template name hard-coded via env var in v0.0.0; per-tenant template = V1 per architecture §10).WhatsAppCascadeFirer—@Scheduled(fixedDelayString = "${notif.cascade.firer.tick.seconds:300}000"):SELECT ... FROM notification WHERE status='AWAITING_CASCADE' AND wa_scheduled_for <= now() FOR UPDATE SKIP LOCKED LIMIT ${notif.cascade.firer.batch.size:50}. Uses the partial index(status, wa_scheduled_for) WHERE status='AWAITING_CASCADE'from NOTIF-001a.- For each row, re-check
SuppressionService.isSuppressed(tenant, customer, phone, WA)— if suppressed, CAS toCASCADE_CANCELLED(reasonwa_suppressed), emitnotification.cascade_cancelledSpring event, skip. - Call
SignedLinkService.reissueForCascade(originalLinkId, ttl=Duration.ofHours(72))(AUTH named interface). AUTH inserts a newsigned_linkrow with the samecorrelation_id, same case key,lineage_of=originalLinkId. Returns{new_signed_link_id, url}. - CAS-update:
UPDATE notification SET status='DISPATCHING_WA', cascade_signed_link_id=?, cascade_signed_link_url=?, wa_dispatched_at=now() WHERE id=? AND status='AWAITING_CASCADE' AND customer_clicked_at IS NULL. If 0 rows updated → late click race → setCASCADE_CANCELLED(reasonrace_with_click), emitnotification.cascade_cancelled, continue (architecture §5 row 6). - Submit to
WhatsAppProvider.send(...)with the cascade link URL + template name + tenant's per-country WA business identity. On 2xx → insertdelivery_attempt(channel='WA', provider_id, provider_message_id, status='SENT', attempt_index=1), emitNOTIF_SENDING+NOTIF_SENToutbox events +notification.dispatched+notification.sentSpring events (each withchannel='WA'). On hard-error →notification.status='FAILED',last_failure_class=?, emitNOTIF_FAILURE. - AUTH
reissueForCascaderetry policy: if AUTH returns 5xx, the firer leaves the row inAWAITING_CASCADE(does not mutate state). Next tick (5 min) retries. After 3 consecutive failed ticks for the same notification →FAILED+last_failure_class='CASCADE_REISSUE_FAILED'+NOTIF_FAILURE(architecture §12 R5). CustomerSessionOpenedListener—@EventListeneron the CP-publishedcustomer.session_opened {tenant_id, customer_id, signed_link_id, opened_at}Spring application event:- Filter by
tenant_id(NOTIF re-filters even though CP carries it — defense in depth). UPDATE notification SET status='CASCADE_CANCELLED', customer_clicked_at=now() WHERE tenant_id=? AND signed_link_id=? AND status='AWAITING_CASCADE'. CAS — 0 rows updated means the firer beat the listener (architecture §5 row 6); leave row inDISPATCHING_WA, do not emitcascade_cancelled.- On success: emit
notification.cascade_cancelledSpring event. - WA status webhook reuses
StatusWebhookControllerfrom NOTIF-001b — the controller is already provider-agnostic via{providerId}path param. - Reuses
WaProviderRegistryskeleton from NOTIF-001d.
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /api/v1/notifications/webhooks/meta_wa_cloud_ci/status |
Provider envelope per §3.3 | 200 / 401 | Meta-style HMAC signature (X-Hub-Signature-256) |
Validation Rules¶
- WA template body MUST include the cascade signed link URL in a body parameter slot. The template is registered with Meta out-of-band (OQ-NOTIF-01 deliverable).
customer_phone_e164MUST be unchanged from the original SMS (carried forward from thenotificationrow — no re-validation needed at cascade time).- Feature flag gate:
FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED(architecture §12 R2 + §14.1). Whenfalse, the firer no-ops (rows stay inAWAITING_CASCADEuntil the flag flips OR the row ages out — V1 ages-out policy land later). This is the OQ-NOTIF-02 ARTCI cross-border safety valve.
Multi-Tenant Considerations¶
- The firer's
SELECT ... FOR UPDATE SKIP LOCKEDis global but each iteration carries the row'stenant_idinto MDC + every subsequent call. Critical-rule #1 enforced per-row. agency_idis nullable in v0.0.0 (per architecture §4.3); v0.0.2 adds it as a partition key for fair scheduling across agencies. This story does NOT implement per-agency partitioning — single-tenant + single-agency assumption is acceptable for v0.0.0.- Both V1 DB profiles supported (all NOTIF tables on
platformDataSource). correlation_idfrom the original SMS notification is carried into the WAdelivery_attempt, every audit event, the WA provider's client-ref, AND the AUTHreissueForCascadecall (so the reissued link inherits the chain per D16 + NOTIF-ADR-07).
Audit & Logging¶
NOTIF_SENDING(channel=WA) +NOTIF_SENT(channel=WA) on each cascade attempt that goes out — mandatory fields per audit-log §B.3, PII hashed.NOTIF_FAILUREon terminal WA failure OR AUTH reissue exhausted-retries.notification.dispatched,notification.sent,notification.failed,notification.cascade_cancelledSpring application events withchannel='WA'(ornullfor cascade_cancelled).- Cascade-cancel race: when CAS update returns 0 rows in step 4, log INFO
cascade_cancel_racewith bothnotification_idandcorrelation_id. NOTIF_WEBHOOK_REJECTEDfor invalid WA webhook signature — same v0.0.0 honest-scope: WARN log instead of audit event (NOTIF-ADR-06).
Frontend Scope¶
N/A — backend only.
Acceptance Criteria¶
AC-003.1: Cascade fires at T+5d only for un-clicked rows
Given a notification at `status='AWAITING_CASCADE'` with `wa_scheduled_for = now() - 1 minute` and `customer_clicked_at IS NULL` and no WA suppression
When the WhatsAppCascadeFirer tick runs
Then exactly one row is picked under FOR UPDATE SKIP LOCKED
And `SignedLinkService.reissueForCascade` is called with the original signed_link_id
And the notification transitions to `DISPATCHING_WA` (CAS succeeds)
And the WA provider receives one submit with the new cascade link URL
And a `delivery_attempt(channel='WA', provider_id, status='SENT', attempt_index=1)` row is persisted
And outbox contains `NOTIF_SENDING` + `NOTIF_SENT` events with `channel='WA'` and `correlation_id` inherited from the original SMS
AC-003.2: CAS prevents race with late session_opened
Given a notification in AWAITING_CASCADE, wa_scheduled_for past
When CustomerSessionOpenedListener processes `customer.session_opened` for that notification's signed_link_id
And the WhatsAppCascadeFirer concurrently attempts the CAS update
Then exactly one wins: either the listener (status=CASCADE_CANCELLED, customer_clicked_at set, `notification.cascade_cancelled` emitted) OR the firer (status=DISPATCHING_WA, WA submit reaches provider exactly once)
And NEVER both (the row never ends up in CASCADE_CANCELLED with a WA delivery_attempt row alongside)
AC-003.3: WA-channel suppression skips the firer cleanly
Given an active suppression for `(tenant, customer, channel='WA')` (e.g. customer_block event, or admin override)
And a notification in AWAITING_CASCADE for that customer past wa_scheduled_for
When the firer tick runs
Then the notification is set to `CASCADE_CANCELLED` with reason `wa_suppressed`
And NO call is made to AUTH reissueForCascade
And NO call is made to the WhatsApp provider
And `notification.cascade_cancelled` Spring event is emitted
AC-003.4: AUTH reissue 5xx retried up to 3 ticks then FAILED
Given a notification in AWAITING_CASCADE past wa_scheduled_for
When `SignedLinkService.reissueForCascade` returns 5xx for 3 consecutive firer ticks (5 min apart)
Then on tick 4 the notification is set to `FAILED` with `last_failure_class='CASCADE_REISSUE_FAILED'`
And `NOTIF_FAILURE` is emitted with `correlation_id` and `last_channel='WA'`
And `notification.failed` Spring event fires exactly once
Compliance Rules¶
- ARTCI mitigations (5) — all five remain active for the WA cascade leg: opt-out link in body (template-locked), consent flag verified at the SMS leg (carried forward via
notificationrow), privacy notice link in body (template-locked), audit emission per send (this story emitsNOTIF_SENDING+NOTIF_SENT), suppression honored (WA suppression check before reissue). StatusVÉRIFIÉfor the mechanism. - OQ-NOTIF-02 (ARTCI cross-border WA filing) —
INCERTAIN — avocat requis. This is the BLOQUANT carried byFEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED. Pilot proceeds under[FOUNDER-RISK-ACCEPTED 2026-05-20](pre-GA window per architecture §0). PRODUCTION GA requires ARTCI B5 + B10 authorizations (PRD V0 envelope). This slice ships behind the flag and DOES NOT lift the BLOQUANT. - AUTH boundary (NOTIF-ADR-03) — NOTIF cannot mint signed links itself; the reissue MUST go through AUTH
reissueForCascade. Architecture §7 row "AUTH boundary". - Critical-rule #3 — every cascade attempt produces
NOTIF_SENDING+NOTIF_SENTorNOTIF_FAILURE. Audit completeness non-negotiable.
Standards & Conventions¶
docs/standards/critical-rules.mddocs/standards/multi-tenant-model.mddocs/standards/compliance-discipline.md(OQ-NOTIF-02 BLOQUANT positioning +[FOUNDER-RISK-ACCEPTED]marker)docs/standards/java-spring-guidelines.md(@Scheduled,@EventListener,FOR UPDATE SKIP LOCKED, Resilience4j)docs/standards/database-guidelines.md(CAS via conditional UPDATE; partial index usage)docs/standards/api-guidelines.mddocs/standards/multi-datasource-patterns.mddocs/standards/tech-stack.md
Testing Requirements¶
Unit Tests¶
WhatsAppCascadeFirerHappyPathTest—Clock.fixedadvances to T+5d, single-row pick, AUTH reissue mocked, WA WireMock receives one submit.WhatsAppCascadeFirerSuppressionTest— WA suppression row → CASCADE_CANCELLED reason wa_suppressed, AUTH not called, WA not called.CustomerSessionOpenedListenerTest— event arrives for an AWAITING_CASCADE row → CASCADE_CANCELLED + customer_clicked_at set.CascadeReissueRetryExhaustionTest— AUTH returns 5xx → row stays AWAITING_CASCADE on ticks 1-3; on tick 4 → FAILED with CASCADE_REISSUE_FAILED.
Integration Tests¶
WhatsAppCascadeE2ETest— full flow: NOTIF-001a/b ships SMS, Clock advanced to T+5d, firer runs, WA WireMock receives submit, status webhook delivered, notification → DELIVERED.CascadeCancelRaceTest— concurrent firer + listener; assert exactly-one-winner property (AC-003.2 — uses architecture §13.4 sample test structure).FeatureFlagDisabledTest—FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED=false→ firer ticks, no AUTH call, no WA call, rows remain in AWAITING_CASCADE.CascadeMultiTenantIsolationTest— two tenants both at T+5d on the same tick → each row carries its own tenant_id end-to-end; no cross-tenant pollution in audit payloads.
What QA Will Validate¶
- WireMock count: exactly one WA submit per cascade-eligible un-clicked row per tick.
- AUTH call count: at most one
reissueForCascadeper notification per tick (no retry within a single tick). - Feature-flag flip: setting
FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED=falseand re-running the firer produces zero outbound traffic. - Cascade-cancel timing: clicking the SMS link at T+4d59min causes a
CASCADE_CANCELLEDrow + zero WA submit on the next tick.
Out of Scope¶
- AUTH
SignedLinkService.reissueForCascadeimplementation — AUTH-side cross-module dependency (OQ-NOTIF-13). This story HARD-DEPENDS on it being merged. - CP
customer.session_openedevent publication — CP-side cross-module dependency (OQ-NOTIF-14). This story HARD-DEPENDS on the event source being live. - Per-agency partitioning of the firer queue (v0.0.2).
- Redis token-bucket rate-limit + tenant daily cap + platform kill-switch (v0.1.0).
- Operator timeline UI / manual retry (V1).
- WA
customer_blockcapture from Meta (V1 — no WA suppression source in v0.0.0; the WA-suppressed AC-003.3 path is exercised in v0.0.0 only via admin override / phone-based SMS suppression flagged WA at admin time, which itself is V1). - Webhook-loss reconciliation polling (V1 NOTIF-US-06).
- Per-tenant Meta template overrides — V1 (architecture §10).
Definition of Done¶
- [ ]
WhatsAppProviderinterface + at least one concrete adapter (Meta Cloud direct OR BSP — per OQ-NOTIF-01) - [ ]
WhatsAppCascadeFirer@Scheduledtask with batch + tick configurable via properties - [ ]
CustomerSessionOpenedListener@EventListenersubscribed to the CP-published event - [ ] AUTH
SignedLinkService.reissueForCascadeintegration through a Spring named interface - [ ] All 4 ACs pass against WireMock + a mocked AUTH bean
- [ ] CAS race-test in CI (architecture §13.4 pattern) demonstrates exactly-one-winner
- [ ]
FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLEDgates all WA outbound traffic - [ ] Tenant isolation verified (multi-tenant cascade-on-the-same-tick test)
- [ ] Both V1 DB profiles tested
- [ ]
correlation_idpropagated through firer thread MDC + AUTH call + WA provider client-ref + every audit/event payload - [ ] PII hashed in every audit payload via
PiiHasher - [ ] No silent failures: every cascade outcome produces an audit event + a Spring application event
- [ ] AUTH reissue 5xx retry exhaustion lands a
NOTIF_FAILUREwithlast_failure_class='CASCADE_REISSUE_FAILED' - [ ] OQ-NOTIF-02 BLOQUANT explicitly referenced in deploy runbook (production GA needs ARTCI B5+B10)