Files
DongHyeonkaandClaude Opus 5 701ba67456 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>
2026-08-14 13:57:27 +09:00

38 lines
1.5 KiB
Markdown

# NOTIF-ADR-003 — ambiguous submission is a first-class state
## Status
Accepted.
## Context
The most common serious failure is not a rejection. It is a request whose body reached the provider
and whose response never came back. The platform has no provider request id, and the user may or may
not have received the notification.
Treating that as a failure produces duplicates: a retry sends a second message, and a cross-channel
fallback sends the SMS next to the push that already arrived. Treating it as a success loses real
failures.
## Decision
`AMBIGUOUS` is a stored `SubmissionOutcome` and `AttemptConfirmation`. Attempts record
`requestStarted`, `requestBodyCommitted` and `providerResponseReceived`, each with an
`EvidenceCertainty` of `PROVEN`, `INFERRED` or `UNKNOWN`, so an adapter that does not know is not
forced to answer `false`.
While an ambiguous attempt exists on a recipient delivery:
- automatic retry is blocked unless the provider proves per-request idempotency
- automatic cross-channel fallback is blocked unconditionally
- reconciliation runs where the provider supports a status query
- otherwise the delivery stops and waits for an operator
Operator redrive of an ambiguous attempt requires explicit duplicate-risk approval.
## Consequences
Some notifications stop in a state that needs a human or a reconciliation pass. That is the intended
trade: an unresolved unknown is cheaper than a guaranteed duplicate, and the state is visible rather
than silently resolved in either direction.