ADR-032: A portable envelope codec for consumer-owned payloads 

Status 

Accepted

Date 

2026-07-29

Context 

EncryptionServiceInterface mirrors this extension's own storage: its decrypt() takes seven arguments — ciphertext, wrapped DEK, two nonces, the AAD identifier, and the version and algorithm markers — because those are seven columns of tx_nrvault_secret.

A consuming extension that wants to protect its own payload rarely has seven columns to spare. It has one, and so it has to invent framing: pack the EncryptedData into something, unpack it again, validate every field, decide what a corrupt value means, and version the format for later.

nr-llm did exactly that. Its AgentStateCodec (ADR-114 there, shipped in nr-llm 0.24.0) is 154 lines, of which roughly a hundred are a version prefix, a base64/JSON pack, a field-by-field type check and a corruption exception — nothing specific to agent state, all of it re-derivable by the next consumer, and each consumer arriving at a slightly different format.

Decision 

Provide the framing here, once, as EnvelopeCodecInterface : one string in, one string out, with parsing and validation on this side of the boundary.

A new interface, not four more methods 

The codec is a NEW interface rather than additional methods on EncryptionServiceInterface . Adding methods to a published interface breaks every implementor of it, and the two interfaces answer different questions: "encrypt these fields" versus "protect this payload". EncryptionService remains the crypto boundary; EnvelopeCodec is framing on top of it and holds no key material or cipher logic of its own.

Wire format 

nrv1: + base64( JSON of EncryptedData::toArray() )
Copied!

The marker makes a stored value self-identifying, so a column can hold sealed and not-yet-sealed values during a migration and isSealed() tells them apart without the caller knowing the format.

The JSON body is deliberately exactly what a consumer that hand-rolled this would already have written — EncryptedData::toArray() — so an existing consumer can adopt the codec by mapping its own marker onto nrv1:, with no data migration and no re-encryption. EnvelopeCodecTest pins this by building a body the way nr-llm's codec builds one and opening it here.

Parsing reads only the six fields it needs and ignores any others, so a body written by an older or newer vault still opens. The change-detection checksum is carried through re-wrapping but not required to open, and not verified — integrity is the AEAD tag's job, and the checksum is an audit token.

Corruption is not tampering 

EnvelopeFormatException is a sibling of EncryptionException , not a subclass. "This string is not an envelope" and "this envelope failed authentication" want different handling: the first is a truncated column, a value that was never sealed, or garbage; the second means the ciphertext was altered, moved to a different AAD context, or written under a key this host does not hold. A consumer catching only EncryptionException must not silently absorb malformed input as if it were a failed MAC check.

Rotation is a caller obligation 

seal() wraps the payload's DEK with the CURRENT master key, and that wrapped DEK is stored in the CONSUMER's table, where this extension's rotation cannot reach it. A consumer that seals therefore must also register a ForeignEnvelopeRotatorInterface (ADR-033). Sealing without one produces data that becomes permanently unreadable the moment an operator rotates. Both the interface docblock and the rotation ADR say so, because the failure is silent and only shows up long after the mistake.

Consequences 

  • A consumer stores one string and writes no framing code. nr-llm's codec collapses to a marker-compat branch plus two delegating calls.
  • One format instead of one per consumer, so a future format change is a vault-side migration rather than an archaeology exercise across extensions.
  • rewrap() re-wraps the DEK layer without decrypting the payload, so rotation never materialises plaintext.
  • The codec alias is public so a consuming extension can resolve it via GeneralUtility::makeInstance() outside a DI-constructed service.
  • The nrv1 marker is a commitment: changing the body format requires a new marker and a read path for the old one.

See also 

  • ADR-002 — the envelope encryption scheme itself.
  • ADR-033 — the rotation obligation that comes with sealing.