# Notification payload at rest — threat model and decision (NTF-INT-007) Wave 2 Task D6 offers two branches and requires that one be chosen and implemented fully: - **(a) encryption** — a codec/port, ciphertext envelope, key id, rotation and history, row migration, and a decryption-failure contract; - **(b) restriction** — this document plus a static restriction proving the variable types cannot carry sensitive values. The plan recommends (b) **"if and only if the variable types can genuinely be restricted to non-sensitive values"**. They cannot. This document records why, what that leaves, and when the remaining branch lands. ## What is stored, and where `CanonicalNotificationPlanWriter.request(...)` puts `encoded.variablesPayload()` into `NotificationRequestRecord` verbatim. `JpaNotificationRequestStore` writes that record to `notification_request.variables_payload` with no transformation. There is no encryption anywhere on this path. ## Why the restriction branch is unavailable Template variables are a closed algebra — `NotificationVariable` permits `TextValue`, `NumberValue`, `BooleanValue`, `NullValue`, `ListValue`, `ObjectValue` — which is a real improvement over the `Map` it replaced. But `TextValue` holds an arbitrary UTF-8 string of up to 8 KiB, and that is not an oversight to be tightened: **the variables are the recipient-specific content of the message**. A password-reset code, an order total, a delivery address, a patient's appointment time — those are what a notification is for. A restriction to "non-sensitive values" would therefore be one of two things, and both are worse than the problem: - **unenforceable** — a comment saying callers should not put sensitive data in a field designed to carry the message's content, which is a policy no type checks and no reviewer can see violated; - **enforced and useless** — a type that refuses free text, which does not restrict the capability so much as delete it. The precondition on the plan's recommendation is false. Branch (b) is not available. > **Status: branch (a) implemented at the storage boundary.** `NotificationPayloadProtection` is the > application-owned port, `AesGcmNotificationPayloadProtection` the AES-GCM implementation, and > `NotificationRecordMapper` applies it — as a **required** constructor argument, so a composition > cannot assemble the notification stores while leaving the payload in plaintext. What remains before > the facade can be imported is the row migration for any deployment that already has plaintext rows, > and the `local-notification-*` lanes. The analysis below is kept as written, because it is what the > decision rests on. ## Decision: branch (a), landing with the persistence wiring Encryption is therefore the required branch. Its scope is unchanged from the plan: a codec behind an application port, a ciphertext envelope carrying its key id, key rotation with history so an old row stays readable, a migration for existing rows, and an explicit contract for what a decryption failure does to a request. **It lands in the change unit that makes the write path reachable, and not before.** The reason is a fact the spec did not have: `NotificationJpaPersistenceFacade`, which assembles `JpaNotificationRequestStore`, is imported by nothing. The composition root's component scan excludes the persistence package by design, and no configuration imports the facade — so the notification capability has **no JPA persistence at all**, and no deployment currently writes this payload anywhere. The defect is real in the code and latent in the runtime. Designing key rotation and a row migration for rows that no deployment produces would be building the migration before the table. Worse, it would settle the envelope's shape before the store that has to read it is wired, which is the order that produces an envelope the store cannot use. **One correction, learned by trying it.** This section said the envelope "lands with the wiring". Wiring the facade first — to register the SMTP assembler — made `NotificationPayloadAtRestContractTest` fail on the case asserting the write path is reachable from no composition, which is exactly what that case is for. The wave forbids connecting wiring over a known security finding on a runtime path, so the wiring was reverted and the envelope built first. The honest ordering is **envelope before or with the wiring, never after**, and the contract test now enforces it by failing on the wiring alone. ### The envelope, and why it has a key id ``` byte version always 1 byte keyIdLength 1..255 UTF-8 bytes byte[] keyId byte[12] nonce byte[] ciphertext + GCM tag ``` The key id is the reason there is a format at all. This repository's callback protection stores nonce and ciphertext and nothing else, so the day the active key changes, every row written under the previous one becomes unreadable and nothing in the row can say which key it needed — that is not a rotation story with a gap in it, it is the absence of one. `SecretMaterialProvider` already exposes `keyById`, so reading the id back and asking for that specific key makes rotation a change of default rather than a data migration. The version byte costs one byte and is what allows the format to change at all. The header is passed as **AAD**, not merely prefixed: without that, the key id is attacker-editable and an envelope could be redirected at a key of the attacker's choosing. A failed decryption throws `NotificationPayloadUnreadableException` rather than returning empty. A caller handed an empty payload renders every variable as nothing and sends "Hello , your code is " to a real person — the failure delivered instead of reported. All three causes (unknown key, wrong key, modified ciphertext) collapse into one message, because telling them apart tells an attacker which of the three they achieved. ## What must not be done instead **Requiring `PAYLOAD_ENCRYPTION` in `INGEST_ONLY` is not a fix.** That secret is consumed by exactly one thing — `AesGcmCallbackPayloadProtection`, which protects raw callback bodies — and by nothing on the accept path. Demanding it would make a deployment supply a key that protects nothing while the payload it appears to be about stays in plaintext. The repository already has one defect of that exact shape: `backend.graphql.cursor.key-ids`, which production refuses to start without and which no code signs a cursor with (GQL-INT-003). Adding a second would make the pattern a habit. ## Consequence Notification is **not promoted to Stable**, per the index's scope boundaries, until branch (a) is complete. The three notification Compose lanes stay non-blocking. `NotificationPayloadAtRestContractTest` holds every fact this decision rests on, so the decision expires automatically if any of them stops being true — in particular, the assertion that no encryption sits on the accept path fails the moment somebody adds one, which is the change this document is waiting for.