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

2.5 KiB

Security and privacy

Protected values

Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template variables, rendered bodies, attachment references and unsubscribe tokens.

At rest

Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256 fingerprint.

Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so two records of the same address are not visibly identical, while equality lookup must be deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in seconds.

The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between contact kinds without failing the authentication tag.

An unknown key id is refused rather than silently falling back to the current key: a silent fallback would turn every historical row into a tag failure at read time.

Never logged, never a metric tag

Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids.

Two mechanisms enforce this rather than convention:

  • CardinalityGuard validates every metric tag against a closed allowlist.
  • SafeDiagnosticContext rejects any structured-diagnostic field outside its allowlist.

An allowlist rather than a denylist, because the failure mode of a denylist is that the one field nobody thought of is the one that leaks.

Every contact point value type overrides toString() to print [redacted]. That covers the case a central redactor cannot: a value interpolated into a log line by accident.

Web Push endpoints

RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the subscriber. It is handled as a secret, not as a URL.

Callbacks

TLS, provider signature verification over the exact received bytes and external URL, replay defence where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate limiting, idempotent ingestion and a security audit trail for rejections.

Tenant isolation

Every store port carries the tenant boundary in its signature. Administrative operations require an explicit tenant or a global authority.