Aller au contenu

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.md cross-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-Version request header — there is no /v1 path 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_id claim per D3. NOTIF cross-checks the request body's tenant_id against the JWT; mismatch = 403 tenant_mismatch.
  • Webhook side (provider → NOTIF): no tenant in the HTTP request — resolved by provider_message_iddelivery_attempt.tenant_id (denormalized for this exact reason). A webhook arriving for an unknown provider_message_id returns 200 (no-op, to avoid provider retries spamming us) but emits NOTIF_WEBHOOK_REJECTED audit. v0.0.0 ships this minimal version; V1 adds full anti-spoofing chain via signature + provider_id allow-list.
  • AUTH/CP event side: customer.session_opened event carries tenant_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).

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 /dispatchoperator 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)

  1. V0_0_0__notif_create_notification.sqlnotification table + 5 indexes.
  2. V0_0_0__notif_create_delivery_attempt.sqldelivery_attempt table + 2 indexes.
  3. V0_0_0__notif_create_suppression.sqlsuppression table + 2 partial indexes.
  4. V0_0_0__notif_seed_country_profile_notif_columns.sqlTENANT-owned migration, listed here for traceability. Adds notif_provider_sms, notif_provider_wa, sms_sender_id_default columns to country_profile. Seed row: ('CI', 'letstalk_sms_ci', 'meta_wa_cloud_ci', 'PAPILLON').
  5. 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 NULL post-backfill on notification — 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 0 on notification.

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 small notif-fixtures helper 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 seeded country_profile.CI row.

Tier 1 — parallelizable after Tier 0

NOTIF-001 — Synchronous dispatch + immediate SMS send (v0.0.0 core happy path)

  • Scope: POST /api/notifications/dispatch end-to-end. Idempotency dual-guard. Suppression pre-check. @Async SMS submit via SmsProvider.send. Status webhook ingestion (status only, not inbound STOP). Outbox emission for NOTIF_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_OUT first (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) + @Async submit + status webhook + delivery_attempt lifecycle.
  • NOTIF-001c — Suppression table + pre-dispatch lookup + NOTIF_SUPPRESSED_OPT_OUT emission.
  • 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}/inbound controller. 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: WhatsAppCascadeFirer scheduled task (every 5 min). customer.session_opened consumer cancels. AUTH reissueForCascade integration. WhatsAppProvider adapter 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_SMS rows older than 60s with no provider_message_id. Resubmit via the same adapter (idempotent on attempt_id).
  • Files: .../job/SmsSubmitRecovery.java, startup wiring in NotifConfiguration.
  • 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_app role used.
  • WireMock — runs as a docker-compose service in dev + as a per-test WireMockExtension in JUnit. Two stub libraries:
  • wiremock/sms-letstalk/POST /v2/messages 200 OK, POST /v2/messages 5xx (transient), POST /v2/messages 422 (hard-error).
  • wiremock/wa-meta/POST /v17.0/{phone_number_id}/messages 200, 4xx (USER_NOT_ON_WHATSAPP, BUSINESS_BLOCKED), 5xx.
  • Spring Modulith outboxspring-modulith-test library replays events synchronously in tests; assertions on AuditEventBuilder invocations done via the existing AUDIT test harness.
  • ClockClock bean injected everywhere; tests use Clock.fixed to 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.