Architecture — notifications (NOTIF) module¶
Module prefix:
NOTIF· Plane: Control · Scope of this document: v0.0.0 only (NOTIF-001). Forward-compatibility checkpoints are called out for v0.0.1 (consent UI), v0.0.2 (per-agency queue), v0.1.0 (Redis rate-limit + kill-switch + cap), and V1 (operator timeline, manual retry, payment-receipt push, webhook-loss reconciliation).Authored by: ARCHITECT session, 2026-05-21. Builds on
docs/architecture/PROGRESS.mdcross-module decisions D1–D16 — those are reused and not re-debated.Required PRD reads (already absorbed in this design):
docs/prd/notifications-prd.md(V0 envelope §"V0 Envelope Scope" + V1 §§A/B/C as forward-compat reference);docs/prd/prd-v0.md;docs/prd/prd-v1.md§§10, 11.4, 11.7, 15.2, 16.5, 18, 24, 27, 30;docs/architecture/PROGRESS.md;docs/architecture/tenant-admin-arch.md(TenantConfigCache + country_profile);docs/architecture/audit-log-arch.md(PiiHasher, outbox);docs/architecture/identity-arch.md(SignedLinkService).Founder corrections from this session (binding, must be reflected back into the PRDs by ANALYST): - Cascade is SMS-first → WhatsApp at T+5d, not parallel. Cancels on signed-link click. Overrides NOTIF PRD V0 envelope line 19 ("envoi parallèle"). - WhatsApp cascade carries a freshly re-issued signed link (original 48–72h TTL is exhausted at T+5d). Requires new AUTH operation
SignedLinkService.reissueForCascade.
0. v0.0.0 scope summary (executive)¶
| Concern | v0.0.0 status |
|---|---|
| Dispatch endpoint | POST /api/notifications/dispatch — accepted, persisted, async-submitted to SMS provider. HTTP 202 + idempotent dual-guard. |
| Channel cascade | SMS at T0 (immediate); WhatsApp at T+5d only if no signed-link click in between. Cancel signal: customer.session_opened Spring event. |
| Country routing | country_profile lookup wired Day 1, CI seeded only. Provider strategy beans keyed by provider_id. |
| Providers (concrete, pending OQ-NOTIF-01) | letstalk_sms_ci (or Hub2 — founder TBD), meta_wa_cloud_ci (Meta direct or via BSP — founder TBD). Adapter interface fixed; concrete picks land before NOTIF-001 dev-start. |
| ARTCI mitigations (5) | Active Day 1: opt-out link in body, consent flag carried from ingestion, privacy-notice link, AUDIT emission per send, suppression honored. |
| Suppression | suppression(tenant_id, customer_id, channel) consulted before each send. STOP detection on SMS inbound webhook. |
| Audit | NOTIF_SENDING, NOTIF_SENT, NOTIF_FAILURE, NOTIF_SUPPRESSED_OPT_OUT via spring-modulith-events-jdbc outbox. |
| Webhooks | Status webhook + inbound SMS webhook, signature-verified, idempotent on (provider_id, provider_message_id, status). |
| Not in v0.0.0 (designed-for but inactive) | Quiet-hours gate (V1), tenant daily cap (v0.1.0), platform kill-switch (v0.1.0), operator timeline UI (V1), manual retry (V1), payment-receipt push (V1, NOTIF-US-11), per-agency partition (v0.0.2 adds agency_id to queue key), Redis rate-limit (v0.1.0), webhook-loss 5-min reconciliation (V1 NOTIF-US-06), PENDING_QUIET_HOURS / PENDING_CAP / PENDING_PLATFORM_PAUSED / PENDING_TENANT_FROZEN statuses (later sub-versions). |
1. System Context¶
1.1 Position in the platform¶
NOTIF is a Control-plane outbound primitive. It is the only module in v0.0.0 that talks to a customer's phone. Producers (OP in v0.0.0; AUTH and PAY in later versions) ask NOTIF to send a message; NOTIF owns provider selection, the cascade timing, suppression, retention, and the AUDIT trail of every send attempt.
flowchart LR
subgraph "Application plane"
OP[operator-console]
CP[customer-portal]
PAY[payment - V1]
end
subgraph "Control plane"
AUTH[identity]
NOTIF[notifications]
TENANT[tenant-admin]
AUDIT[audit-log]
end
subgraph "External (per country)"
SMS[(SMS aggregator<br/>letstalk_sms_ci)]
WA[(WhatsApp Cloud API<br/>meta_wa_cloud_ci)]
end
OP -- "POST /notifications/dispatch" --> NOTIF
NOTIF -- "snapshot country_code,<br/>provider lookup" --> TENANT
NOTIF -- "reissueForCascade<br/>(at T+5d)" --> AUTH
NOTIF -- "outbox: NOTIF_*" --> AUDIT
NOTIF -- "ApplicationEvent:<br/>notification.dispatched/sent/failed" --> OP
CP -- "ApplicationEvent:<br/>customer.session_opened" --> NOTIF
NOTIF --> SMS
NOTIF --> WA
SMS -. "status + inbound webhook" .-> NOTIF
WA -. "status webhook" .-> NOTIF
1.2 v0.0.0 cross-module dependencies¶
| Direction | Counterpart | Contract | Available |
|---|---|---|---|
| In | OP | POST /api/notifications/dispatch with the v0.0.0 payload (no campaign_id until v0.0.2; v0.0.0 uses an ad-hoc idempotency_key). |
v0.0.0 |
| In | TENANT | TenantConfigCache bean (D-shared) → reads tenant.country_code, tenant.notif.sms_sender_id, tenant.notif.wa_business_id. |
v0.0.0 |
| In | TENANT | country_profile row for CI: columns notif_provider_sms, notif_provider_wa, sms_sender_id_default. Cross-module impact — TENANT migration must seed this. |
v0.0.0 |
| In | AUTH | SignedLinkService.reissueForCascade(originalLinkId, ttl) — new operation, AUTH-side cross-module impact (flagged below). |
v0.0.0 |
| In | AUTH/CP | Spring application event customer.session_opened {tenant_id, customer_id, signed_link_id, opened_at} — owner: CP (consumed by NOTIF). Cross-module impact. |
v0.0.0 |
| Out | AUDIT | Outbox events: NOTIF_SENDING, NOTIF_SENT, NOTIF_FAILURE, NOTIF_SUPPRESSED_OPT_OUT. AUDIT catalog must be extended before NOTIF-001 ships (OQ-NOTIF-04 — folded into AUDIT-002). |
v0.0.0 |
| Out | OP | Spring application events notification.dispatched, notification.sent, notification.failed. OP consumes for the file timeline (UI rendered in V1; v0.0.0 emits silently). |
v0.0.0 |
1.3 OQ-X3 confirmation¶
PROGRESS.md D16 locks correlation_id origin = OP at OP_CAMPAIGN_VALIDATED, propagated downstream. NOTIF v0.0.0 confirms this default: NOTIF never originates a correlation_id. The POST /dispatch payload carries it from OP; NOTIF forwards it to every AUDIT event, every Spring application event, and (when the cascade fires) into AUTH's reissueForCascade call so the re-issued link inherits the same chain.
2. Data Model¶
All NOTIF tables live on the platform DB regardless of tenant db_profile (TV-05; mirrors AUDIT D12 / AUDIT-ADR-01). Rationale: a BYO-DB outage must not stop renewal notifications from going out.
2.1 ERD (v0.0.0)¶
erDiagram
notification ||--o{ delivery_attempt : has
notification }o--|| tenant : "tenant_id (platform DB FK)"
suppression }o--|| tenant : "tenant_id (platform DB FK)"
delivery_attempt ||--o{ delivery_status_event : "1..N (JSONB array)"
notification {
uuid notification_id PK "UUIDv7"
uuid tenant_id FK
uuid agency_id "nullable in v0.0.0 (NOT NULL from v0.0.2)"
char(2) country_code "snapshot from tenant at enqueue"
uuid customer_id
uuid contract_id
text message_type "RENEWAL_REMINDER_INITIAL (v0.0.0)"
smallint iteration "1 in v0.0.0; multi from v0.1.0"
uuid idempotency_key
uuid signed_link_id "AUTH-issued"
text signed_link_url "first SMS link"
uuid cascade_signed_link_id "set when WA cascade fires (re-issued)"
text cascade_signed_link_url "set at T+5d"
text customer_phone_e164
text customer_first_name
text tenant_name_snapshot
date due_date
bigint amount_minor_xof
uuid correlation_id "from OP per D16"
bool customer_consented "from ingestion (mitigation #2)"
timestamptz enqueued_at
timestamptz sms_sent_at "set when SMS attempt reaches SENT"
timestamptz customer_clicked_at "set on customer.session_opened"
timestamptz wa_scheduled_for "computed as sms_sent_at + 5 days"
timestamptz wa_dispatched_at "set when WA attempt is submitted"
text status "PENDING|DISPATCHING_SMS|SMS_SENT|AWAITING_CASCADE|DISPATCHING_WA|DELIVERED|FAILED|OPT_OUT|CASCADE_CANCELLED"
text last_failure_class "nullable"
timestamptz terminal_event_published_at "guards exactly-once terminal Spring event"
}
delivery_attempt {
uuid attempt_id PK "UUIDv7"
uuid notification_id FK
uuid tenant_id "denormalized"
text channel "SMS|WA"
text provider_id "e.g. letstalk_sms_ci"
text provider_message_id "UNIQUE with provider_id"
smallint attempt_index "1 in v0.0.0 (no retries yet)"
text status "SUBMITTING|SENT|DELIVERED|FAILED|HARD_ERROR"
timestamptz submitted_at
timestamptz last_status_at
jsonb status_timeline "array of {status, at, raw_response_truncated <=8KB}"
}
suppression {
uuid suppression_id PK
uuid tenant_id
uuid customer_id "nullable; some STOPs identify only by phone in v0.0.0"
text phone_e164 "supports phone-only opt-out (mitigation #5)"
text channel "SMS|WA"
text reason "customer_stop|customer_block|admin_override"
timestamptz suppressed_at
timestamptz removed_at "platform-admin only; V1"
}
tenant {
uuid tenant_id PK
char(2) country_code
text status
}
2.2 Index strategy (v0.0.0)¶
| Table | Index | Used by |
|---|---|---|
notification |
UNIQUE (tenant_id, idempotency_key) |
FR-02 idempotent replay |
notification |
UNIQUE (tenant_id, customer_id, message_type, iteration) |
Natural-key guard (FR-02 alinéa 2) |
notification |
(status, wa_scheduled_for) WHERE status = 'AWAITING_CASCADE' |
WhatsAppCascadeFirer partial index — scan stays O(due-now) |
notification |
(tenant_id, customer_id, enqueued_at DESC) |
Future operator timeline (V1) — created Day 1 |
notification |
(correlation_id) |
AUDIT case-key lookup (D16 chain) |
delivery_attempt |
UNIQUE (provider_id, provider_message_id) |
Webhook idempotency |
delivery_attempt |
(notification_id, attempt_index) UNIQUE |
FK locality |
suppression |
UNIQUE (tenant_id, COALESCE(customer_id, '00000000-0000-0000-0000-000000000000'::uuid), phone_e164, channel) WHERE removed_at IS NULL |
FR-06 lookup pre-dispatch |
suppression |
(tenant_id, phone_e164) partial WHERE removed_at IS NULL |
STOP webhook lookup (phone-only) |
Why a partial index on AWAITING_CASCADE: it is the single index the WhatsAppCascadeFirer job hits every 5 minutes. Because it filters on a small subset (notifications past T0 but before T+5d AND not yet clicked), the index stays small even at scale. A regular (status, wa_scheduled_for) index would bloat with all terminal rows.
2.3 Notification state machine (v0.0.0)¶
stateDiagram-v2
[*] --> PENDING: POST /dispatch accepted
PENDING --> OPT_OUT: suppression hit on every viable channel<br/>(emit NOTIF_SUPPRESSED_OPT_OUT)
PENDING --> DISPATCHING_SMS: @Async submit to SMS provider
DISPATCHING_SMS --> SMS_SENT: provider 2xx (set sms_sent_at, wa_scheduled_for = sms_sent_at + INTERVAL '5 days')
DISPATCHING_SMS --> FAILED: provider hard-error (no retry in v0.0.0)
SMS_SENT --> AWAITING_CASCADE: same row, after-commit transition
AWAITING_CASCADE --> CASCADE_CANCELLED: customer.session_opened received
AWAITING_CASCADE --> DISPATCHING_WA: WhatsAppCascadeFirer picks row at >= wa_scheduled_for
DISPATCHING_WA --> DELIVERED: WA provider 2xx + delivery webhook (or accept SENT in v0.0.0)
DISPATCHING_WA --> FAILED: WA provider hard-error (no retry in v0.0.0)
SMS_SENT --> DELIVERED: SMS delivery webhook (terminal happy path)
CASCADE_CANCELLED --> [*]
DELIVERED --> [*]
FAILED --> [*]
OPT_OUT --> [*]
Statuses reserved but unreachable in v0.0.0 (designed Day 1 to avoid V1 schema churn): PENDING_QUIET_HOURS, PENDING_CAP, PENDING_PLATFORM_PAUSED, PENDING_TENANT_FROZEN. The status column is declared as TEXT (not an enum) precisely so later sub-versions can add values without migration.
3. API Design¶
3.1 v0.0.0 endpoints (claimed namespace /api/notifications/... per PROGRESS.md)¶
| Method | Path | Caller | v0.0.0 |
|---|---|---|---|
| POST | /api/notifications/dispatch |
OP (operator-scoped JWT) | ✅ |
| POST | /api/v1/notifications/webhooks/{providerId}/status (URI-versioned carve-out) |
Provider (HMAC-signed) | ✅ |
| POST | /api/v1/notifications/webhooks/{providerId}/inbound (URI-versioned carve-out) |
Provider (HMAC-signed) | ✅ — STOP detection |
| GET | /api/notifications/{id} (lightweight status) |
OP | ✅ — sync polling for OP file detail |
API versioning (D31): business endpoints carry the API version in the
X-API-Versionrequest header — there is no/v1path segment. The webhook ingress rows above are the documented exception: callback URLs registered with external providers (SMS gateway) retain URI versioning (/api/v1/notifications/webhooks/...) because third-party callers cannot reliably send custom headers.
Not in v0.0.0: /admin/kill-switch, /customers/{id}/timeline, /notifications/{id}/retry, /admin/suppressions/{id}/remove — paths reserved, controllers stubbed-out for V1.
3.2 POST /dispatch contract (v0.0.0)¶
// Request
{
"tenant_id": "uuid", // redundant with JWT but explicit; rejected if mismatched
"customer_id": "uuid",
"contract_id": "uuid",
"message_type": "RENEWAL_REMINDER_INITIAL",
"iteration": 1,
"customer_phone_e164": "+22507XXXXXXXX",
"customer_first_name": "Aïssatou",
"due_date": "2026-06-15",
"amount_minor_xof": 15000, // integer, XOF minor units = 0-dec (NFR-08)
"signed_link_id": "uuid",
"signed_link_url": "https://app.papillon.ci/portail/abc...",
"customer_consented": true, // ARTCI mitigation #2: carried from ingestion
"correlation_id": "uuid", // from OP per D16
"idempotency_key": "uuid" // mandatory; producer-generated
}
// Response 202
{ "notification_id": "uuid", "status": "PENDING" }
// Response 200 (idempotent replay within 24h)
{ "notification_id": "uuid", "status": "<current>" }
// Response 409 (natural-key conflict within 60s, different idempotency_key)
{ "reason": "natural_key_conflict", "existing_notification_id": "uuid" }
// Response 400 (validation)
{ "reason": "missing_variable" | "unknown_message_type" | "phone_invalid_e164" | "tenant_country_unsupported", "field": "..." }
Validation rules (v0.0.0):
- message_type ∈ {RENEWAL_REMINDER_INITIAL} — single value Day 1; enum is open.
- customer_consented MUST be true (mitigation #2 — pre-checked broker-side during ingestion).
- customer_phone_e164 MUST match ^\+225[0-9]{10}$ (CI seed; regex picked from country profile).
- tenant.country_code MUST equal a country with a seeded profile (= CI only in v0.0.0); else 400 tenant_country_unsupported.
3.3 Webhook contracts (provider-agnostic envelope)¶
// Status webhook: POST /api/v1/notifications/webhooks/{providerId}/status
// Headers: X-Signature: hmac-sha256=<hex> (provider-specific scheme captured per adapter)
{
"provider_message_id": "...",
"status": "DELIVERED" | "FAILED" | "SENT",
"failure_reason": "..." // present only on FAILED
}
// Inbound webhook: POST /api/v1/notifications/webhooks/{providerId}/inbound
{
"from_phone_e164": "+22507XXXXXXXX",
"body": "STOP",
"received_at": "2026-05-21T10:23:45Z"
}
Idempotency: dedup key = (providerId, provider_message_id, status) — (provider_id, provider_message_id) UNIQUE on delivery_attempt + an idempotency table on inbound is reserved for V1; in v0.0.0 we treat duplicate STOPs as upserts on suppression.
3.4 Pagination, versioning¶
GET /notifications/{id} is single-resource — no pagination. Operator-timeline endpoint (V1) will follow PROGRESS.md D6 (opaque cursor base64 of (enqueued_at, id), default 25, max 100). The path-version v1 is locked at namespace level (D5).
4. Multi-Tenant Strategy¶
4.1 Tenant resolution¶
- Producer side (OP → NOTIF): Keycloak JWT
tenant_idclaim per D3. NOTIF cross-checks the request body'stenant_idagainst the JWT; mismatch = 403tenant_mismatch. - Webhook side (provider → NOTIF): no tenant in the HTTP request — resolved by
provider_message_id→delivery_attempt.tenant_id(denormalized for this exact reason). A webhook arriving for an unknownprovider_message_idreturns 200 (no-op, to avoid provider retries spamming us) but emitsNOTIF_WEBHOOK_REJECTEDaudit. v0.0.0 ships this minimal version; V1 adds full anti-spoofing chain via signature +provider_idallow-list. - AUTH/CP event side:
customer.session_openedevent carriestenant_id; NOTIF filters on it before clearing the cascade.
4.2 V1 DB-profile compatibility¶
Per TV-05 + D12: NOTIF tables are on platformDataSource for both SHARED and BYO profiles. A BYO-DB tenant still has its NOTIF rows on the central platform DB. Documented in BYO-DB onboarding contract (TENANT owns this surface). Tenant resolver still applies on every query — tenant_id filter is non-negotiable per critical-rule #1.
4.3 Agency sub-scoping (v0.0.0 vs v0.0.2)¶
The notification.agency_id column is nullable in v0.0.0 because v0.0.0 ships single-agency tenants. From v0.0.2:
- TENANT introduces multi-agency.
- A NOT-NULL-after-backfill migration is added.
- WhatsAppCascadeFirer adds agency_id as a partition key for fair scheduling across agencies (no agency can starve another).
V0.0.0 design choice: write agency_id as the tenant's default_agency_id whenever available, so the v0.0.2 backfill is a no-op for v0.0.0 rows.
5. Connectivity & Resumability¶
NOTIF is server-side; no customer-facing surface in v0.0.0 (the consent UI is v0.0.1, owned by CP). NOTIF participates in the customer's signed-link flow only as the carrier.
| Resilience concern | v0.0.0 mechanism |
|---|---|
| Crash between accept and SMS submit | notification row + outbox NOTIF_SENDING written in same TX as HTTP accept; @Async SMS submit happens after-commit. If JVM dies before submit, the SmsSubmitRecovery job (runs on startup) finds rows in DISPATCHING_SMS older than 60s and resubmits. Bounded by delivery_attempt.attempt_index. |
| Crash between SMS submit and provider 2xx | The SMS adapter call is idempotent via the delivery_attempt.attempt_id carried as the provider's client-side idempotency key (LetsTalk + Meta both support a client-ref). A re-submit returns the same provider_message_id. |
| Crash mid-cascade firing | WhatsAppCascadeFirer uses FOR UPDATE SKIP LOCKED on the AWAITING_CASCADE partial index; a crashed firer releases its locks on TX rollback; next tick picks the row up. |
| Webhook lost | NOT handled in v0.0.0. V1 NOTIF-US-06 adds 5-min polling reconciliation. v0.0.0 risk: a notification stuck at SMS_SENT without a delivery webhook never advances to DELIVERED. Mitigation: cascade still fires at T+5d if no customer_clicked_at arrives, regardless of webhook status. The DELIVERED enrichment is best-effort in v0.0.0. |
| Provider down | v0.0.0 has no retry policy (the 5 mitigations list this as v0.1.0). A hard-error → FAILED. A network timeout → FAILED after the adapter's per-call 10s budget. Operator visibility is via the notification.failed Spring event (UI surface lands in V1). |
| Cascade cancel race | customer.session_opened arrives after WhatsAppCascadeFirer has picked the row but before WA submit → handled by the firer's UPDATE notification SET status='DISPATCHING_WA' WHERE id=? AND status='AWAITING_CASCADE' AND customer_clicked_at IS NULL (compare-and-swap). 0 rows updated → firer aborts, leaves row at CASCADE_CANCELLED. |
correlation_id propagation |
Carried in MDC for every request thread + every @Async task + every scheduled task. AUDIT events inherit it (D16 chain). |
5.1 Signed-link cascade — the AUTH boundary¶
At T+5d, NOTIF needs a fresh signed link for the WhatsApp body. Architecturally:
sequenceDiagram
participant Firer as WhatsAppCascadeFirer (NOTIF)
participant DB as platform DB
participant Auth as SignedLinkService (AUTH)
participant WA as WhatsApp Cloud API
participant Outbox as event_publication
participant Audit as AUDIT consumer
Firer->>DB: SELECT FOR UPDATE SKIP LOCKED WHERE status='AWAITING_CASCADE' AND wa_scheduled_for <= now()
DB-->>Firer: notification_id, signed_link_id (original), tenant_id, ...
Firer->>Auth: reissueForCascade(original_link_id, ttl=72h)
Auth->>DB: insert new signed_link row, same correlation_id, same case key, lineage_of=<original>
Auth-->>Firer: new signed_link_id + url
Firer->>DB: 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
alt 0 rows updated (customer clicked in race)
Firer->>DB: UPDATE status='CASCADE_CANCELLED'
Firer->>Outbox: enqueue notification.cascade_cancelled
else 1 row updated
Firer->>WA: send template message with new url
WA-->>Firer: 2xx + wa_provider_message_id
Firer->>DB: insert delivery_attempt (channel=WA, provider_id, provider_message_id, status=SENT)
Firer->>Outbox: enqueue NOTIF_SENDING + notification.dispatched (channel=WA)
end
Outbox-->>Audit: redeliver
6. Sequence Diagrams¶
6.1 Happy path — SMS at T0, customer clicks within 5 days¶
sequenceDiagram
participant OP
participant NOTIF
participant DB
participant Outbox
participant SMS as SMS provider
participant CP
participant Audit
OP->>NOTIF: POST /dispatch (idempotency_key, correlation_id, ...)
NOTIF->>DB: INSERT notification (status=PENDING), INSERT delivery_attempt (channel=SMS, status=SUBMITTING)
NOTIF->>DB: check suppression(tenant_id, customer_id, phone, SMS) → no row
NOTIF->>Outbox: NOTIF_SENDING (channel=SMS)
NOTIF-->>OP: 202 {notification_id, status=PENDING}
Note over NOTIF: TX commits → @Async resumes
NOTIF->>SMS: submit (client_ref = delivery_attempt.attempt_id)
SMS-->>NOTIF: 200 {provider_message_id}
NOTIF->>DB: UPDATE notification SET status=SMS_SENT, sms_sent_at=now(), wa_scheduled_for=now()+5d
NOTIF->>DB: UPDATE delivery_attempt SET status=SENT, provider_message_id=?
NOTIF->>Outbox: NOTIF_SENT + notification.sent
Outbox-->>Audit: redeliver
Note over CP: T+2 days — customer opens portal via signed link
CP-->>NOTIF: ApplicationEvent customer.session_opened {tenant_id, customer_id, signed_link_id}
NOTIF->>DB: UPDATE notification SET status=CASCADE_CANCELLED, customer_clicked_at=now() WHERE signed_link_id=? AND status=AWAITING_CASCADE
NOTIF->>Outbox: notification.cascade_cancelled
6.2 Cascade path — no click, WhatsApp at T+5d¶
sequenceDiagram
participant Firer as WhatsAppCascadeFirer
participant DB
participant Auth
participant WA as WhatsApp Cloud API
participant Outbox
Note over Firer: Every 5 min
Firer->>DB: SELECT ... WHERE status=AWAITING_CASCADE AND wa_scheduled_for <= now() FOR UPDATE SKIP LOCKED LIMIT 50
DB-->>Firer: rows
loop per row
Firer->>DB: check suppression(tenant, customer, WA)
alt WA suppressed
Firer->>DB: status=CASCADE_CANCELLED (reason=wa_suppressed)
Firer->>Outbox: notification.cascade_cancelled
else not suppressed
Firer->>Auth: reissueForCascade(original_link_id)
Auth-->>Firer: new signed_link_id + url
Firer->>DB: UPDATE ... SET status=DISPATCHING_WA ... WHERE customer_clicked_at IS NULL (CAS)
alt CAS fails
Firer->>DB: status=CASCADE_CANCELLED (reason=race_with_click)
else CAS wins
Firer->>WA: send template message
WA-->>Firer: 200 {provider_message_id}
Firer->>DB: INSERT delivery_attempt (WA, SENT)
Firer->>Outbox: NOTIF_SENDING + NOTIF_SENT + notification.sent (channel=WA)
end
end
end
6.3 Provider-status-unknown (v0.0.0 honest scope)¶
sequenceDiagram
participant NOTIF
participant SMS
participant DB
NOTIF->>SMS: submit
SMS-->>NOTIF: 200 {provider_message_id}
NOTIF->>DB: delivery_attempt.status=SENT
Note over SMS: Delivery webhook never fires (provider drops it)
Note over NOTIF: V0.0.0 has no 5-min polling reconciliation (V1 NOTIF-US-06)
Note over NOTIF: notification stays at SMS_SENT forever; the cascade still fires at T+5d if no customer click
Note over NOTIF: This is acknowledged honest scope. Operator UI is V1; ARTCI proof obligation is met by the NOTIF_SENT audit event (proof of submission, not delivery).
6.4 STOP path¶
sequenceDiagram
participant SMS as SMS provider (inbound)
participant NOTIF
participant DB
participant Outbox
SMS->>NOTIF: POST /webhooks/letstalk_sms_ci/inbound (signed) {from_phone, body: "STOP"}
NOTIF->>NOTIF: verify HMAC
alt invalid signature
NOTIF-->>SMS: 401
NOTIF->>Outbox: NOTIF_WEBHOOK_REJECTED (NOT in v0.0.0 audit catalog — emit as a log line in v0.0.0; audit-eventify at v0.1.0)
else valid
NOTIF->>NOTIF: normalize body (trim, upper, strip accents)
alt body in {STOP, ARRET, NON, NO}
NOTIF->>DB: UPSERT suppression(tenant_id, NULL customer_id, phone, channel=SMS, reason=customer_stop)
NOTIF-->>SMS: 200
else
NOTIF-->>SMS: 200 (no-op, conversational text dropped)
end
end
tenant_id for an inbound STOP is resolved by joining on a recent notification.customer_phone_e164 for the same provider_id. If multiple tenants have the same phone in their recent sends, the STOP is recorded per tenant the phone appears under in the last 14 days (per-tenant scope, AC-07.4). This is v0.0.0-acceptable; V1 may tighten if multi-tenant phone collision becomes a real issue.
7. Security¶
| Concern | v0.0.0 implementation |
|---|---|
| Producer auth | Keycloak JWT (D3) on POST /dispatch — operator role required, tenant_id claim cross-checked against request body. |
| Webhook auth | Provider HMAC signature (per-provider scheme via adapter). Body bytes signed; replay window 5 min; nonce store deferred to V1. |
| AUTH boundary | SignedLinkService.reissueForCascade is an in-process Spring named interface (Spring Modulith API), not HTTP. AUTH owns authorization; NOTIF cannot mint links itself. |
| Tenant isolation | Every query carries tenant_id. Code-review enforced. JdbcClient access pattern per D2. |
| Encryption in transit | TLS 1.2+ on every external edge (producer HTTPS, provider HTTPS, webhook ingress HTTPS). |
| Encryption at rest | PostgreSQL volume-level (deploy-default). signed_link_url, cascade_signed_link_url, customer_phone_e164 are minimum-access columns; column-level encryption considered V2. |
| Secrets | Provider API keys + webhook signing secrets via environment variables in v0.0.0 (Coolify secret store); rotation = redeploy. KMS-backed secret store deferred to v0.1.0 alongside the BYO-DB KEK pattern (D8). |
| PII in audit | PiiHasher (AUDIT-owned, D-shared) applied to phone + customer name before any AUDIT payload assembly (per audit-log AC-06). Raw PII stays on the notification row only. |
| Audit completeness | NOTIF_SENDING, NOTIF_SENT, NOTIF_FAILURE, NOTIF_SUPPRESSED_OPT_OUT per send. Mandatory fields per audit-log §B.3 NOTIF row. Catalog extension is a v0.0.0 prerequisite — AUDIT-002 ships it. |
| Webhook-rejected logging | In v0.0.0 emitted as a structured WARN log (since the AUDIT catalog entry NOTIF_WEBHOOK_REJECTED lands at v0.1.0 per OQ-NOTIF-04). Log retention via Coolify; will be back-filled to AUDIT once the catalog admits it. |
8. Performance & Bandwidth Budget¶
NOTIF has no customer-facing payload in v0.0.0 (the CP disclosure surface NOTIF-US-08 is V1). Performance budget is server-side only:
| Metric | v0.0.0 target |
|---|---|
POST /dispatch p95 (enqueue only) |
≤ 200 ms (NFR-01) |
| End-to-end accept → SMS submit | p95 ≤ 5 s (smaller than NFR-03 30s because no quiet-hours, no cap to gate on) |
WhatsAppCascadeFirer batch size |
50 rows per tick @ 5 min interval — sized for 600 cascades/hour cap. Tunable via property. |
AUDIT write impact on /dispatch p95 |
≤ 0 ms (outbox writes are in same TX; no extra round-trip vs business write — D4/D7 outbox is local Postgres insert) |
| Provider call timeout | 10 s per attempt (Resilience4j config); after that = FAILED. |
Caching: TenantConfigCache (D-shared) is read on every /dispatch. country_profile is read at the same call site through TENANT's existing cache. No NOTIF-owned cache layer in v0.0.0.
9. Migration Strategy¶
Flyway path db/migration/platform/ per D9. AUDIT-style append-only role separation is NOT applied to NOTIF tables — NOTIF needs UPDATE/DELETE on its own rows (status transitions, suppression removal). Single role papillon_app has full DML on NOTIF tables.
9.1 v0.0.0 migrations (ordered)¶
V0_0_0__notif_create_notification.sql—notificationtable + 5 indexes.V0_0_0__notif_create_delivery_attempt.sql—delivery_attempttable + 2 indexes.V0_0_0__notif_create_suppression.sql—suppressiontable + 2 partial indexes.V0_0_0__notif_seed_country_profile_notif_columns.sql— TENANT-owned migration, listed here for traceability. Addsnotif_provider_sms,notif_provider_wa,sms_sender_id_defaultcolumns tocountry_profile. Seed row:('CI', 'letstalk_sms_ci', 'meta_wa_cloud_ci', 'PAPILLON').V0_0_0__notif_register_outbox_producer.sql— no-op DDL; declares NOTIF as an outbox producer for spring-modulith-events-jdbc (purely operational metadata).
Reversibility: each Vx__notif_*.sql is matched by a Ux__notif_*.sql undo file (drop table for net-new tables; revert alter for column adds). v0.0.0 has no production data yet, so reversibility is mostly procedural.
9.2 Future forward-compat (designed-for, not migrated yet)¶
- v0.0.2 will add
agency_id NOT NULLpost-backfill onnotification— backfill is trivial for v0.0.0 single-agency rows. - v0.1.0 will add
notif_kill_switch (paused BOOL, engaged_at, engaged_by, reason)single-row table on platform DB. - V1 will add
manual_retry_count SMALLINT NOT NULL DEFAULT 0onnotification.
None of these require restructuring v0.0.0 tables; all are additive.
10. Integration Points¶
| External | v0.0.0 status |
|---|---|
SMS aggregator (letstalk_sms_ci) |
Adapter SmsProvider interface implemented by LetsTalkSmsAdapter (or Hub2SmsAdapter per founder OQ-NOTIF-01). Stubbed via WireMock in tests. |
WhatsApp Cloud API (meta_wa_cloud_ci) |
Adapter WhatsAppProvider interface. Direct Meta API (or via BSP per OQ-NOTIF-01). Template ID hard-coded via env var in v0.0.0; per-tenant template = V1. |
| CSV/XLSX ingestion | Not a NOTIF concern. ING owns ingestion; OP owns campaign validation; NOTIF receives /dispatch calls only. |
| Webhooks (in) | POST /api/v1/notifications/webhooks/{providerId}/{status|inbound} (URI-versioned carve-out per D31) — HMAC signature verified per adapter. |
| Outbound platform→tenant e-invoicing (FNE) | Not a NOTIF concern — BILL handles. NOTIF's audit events feed BILL's send-counting at V1; not before. |
| AUDIT outbox | spring-modulith-events-jdbc — D4. Same Postgres, same TX. papillon_app role writes; AUDIT consumer reads. |
| TENANT directory | TenantConfigCache bean — read-only access. |
| AUTH | SignedLinkService.reissueForCascade(original_link_id, ttl) Spring named interface — new operation. |
11. Vertical Slice Decomposition¶
PO input — not for DEVELOPER. The DEVELOPER reads only the story file at
docs/stories/v0.0.0/notifications/<STORY-ID>.md. This section exists for the PRODUCT_OWNER to decompose into self-contained stories.
Tier 0 (serial) — must merge before any other NOTIF slice¶
NOTIF-S0 — Dev environment + test infrastructure for NOTIF (v0.0.0)¶
- Scope: Extend the existing
docker-compose.yml(introduced by TENANT S0) with: WireMock service for SMS/WA provider stubs; a smallnotif-fixtureshelper to load tenant + signed-link fixtures into the platform DB; Testcontainers wiring for the same. No new production dep — Postgres + Keycloak + Redis (cache only, not yet queue) + MinIO are inherited from TENANT S0. - Files:
docker-compose.yml(edit),backend/src/test/resources/wiremock/sms-letstalk/*.json,backend/src/test/resources/wiremock/wa-meta/*.json,backend/src/test/java/.../NotifTestContainersConfig.java,backend/src/test/java/.../NotifFixtures.java. - Complexity: S
- Dependencies: TENANT S0 merged; AUDIT S0 merged.
- ACs (3): WireMock up via
docker-compose up; sample TestContainers test for a NOTIF table boots + inserts + queries with tenant filter; fixture loads a CI tenant + a seededcountry_profile.CIrow.
Tier 1 — parallelizable after Tier 0¶
NOTIF-001 — Synchronous dispatch + immediate SMS send (v0.0.0 core happy path)¶
- Scope:
POST /api/notifications/dispatchend-to-end. Idempotency dual-guard. Suppression pre-check.@AsyncSMS submit viaSmsProvider.send. Status webhook ingestion (status only, not inbound STOP). Outbox emission forNOTIF_SENDING/NOTIF_SENT/NOTIF_FAILURE/NOTIF_SUPPRESSED_OPT_OUT. - Files:
backend/src/main/java/.../api/DispatchController.java,.../api/StatusWebhookController.java,.../domain/NotificationService.java,.../adapter/sms/LetsTalkSmsAdapter.java,.../adapter/sms/SmsProvider.java, Flyway migrations V0_0_0__*. - Complexity: L — split candidate (see below).
- Dependencies: NOTIF-S0; AUDIT-002 must ship the catalog extension for
NOTIF_SUPPRESSED_OPT_OUTfirst (OQ-NOTIF-04 blocker). - ACs (4): AC-01.1 (single dispatch contract); AC-01.2 (idempotency wins); AC-01.4 (mandatory-field gate); 5-mitigation audit emission (NOTIF_SENDING + NOTIF_SENT or NOTIF_SUPPRESSED_OPT_OUT visible in AUDIT).
- Split-candidate sub-slices (PO decision):
- NOTIF-001a — Dispatch endpoint + DB persistence + idempotency dual-guard (no provider call yet; status stays PENDING; happy path verifiable from DB).
- NOTIF-001b — SmsProvider adapter (LetsTalk or Hub2) +
@Asyncsubmit + status webhook + delivery_attempt lifecycle. - NOTIF-001c — Suppression table + pre-dispatch lookup +
NOTIF_SUPPRESSED_OPT_OUTemission. - NOTIF-001d — Country-profile resolver + provider strategy bean wiring (CI only).
NOTIF-002 — Inbound STOP detection (mitigation #5 closure)¶
- Scope:
POST /api/v1/notifications/webhooks/{providerId}/inboundcontroller. Body normalization, token match, upsert suppression. Per-tenant scoping via 14-day phone-→-tenant lookup. - Files:
.../api/InboundWebhookController.java,.../domain/StopDetector.java,.../domain/SuppressionService.java. - Complexity: M
- Dependencies: NOTIF-001a merged (need notification rows to derive tenant from phone).
- ACs (3): STOP body normalized + suppression row upserted; per-tenant scope (no cross-tenant leak); duplicate STOP → idempotent upsert.
NOTIF-003 — WhatsApp cascade at T+5d¶
- Scope:
WhatsAppCascadeFirerscheduled task (every 5 min).customer.session_openedconsumer cancels. AUTHreissueForCascadeintegration.WhatsAppProvideradapter for Meta Cloud (or BSP). Status webhook reused from NOTIF-001b. - Files:
.../job/WhatsAppCascadeFirer.java,.../event/CustomerSessionOpenedListener.java,.../adapter/wa/MetaWaCloudAdapter.java,.../adapter/wa/WhatsAppProvider.java. - Complexity: L
- Dependencies: NOTIF-001b merged (SMS-submit pipeline reused for WA submit); AUTH must ship
reissueForCascade(cross-module dependency — AUTH session needs a slice for this). - ACs (4): cascade fires at >= T+5d only for un-clicked rows; CAS prevents race with late click; AUTH reissue produces a fresh link before WA send; WA delivery_attempt + NOTIF_SENDING audit emitted with
channel=WA.
NOTIF-004 — Crash-recovery startup job (SmsSubmitRecovery)¶
- Scope: Startup-time scan for
DISPATCHING_SMSrows older than 60s with noprovider_message_id. Resubmit via the same adapter (idempotent onattempt_id). - Files:
.../job/SmsSubmitRecovery.java, startup wiring inNotifConfiguration. - Complexity: S
- Dependencies: NOTIF-001b.
- ACs (2): simulated crash mid-submit → on restart, the stuck row is recovered exactly-once; resubmit returns same
provider_message_id(adapter-level idempotency).
12. Technical Risks¶
| ID | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | AUDIT catalog extension (NOTIF_SUPPRESSED_OPT_OUT) is not shipped before NOTIF-001 dev-start. |
M | High | NOTIF-001 explicitly blocked on AUDIT-002. Tracked in PROGRESS.md cross-module table. Founder owns the AUDIT PRD rework before NOTIF-001 enters backlog. |
| R2 | OQ-NOTIF-02 (ARTCI cross-border WA filing) returns "filing required" before v0.0.0 GA — would freeze WA cascade. | M | Critical | v0.0.0 pilot is pre-GA per founder risk accept [FOUNDER-RISK-ACCEPTED 2026-05-20]. If filing required, WA cascade ships behind feature flag feature.notif.whatsapp-cascade.enabled (default false) until B5+B10 notified. SMS-only fallback remains live. |
| R3 | Webhook signature verification fails for malformed provider payloads (DoS via signature-check CPU). | L | Medium | Reject pre-signature on Content-Length cap (8 KB), Content-Type allow-list. Provider IP allow-list at edge (Coolify) once OQ-NOTIF-01 finalises provider choice. |
| R4 | Cascade firer batch size starves under load (50/tick × 5min = 36000/day; pilot single-cabinet stays under, but a misconfigured operator could blow it). | L | Medium | Property-configurable batch size. v0.1.0 layers Redis token-bucket rate-limit (designed in §11 forward-compat). |
| R5 | reissueForCascade at T+5d fails (AUTH returns 5xx). Notification stuck at AWAITING_CASCADE past wa_scheduled_for. |
L | Medium | Retry in firer on next tick (5 min); after 3 ticks, set FAILED + last_failure_class=CASCADE_REISSUE_FAILED. AUDIT NOTIF_FAILURE emits. |
| R6 | STOP webhook arrives for a phone that's never been in NOTIF (someone else's STOP routed wrong). Phantom suppression row. | L | Low | 14-day phone-→-tenant lookup window prevents the most-wrong case. If no tenant ties to the phone, log + drop (no suppression row). V1 hardens with per-provider scoping. |
| R7 | Founder pivots provider choice (OQ-NOTIF-01) after NOTIF-001 ships → adapter rewrite. | M | Low | SmsProvider / WhatsAppProvider interfaces are minimal (3 methods each). Adapter switch is one class + one config row in country_profile. |
| R8 | customer.session_opened event is emitted by CP but CP slice doesn't ship in time → cascade can never be cancelled, always fires WA. |
M | Medium | NOTIF-003 explicitly depends on a CP slice that publishes the event. PROGRESS.md cross-module table flags this. If CP slips, NOTIF-003 holds. |
| R9 | Founder's PRD V0 envelope (parallel SMS+WA) hasn't been updated to reflect SMS-first cascade — ANALYST may rework PRD against an out-of-date spec. | H | Low | This document lists "PRD updates needed after session" front-matter. ANALYST must consume that list before reworking the PRD. |
| R10 | NOTIF-001 ships SMS path in v0.0.0 before OQ-NOTIF-02 resolves; ARTCI filing later mandates retroactive suppression of v0.0.0 sends. | L | Medium | The 5 mitigations are active Day 1; consent flag + opt-out link + privacy notice + audit trail per send + suppression honored is the defense. Founder risk-accept covers the pre-GA window. |
13. Test Infrastructure¶
13.1 TestContainers + WireMock setup¶
- Postgres TestContainer — reused from TENANT S0; one image, one schema migration replay per suite via Flyway.
papillon_approle used. - WireMock — runs as a docker-compose service in dev + as a per-test
WireMockExtensionin JUnit. Two stub libraries: wiremock/sms-letstalk/—POST /v2/messages200 OK,POST /v2/messages5xx (transient),POST /v2/messages422 (hard-error).wiremock/wa-meta/—POST /v17.0/{phone_number_id}/messages200, 4xx (USER_NOT_ON_WHATSAPP, BUSINESS_BLOCKED), 5xx.- Spring Modulith outbox —
spring-modulith-testlibrary replays events synchronously in tests; assertions onAuditEventBuilderinvocations done via the existing AUDIT test harness. - Clock —
Clockbean injected everywhere; tests useClock.fixedto advance to T+5d in-memory (cascade firer unit test does not wait).
13.2 Sample tenant-isolation test¶
@SpringBootTest
@Import(NotifTestContainersConfig.class)
class NotifTenantIsolationTest {
@Autowired NotificationService notif;
@Autowired NotifFixtures fixtures;
@Test
void tenantA_cannot_observe_tenantB_notifications() {
var tenantA = fixtures.tenant("CI");
var tenantB = fixtures.tenant("CI");
var notifA = notif.dispatch(fixtures.dispatchRequest(tenantA));
var notifB = notif.dispatch(fixtures.dispatchRequest(tenantB));
// Operator from tenantA tries to look up notifB
var jwt = fixtures.operatorJwt(tenantA);
assertThatThrownBy(() -> notif.findByIdScoped(jwt, notifB.id()))
.isInstanceOf(NotificationNotFoundException.class) // not "forbidden" — pure absence
.hasMessageNotContaining(notifB.id().toString()); // no info leak
}
@Test
void stop_in_tenantA_does_not_suppress_in_tenantB() {
var phone = "+22507123456789";
var tenantA = fixtures.tenant("CI");
var tenantB = fixtures.tenant("CI");
fixtures.notification(tenantA, phone); // SMS sent
fixtures.notification(tenantB, phone); // SMS sent
fixtures.inboundStop("letstalk_sms_ci", phone); // STOP from this phone
// STOP should land on tenantA OR tenantB based on lookup — and ONLY one
var suppressedA = notif.isSuppressed(tenantA, phone, SMS);
var suppressedB = notif.isSuppressed(tenantB, phone, SMS);
assertThat(suppressedA ^ suppressedB).isTrue(); // exactly one tenant; per-tenant scope
}
}
13.3 Sample resumability test (crash mid-submit)¶
@SpringBootTest
class NotifCrashRecoveryTest {
@Test
void crash_between_accept_and_provider_submit_recovers_exactly_once() {
var req = fixtures.dispatchRequest();
notif.dispatch(req);
// simulate crash: stop the @Async executor before it picks the task up
asyncExecutor.shutdownNow();
// Restart simulated by invoking SmsSubmitRecovery
smsSubmitRecovery.run();
// The notification should be SMS_SENT exactly once; the provider stub asserts no double-submit
var n = notif.findById(req.notificationId());
assertThat(n.status()).isEqualTo(SMS_SENT);
wireMockServer.verify(1, postRequestedFor(urlEqualTo("/v2/messages")));
}
}
13.4 Sample cascade-cancel race test¶
@SpringBootTest
class NotifCascadeCancelRaceTest {
@Test
void late_session_opened_does_not_trigger_WA_after_firer_already_submitted() {
var n = fixtures.notificationInAwaitingCascade(/*sms_sent_at*/ now.minus(5, DAYS).minus(1, MINUTES));
// Firer picks the row (CAS succeeds), submits to WA
cascadeFirer.runOnce();
// Now session_opened arrives "late"
eventPublisher.publishEvent(new CustomerSessionOpened(n.tenantId(), n.customerId(), n.signedLinkId(), Instant.now()));
var updated = notif.findById(n.id());
assertThat(updated.status()).isEqualTo(DISPATCHING_WA); // not CASCADE_CANCELLED — firer won the race
wireMockServer.verify(1, postRequestedFor(urlPathMatching("/v17.0/.*/messages")));
}
}
14. Local Dev Environment¶
Extends the existing docker-compose.yml from TENANT S0. NOTIF S0 adds the WireMock service.
# Excerpt — NOTIF-relevant services
services:
# ... existing: postgres, keycloak, redis, minio (TENANT S0)
wiremock:
image: wiremock/wiremock:3.5.4
container_name: papillon-wiremock
ports:
- "8089:8080"
volumes:
- ./backend/src/test/resources/wiremock:/home/wiremock
command: ["--global-response-templating", "--verbose"]
networks: [papillon-net]
14.1 Environment variables (v0.0.0)¶
# NOTIF v0.0.0 — required env vars
NOTIF_SMS_PROVIDER_BASE_URL=http://wiremock:8080/sms-letstalk # prod: https://api.letstalk.ci
NOTIF_SMS_PROVIDER_API_KEY=dev-stub-key
NOTIF_SMS_WEBHOOK_SECRET=dev-stub-hmac-secret
NOTIF_WA_PROVIDER_BASE_URL=http://wiremock:8080/wa-meta # prod: https://graph.facebook.com
NOTIF_WA_PHONE_NUMBER_ID=dev-stub-pnid
NOTIF_WA_ACCESS_TOKEN=dev-stub-token
NOTIF_WA_WEBHOOK_SECRET=dev-stub-hmac-secret
NOTIF_WA_TEMPLATE_NAME=renewal_reminder_initial_fr
NOTIF_CASCADE_INTERVAL_DAYS=5
NOTIF_CASCADE_FIRER_BATCH_SIZE=50
NOTIF_CASCADE_FIRER_TICK_SECONDS=300
# Feature flag for OQ-NOTIF-02
FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED=true # set false if ARTCI filing pending
14.2 Smoke verification command¶
# Spin up
docker-compose up -d postgres keycloak redis minio wiremock
# Apply migrations
./gradlew :backend:flywayMigrate
# Boot the app
./gradlew :backend:bootRun &
# Dispatch a notification (smoke)
curl -X POST http://localhost:8080/api/notifications/dispatch \
-H "Authorization: Bearer $OPERATOR_JWT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"tenant_id":"00000000-0000-0000-0000-000000000001",
"customer_id":"00000000-0000-0000-0000-000000000010",
"contract_id":"00000000-0000-0000-0000-000000000100",
"message_type":"RENEWAL_REMINDER_INITIAL",
"iteration":1,
"customer_phone_e164":"+22507123456789",
"customer_first_name":"Aïssatou",
"due_date":"2026-06-15",
"amount_minor_xof":15000,
"signed_link_id":"00000000-0000-0000-0000-000000001000",
"signed_link_url":"https://app.papillon.ci/portail/abc",
"customer_consented":true,
"correlation_id":"00000000-0000-0000-0000-000000010000",
"idempotency_key":"00000000-0000-0000-0000-000000100000"
}'
# Expect: 202 + JSON {"notification_id":"...","status":"PENDING"}
# Verify: WireMock saw the SMS submission
curl http://localhost:8089/__admin/requests/count -d '{"method":"POST","url":"/sms-letstalk/v2/messages"}'
# Verify: AUDIT received NOTIF_SENDING
psql -h localhost -U papillon -d papillon -c \
"select event_type, count(*) from audit_log where event_type like 'NOTIF_%' group by 1;"
Appendix A — ADRs (NOTIF-specific)¶
| ADR ID | Decision | Rationale |
|---|---|---|
| NOTIF-ADR-01 | NOTIF tables on platform DB for both SHARED and BYO profiles (TV-05; mirrors AUDIT D12). |
Operational resilience: a BYO-DB outage must not stop ARTCI-required outbound notifications. Single source for cross-tenant ops queries. |
| NOTIF-ADR-02 | Cascade is SMS-first → WA at T+5d, NOT parallel send. Cancels on customer.session_opened. |
Founder decision 2026-05-21 overriding NOTIF PRD V0 envelope. Cost-positive (one channel per renewal in the happy case), reduces cross-border WA noise. ANALYST must rework PRD. |
| NOTIF-ADR-03 | WA cascade carries a freshly re-issued signed link via AUTH.SignedLinkService.reissueForCascade. |
Original 48–72h link is expired at T+5d. Re-issue keeps the customer's WA-read window valid; preserves audit case-key chain (D16) via lineage_of. |
| NOTIF-ADR-04 | Dispatch substrate = @Async post-commit submit + single scheduled job WhatsAppCascadeFirer (every 5 min, partial-index scan). |
No continuous polling. No new infra in v0.0.0. Crash recovery via SmsSubmitRecovery startup job. Forward-compat with v0.1.0 Redis rate-limit (same scheduled task adds a token-bucket gate). |
| NOTIF-ADR-05 | country_profile provider lookup wired Day 1; only CI seeded. |
ARCHITECT-locked V1 constraint. Adding a second country = one row + one adapter class; no NOTIF source-code change. |
| NOTIF-ADR-06 | Audit NOTIF_WEBHOOK_REJECTED is logged-only in v0.0.0 (AUDIT catalog admits it at v0.1.0). |
OQ-NOTIF-04 ships NOTIF_SUPPRESSED_OPT_OUT in v0.0.0; the rest of the NOTIF self-events land in v0.1.0 alongside the cap + kill-switch. Honest-scope. |
| NOTIF-ADR-07 | correlation_id propagates through MDC + async tasks + scheduled tasks; never minted by NOTIF. |
D16 chain. NOTIF is a transit point, not an originator. |
| NOTIF-ADR-08 | Inbound STOP without a recent notification on the phone → log + drop, no suppression row created. | Per-tenant scope requires resolving phone → tenant; if no recent notification ties the phone to any tenant, creating a global suppression would leak cross-tenant. V1 may add a provider-scoped suppression list for unmatched STOPs. |
Appendix B — Open Questions carried (v0.0.0 closure status)¶
| ID | Question (abbrev.) | v0.0.0 status |
|---|---|---|
| OQ-NOTIF-01 | Concrete WA BSP + SMS aggregator choice. | Blocks NOTIF-001 dev-start (adapter implementation needs a target). |
| OQ-NOTIF-02 | ARTCI authorization filing for cross-border WA. | Feature-flagged (FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED); pilot proceeds under founder risk accept. |
| OQ-NOTIF-03 | Mandatory-clause requirement in message body itself. | Not blocking v0.0.0 (template body is locked + reviewed by DESIGNER+LEGAL). |
| OQ-NOTIF-04 | AUDIT V1 catalog extension. | Resolved for v0.0.0 scope: AUDIT-002 ships NOTIF_SUPPRESSED_OPT_OUT. Remaining 7 event types land at v0.1.0. |
| OQ-X3 | correlation_id origin = OP at OP_CAMPAIGN_VALIDATED. |
Confirmed in this session (NOTIF-ADR-07). Updated in PROGRESS.md. |
| (NEW) OQ-NOTIF-12 | PRD V0 envelope rework required to reflect SMS-first cascade (replaces "parallel"). | Founder confirmed 2026-05-21. ANALYST owns. |
| (NEW) OQ-NOTIF-13 | AUTH must expose SignedLinkService.reissueForCascade(originalLinkId, ttl) Spring named interface. |
ARCHITECT-flagged. AUTH session needs a new slice. |
| (NEW) OQ-NOTIF-14 | CP must publish customer.session_opened Spring application event on first signed-link-authenticated view. |
ARCHITECT-flagged. CP session needs a slice. |
End of v0.0.0 architecture for NOTIF. V1 extensions (operator timeline, manual retry, payment-receipt push, webhook-loss reconciliation, kill-switch, cap, quiet-hours) will be appended as a §V1 section in a later ARCHITECT pass — not before the v0.0.0 implementation is in production.