feat(notification): implement the notification delivery platform

Maps the 31-module plan onto the registry's 19 leaves as packages; the two
edges the registry forbids (provider->httpclient, inbox->messaging) are
replaced by application-owned ports. See docs/notification/module-mapping.md.

Acceptance is not delivery: ProviderSubmissionResult refuses to carry a
delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks
automatic retry and fallback until reconciliation resolves it.

Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature +
reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292),
SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate
HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception.

Dispatch commits the attempt row, calls the provider with no transaction open,
then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 13:57:27 +09:00
co-authored by Claude Opus 5
parent 3b5aee50e3
commit 701ba67456
511 changed files with 30537 additions and 23 deletions
+65
View File
@@ -0,0 +1,65 @@
# Provider runbooks
## SMTP
| Symptom | Classification | Action |
|---|---|---|
| Final `2xx` after `DATA` | `CONFIRMED_ACCEPTED` / `PROVIDER_ACCEPTED` | None; this is acceptance, not inbox delivery |
| `4yz` | `TRANSIENT_PROVIDER` | Retry under budget and deadline |
| `5yz` | `PERMANENT_PROVIDER` or `INVALID_RECIPIENT` | Stop, or invalidate the contact point |
| Connection lost after `DATA` | `AMBIGUOUS_SUBMISSION` | Reconcile or escalate; do not resend automatically |
Connection, read, write and pool-acquire timeouts are all finite. There is no unbounded timeout.
## Amazon SES
`MessageId` is acceptance evidence. SES itself documents that it can accept a request and then not
send, so `MessageId` is never mapped to `DELIVERED`.
| Event | Normalized |
|---|---|
| `Send` | reinforces `PROVIDER_ACCEPTED` |
| `Delivery` | `DELIVERY_CONFIRMED` / `NETWORK_OR_CARRIER_ACCEPTED` |
| `DeliveryDelay` | delay fact |
| `Bounce` (permanent) | `BOUNCED_HARD` plus hard-bounce suppression |
| `Bounce` (transient) | `BOUNCED_SOFT`; retry policy input, not a suppression reason |
| `Complaint` | complaint fact plus suppression |
| `Reject` | `PROVIDER_REJECTED` |
| `RenderingFailure` | `TEMPLATE_FAILURE` |
## Twilio
`accepted`/`queued` is acceptance only. `sent` is carrier acceptance. `delivered` is device delivery.
Callbacks are not ordered. A `sent` arriving after `delivered` is stored and ignored by the
projection. Missing callbacks are corrected by status polling under the provider rate limit.
Signature verification uses the canonical external URL from the profile, not the URL the servlet
container reconstructed behind a proxy.
## FCM
| Error | Classification |
|---|---|
| `UNREGISTERED` | `INVALID_RECIPIENT`; invalidate the contact point, never retry |
| `INVALID_ARGUMENT` | `INVALID_PAYLOAD` |
| `QUOTA_EXCEEDED` | `THROTTLED`, exponential backoff |
| `UNAVAILABLE` | `TRANSIENT_PROVIDER`, honour `Retry-After`, add jitter |
| Credential failure | `AUTHENTICATION`; opens the provider route |
A batch is one transport call and many attempts. Partial results map back by input index; one
transport failure does not become one shared outcome unless the adapter can prove it.
## APNs
2xx is acceptance. Environment and topic mismatches are configuration failures, not delivery
failures. Sandbox and production tokens are separate namespaces.
## Web Push
`TTL` is mandatory by protocol. `201` is acceptance. `404` is an expired subscription per RFC 8030;
provider-documented `410` maps the same way. Payloads use `aes128gcm` per RFC 8291 and VAPID JWTs are
signed per RFC 8292 with the audience taken from the endpoint origin.
VAPID key rotation is not ordinary credential rotation: a restricted subscription may need to be
re-created, so it is a migration operation.