Skip to content

docs(privacy): decide session-key custody, rotation, and erasure (ADR-0016) - #156

Merged
myselfsiddharth merged 5 commits into
mainfrom
track1/b5-session-key-custody-adr
Aug 12, 2026
Merged

docs(privacy): decide session-key custody, rotation, and erasure (ADR-0016)#156
myselfsiddharth merged 5 commits into
mainfrom
track1/b5-session-key-custody-adr

Conversation

@myselfsiddharth

@myselfsiddharth myselfsiddharth commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #146. Follow-up to #98/#143 (docs/privacy/session-state-encryption.md).

A decision document, not a code change. #98 closed SC-01's mechanism half — per-tenant HKDF derivation, an AES-256-GCM envelope, a TenantKey an object literal cannot forge — and deliberately deferred custody: where the master lives in prod, what rotation means for a stored file, and whether "destroy the key" is a real erasure story. ADR-0016 decides all four questions #146 asks.

Timed ahead of need on purpose. Re-verified rather than inherited from the old finding: grep for persistSessionState/restoreSessionState across src/ and experiments/ returns nothing outside src/session/ itself, and the only addCookies() call outside that package is tests/unit/session-material-tripwire.test.ts giving the SC-04 tripwire a real cookie to fail on. No production path persists or restores session material. That matters because two of the four decisions change key derivation, and changing derivation after real files exist means re-encrypting every one of them — the same ordering argument #98 used for per-tenant derivation itself.

The four decisions

  1. Custody — a KMS-wrapped master. A CMK wraps the same 32 bytes keys.ts reads from PARAGENT_SESSION_MASTER_KEY today; the app unwraps once at start and holds them for the process lifetime, exactly as it holds the env-var bytes now. Vendor deliberately unpicked — grepped docs/pitch|decisions|prd|privacy and confirmed no AWS/GCP/Azure/Vault commitment exists anywhere in this repo, so inventing one here would be the speculation Session-key custody: the deferred half of SC-01 (KMS, rotation, tenant offboarding) #146 rules out. What is decided is the shape a vendor must satisfy. Rejected: a per-tenant wrapped DEK as the confidentiality mechanism (HKDF already gives per-tenant confidentiality with no stored state), a self-hosted HSM/Vault (swaps a managed burden for an operational one with no benefit this repo has stated a preference for), an age-style offline key (a live decrypting service needs the private half loaded, which is a KMS with extra steps).
  2. Rotation — a global key_epoch beside version, retired by a batch job. version means "how this envelope is laid out"; key_epoch means "which generation of the master wrote this." One live generation per deployment, bumped once globally per rotation, nothing per-tenant. Batch re-encryption, not lazy-on-read: lazy completion depends on reads happening, and nothing reads these files today, so a rotation that waits for organic reads may never finish. And it is re-encryption, not re-wrapping — the tenant key is derived from the master, so every file is a full read-modify-write, which is the number whoever sizes the grace window is actually sizing. A file carrying an epoch custody no longer retains fails closed with a distinct error (provisionally SessionKeyRetiredError), never a fallback to the current epoch — the posture ADR-0013 took for a partial cache hit.
  3. Erasure — per-tenant erasure requires per-tenant secret material, so the ADR adds exactly one. Yes, key destruction is the offboarding story rather than finding and deleting files. But a non-secret marker cannot destroy a derived key: TenantKey is a deterministic function of (master, tenantId, salt), and a per-tenant counter added to that is a fourth input that survives its own "retirement" — master untouched, tenant id known, salt in the envelope, counter necessarily in the envelope too. Nothing would be destroyed; a row would say not to, which is a runtime check somebody in a hurry can skip. So: a registry row per tenant holding a 32-byte erasure secret minted at onboarding, wrapped under the CMK, not derivable from (master, tenantId), and never in the envelope — which is what lets it be secret. Derivation extracts from master ‖ erasure_secret, leaving the per-file salt and the info domain separation exactly as they are. Destroy the secret and that tenant's files are underivable, master and every other row untouched. Preferred over a per-tenant wrapped DEK (same per-tenant storage cost, but a DEK is the tenant's key while this is half the input, and it composes with the existing derivation instead of replacing it) and over one KMS key per tenant (rigorous, but it forces the vendor decision this ADR declines and puts a recurring per-tenant cost in the onboarding path — explicitly not foreclosed: the registry row is the seam, and backing the secret with a per-tenant KMS key later changes only the resolver, re-encrypting nothing).
  4. Local dev and CI — exactly what they have today, decided so it cannot drift. generateEphemeral() and the env var stay the only path either needs; neither holds real tenant material, so neither needs a KMS credential, rotation, or the registry. The ephemeral path mints a per-process erasure secret behind the same resolver type as prod, so dev is not a second, laxer way to build a TenantKey.

What it refuses to claim

The mechanism is real, so the limits are stated rather than implied. Erasure is cryptographic only to the extent the secret is destroyed everywhere it exists — backups, replicas, WAL, snapshots — which makes the registry a secret store with a destruction requirement its datastore must actually meet, and that is now the load-bearing open question, with the honest fallbacks named (per-tenant KMS key, or word the commitment as policy-enforced). Erasure is forward-looking, as with every crypto-shredding scheme including KMS key deletion. And key_id stays master-scoped on purpose so a sweep job can still find an erased tenant's files: erased means undecryptable, not unfindable.

Vendor primitives, cited rather than assumed

A primitives table with inline URLs and access dates, because an earlier draft built a decision on a primitive that does not exist. The correction: destruction granularity is the key or key version, never the individual ciphertext — so there is no primitive that erases one tenant out of shared key material, which is why Decision 3's requirement is an identity and not a preference. The two vendors also diverge on the primitive Decision 2 leans on: GCP can destroy one key version and keep the current one, while AWS "deletes key material only when you delete the KMS key," so per-epoch destruction on AWS means one KMS key per epoch (its manual rotation) rather than automatic rotation of one key. Stated as a vendor-neutral requirement — the deployment must be able to destroy a retired master generation without destroying the current one — with the mapping fallback spelled out. The AWS multi-tenant blog is cited narrowly and labelled as such: it argues per-tenant keys for isolation and cost and does not discuss erasure at all.

Consequences worth knowing before implementation

  • The ENVELOPE_VERSION bump is not "bump the constant." parseEnvelope refuses any version but its own, and additionalData() binds the module constant into the GCM AAD — so a v2 build reading a v1 file computes AAD over 2 and fails the tag. Reading old files requires the AAD to use the parsed version, a deliberate change to what it commits to. Cost is real and currently zero-impact, which is the argument for landing it before a caller exists.
  • key_id is an HMAC under the master, so a rotation changes it: the batch job must resolve the master by key_epoch before comparing key_id.
  • Decision 3 is a genuine one-way door once files exist — adding or removing a derivation input both mean re-encrypting everything. Choosing it now is choosing to pay that at a file count of zero.
  • The "index of what is stored" question splits. The registry answers the offboarding half; a file-path index stays out of scope, since persist.ts's filePath is caller-supplied and there is no caller to own populating one.

Cross-links

  • docs/privacy/session-state-encryption.md: the first three "What v1 defers" rows now read decided, not implemented and point at the ADR; the derivation section says plainly that ADR-0016 changes it and why now; the prod threat-model row and the tenancy-granularity open question follow.
  • docs/README.md: the ADR-0016 Decisions row, and the privacy row's last-true date.

Test plan

🤖 Generated with Claude Code

…-0016)

Closes #146. #98/#143 shipped SC-01's mechanism (per-tenant HKDF, AES-256-GCM,
a merge-blocking plaintext canary) and deliberately deferred custody. This
resolves the deferred half as a decision, not an implementation: a KMS-wrapped
master with the existing per-tenant derivation left unchanged, rotation via a
key_epoch field retired by a batch re-wrap job, erasure via a new per-tenant
epoch registry rather than file deletion, and an explicit floor keeping
dev/CI on the env-var + generateEphemeral() path with no KMS dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@myselfsiddharth
myselfsiddharth requested a review from a team as a code owner August 12, 2026 08:27
@github-actions github-actions Bot added the size/L <= 600 changed lines label Aug 12, 2026
@github-actions
github-actions Bot requested a review from OM152002 August 12, 2026 08:28
@github-actions github-actions Bot added documentation Improvements or additions to documentation proposal Design / governance proposal privacy-boundary Touches the privacy boundary — canary is merge-blocking labels Aug 12, 2026
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Review — ADR-0016 (session-key custody), PR #156

Verdict: blocking issues. Decisions 1, 2 and 4 are genuinely decided and well argued. Decision 3 — the erasure story, which is the headline claim of this PR and is now asserted as settled in two other docs — does not work as specified, and contradicts Decision 1 in three places. This needs a substantive revision, not a patch, so I have pushed no commits to the branch.

What I checked

  • Issue Session-key custody: the deferred half of SC-01 (KMS, rotation, tenant offboarding) #146 and the full PR body.
  • The complete diff: the new docs/decisions/ADR-0016-session-key-custody.md (281 lines, read in full, not via the PR summary), and the edits to docs/privacy/session-state-encryption.md and docs/README.md.
  • src/session/keys.ts, store.ts, persist.ts, types.ts — to check the ADR's claims about TenantKey/MasterKey/the envelope against what the code actually does.
  • docs/decisions/ADR-0009-cache-confidence.md, ADR-0003, ADR-0013 and CONTRIBUTING.md for the repo's real ADR/sourcing bar.
  • Re-ran the checks myself in a fresh worktree at ac07dc4.

Checks I ran independently (all reproduce the PR body's claims)

Command Result
npm run lint:docs lint-docs: clean (58 docs)
npm run ci exit 0 — secret-scan clean, validate:contracts, lint, lint:docs clean (58 docs), typecheck, 405 unit tests / 29 files, 26 integration tests / 5 files
npm run test:canary 48 tests / 7 files passed, including tests/canary/session-plaintext.test.ts (14)

No stale green claims — the numbers in the test plan are the numbers I got.

Scope discipline: clean. gh pr diff --name-only is exactly three files, all under docs/. Nothing in src/, src/cache/, src/intent/, or docs/pitch/. The ADR-0015 gap in the numbering is correct, not an error — ADR-0015 is claimed by the parallel PR #158. Not bumping docs/README.md's own frontmatter updated: matches what the last two ADR-adding commits did, so that is convention, not an omission.

Verified factual claims that do hold:

  • "Nothing calls src/session/" — confirmed fresh. persistSessionState/restoreSessionState appear only in src/session/ itself plus tests/unit/session-store.test.ts and tests/canary/session-plaintext.test.ts.
  • "No cloud vendor is decided anywhere in this repo" — confirmed. The only hits across docs/pitch|decisions|prd|privacy are the PRD's "no credential vault in v1" and the existing deferral notes. Leaving the vendor unpicked is the right call and is argued, not dodged.
  • The code claims in Context are accurate: TenantKey does have a private constructor and private #material, writeEncryptedStorageState does require one, restoreSessionState does re-derive via master.tenantKey(tenantId, salt) on every read rather than loading a stored key, and ENVELOPE_VERSION is 1 with parseEnvelope hard-refusing anything else.
  • Frontmatter is present and well-formed; the doc ends in Open questions / what I could not verify, and that section is substantive rather than decorative.
  • No invented numbers anywhere, and the "What this doesn't do" section is honest about no KMS having been touched. That part is the repo's standard.

Blocking

B1. Decision 3 does not erase anything. A non-secret counter cannot make a derived key underivable.

Decision 3 states:

Offboarding a tenant is retiring that tenant's registry row and never advancing it again: every envelope written under that tenant's retired epoch becomes permanently underivable, without touching the master or any other tenant's row.

and immediately constrains the registry to:

The registry holds one small row per tenant — an epoch counter and a retirement marker — not a wrapped key blob per tenant.

Those two cannot both be true. keys.ts makes TenantKey a deterministic function of (master, tenantId, salt); adding an epoch makes it a function of (master, tenantId, salt, epoch). Every input on the right-hand side survives the retirement:

  • the master is untouched (Decision 3 says so explicitly),
  • the tenant id is known to whoever is asking,
  • the salt is in the envelope header today,
  • and the epoch is also in the envelope header, because Decision 2 requires key_epoch to be a readable field so a reader can tell which generation produced the file.

So after "retirement", anyone holding the master can recompute the retired tenant's key exactly as before. Nothing has been destroyed; a row in a table now says not to. That is a runtime policy check — precisely what issue #146's constraint and Decision 2's own sentence ("a retired epoch has to actually stop working, not 'stop working unless someone forgot to check'") rule out. The ADR correctly diagnoses this problem one paragraph earlier ("there is nothing at the tenant granularity to destroy") and then adopts a fix that does not address the diagnosis.

The same field cannot be both the public selector Decision 2 needs and the secret whose destruction erases. Concretely, there are only two horns and the ADR should pick one out loud:

  • Epochs correspond to distinct master secrets (which is what Decision 2's "KMS retains the ability to decrypt the previous epoch's wrapped master" implies). Then destroying an epoch's master is real cryptographic erasure — but at global granularity, which the ADR itself calls "an outage, not an offboarding".
  • Erasure is per-tenant. Then the thing destroyed has to be per-tenant secret material — a per-tenant wrapped DEK, or a per-tenant secret pepper held only in the registry — which is the option Decision 1 rejected and Decision 3 disclaims.

This is not a wording nit: docs/privacy/session-state-encryption.md and docs/README.md are both edited in this PR to tell future readers that erasure is decided ("destroy the tenant's key is the erasure story, via a new per-tenant epoch registry — not file deletion"). A future reader building the first real caller would inherit a deletion commitment the mechanism cannot honour. Either revise Decision 3 to hold per-tenant secret state (and revisit Decision 1's rejection honestly), or state plainly that erasure is policy-enforced rather than cryptographic and say what that costs an erasure commitment.

B2. Decision 1 and Decision 3 contradict each other in three places.

  1. Decision 1's heading and body: "a KMS-wrapped master, HKDF derivation unchanged" and "The per-tenant HKDF derivation in keys.tstenantKey(tenantId, salt)does not change." Decision 3: "tenantKey()'s derivation is scoped by the tenant's live epoch from this registry." Scoping a KDF by a new input is a change to the derivation — new signature, new key material per epoch, and old files only re-derivable if the old epoch is supplied. docs/README.md's new row propagates the wrong half ("HKDF derivation unchanged").
  2. Decision 1 says the per-tenant wrapped DEK is "Rejected as the confidentiality mechanism, kept as a partial answer under Decision 3." Decision 3 says "This is not the per-tenant DEK-per-resource pattern rejected in Decision 1." One of these has to go.
  3. Decision 1 lists among the shape requirements a vendor must satisfy: "support revoking a specific wrapped value without touching another." That is the requirement B1 shows the design never uses, and (see B3) it is not a primitive at that granularity anyway.

B3. key_epoch is given two incompatible definitions, so the one field this ADR decides to add is under-decided.

Decision 2: key_epoch means "which generation of the master produced this" — a global counter, one master, one epoch at a time. Decision 3: epochs are per-tenant ("retire this tenant's epoch"), and the batch job "walks the tenant registry". A global master generation and a per-tenant epoch are different namespaces, and the ADR never says which one the envelope byte carries. It matters for the thing Decision 2 decides: "an epoch older than what custody currently retains" is a global comparison under the first reading and a per-tenant one under the second, and a master rotation under the second reading has to bump every tenant's row. For an ADR whose stated job is to fix the field, this is the field being left ambiguous.

B4. External claims about AWS KMS / GCP Cloud KMS carry no citation, while frontmatter says sources_verified: true.

The ADR asserts twice that both vendors document the needed primitives — "wrap/unwrap a value, retain more than one key version for decrypt, and revoke a specific wrapped value without affecting others" — with no URL and no access_date. The repo's bar for exactly this is explicit: CONTRIBUTING requires evidence_urls + access_date, and ADR-0001, ADR-0003 and ADR-0007 all carry inline URLs with access dates for their external claims.

It is also the claim most worth checking, because I believe the third primitive is wrong as stated. KMS revocation/destruction granularity is the key or key version, not an individual ciphertext: scheduling deletion of a KMS key renders all data under it unrecoverable (AWS: Delete an AWS KMS key), and AWS's own guidance for per-tenant erasure is a KMS key per tenant (AWS Architecture Blog: multi-tenant KMS key strategy) — i.e. per-tenant key material, the shape Decision 1 rejected. This is the same gap as B1, arriving from the vendor side.

Either cite both vendors' docs with access dates and correct the primitive, or drop the claim and flip sources_verified to false (the repo has precedent: docs/INTEGRITY-AUDIT.md and the PRD both carry false).


Non-blocking

  • The caller re-verification is slightly overstated. The ADR says a repo-wide search for addCookies() etc. "returns nothing outside src/session/'s own tests". tests/unit/session-material-tripwire.test.ts:378 calls page.context().addCookies([...]) (deliberately, to give the SC-04 tripwire a real cookie to fail on), and that file is not one of src/session/'s tests. The substantive point — nothing persists session material — is correct and I confirmed it; the sentence just claims more precision than it has, in a doc that leans on "re-verified, not assumed".
  • ADR-0003 is quoted, but not verbatim. The ADR writes ADR-0003: "no partner or third-party SaaS ToS"; ADR-0003 line 22 reads "measure churn survival without a design partner or third-party SaaS ToS". Fair paraphrase, wrong to present in quotation marks.
  • The ENVELOPE_VERSION bump is harder than Consequences implies. Consequences suggests an old v1 file could be read as "epoch 0, always resolvable". Today parseEnvelope throws SessionEnvelopeError on any version != ENVELOPE_VERSION, and additionalData() binds the module constant version into the GCM AAD — so a v2 build cannot read a v1 file even if the parser were relaxed, without also making the AAD use the parsed version. Worth naming in the "carried forward as a constraint" paragraph, since it is a concrete migration cost, not a free default.
  • "Re-wrap" is the wrong verb for the batch job. If the epoch enters derivation, rotation requires decrypting and re-encrypting every file under a newly derived tenant key, not rewrapping a master ciphertext. The ADR uses "re-encrypt" correctly when describing the lazy path, then "re-wrap job" for the batch path; the cost profile of those two words differs a lot for whoever sizes the grace window.

What is good

Decisions 1, 2 and 4 are real decisions with named, argued rejections (per-tenant DEK, self-hosted HSM/Vault, age-style offline key; lazy-only re-wrap; upgrading dev to a KMS dependency) — that is the ADR-0009 bar, met. Leaving the vendor unpicked is justified by a check I reproduced rather than by hedging. The fail-closed reader posture and the explicit floor under dev/CI are exactly right, and "What this doesn't do" is honest about nothing having been tested against a real KMS. The problem is confined to Decision 3 and what Decisions 1/2 say about it — but that is also the half the two cross-linked docs now advertise as settled, which is why it blocks.

Review on #156 established that Decision 3 did not work: it proposed a
per-tenant registry holding "an epoch counter and a retirement marker",
explicitly not secret material. But TenantKey is a deterministic function
of (master, tenantId, salt, epoch), and every one of those survives
retirement — the master by construction, the tenant id because the caller
supplies it, the salt from the envelope header, and the epoch from the
envelope header too, because Decision 2 needs it readable. Anyone holding
the master could recompute a "retired" tenant's key exactly as before. It
was a runtime policy check wearing a deletion's clothes, which is the
failure keys.ts already refuses in the master case.

Takes the per-tenant-secret horn rather than softening the claim:

- Decision 3 now adds a per-tenant erasure secret — 32 random bytes minted
  at onboarding, wrapped under the same CMK, held only in the registry, and
  NOT derivable from (master, tenantId). It becomes a second secret input
  to HKDF beside the master, so destroying it makes that tenant's files
  underivable for real. Chosen over a per-tenant wrapped DEK because both
  cost the same per-tenant state but this shape needs master AND registry
  to decrypt, so neither compromise alone yields plaintext. Chosen over a
  KMS key per tenant because that needs the vendor decision this ADR
  deliberately does not make — and the registry row is left as the seam so
  that upgrade costs no re-encryption later.
- Decision 1 no longer claims "HKDF derivation unchanged". Its rejection of
  the per-tenant DEK is kept for confidentiality (the reason holds) and
  explicitly reversed for erasure (that reason never covered it).
- B3: key_epoch is pinned GLOBAL — one master generation per deployment.
  The per-tenant secret is a separate thing with a separate name, is never
  in the envelope, and is not an epoch. Two concerns, two names.
- B4: the "revoke a specific wrapped value without affecting others"
  primitive was wrong and is corrected. Destruction granularity is the key
  or key version, never the ciphertext — cited to AWS and GCP docs with
  access dates, which is also what forces per-tenant secret material.
- States exactly what this buys: erasure is cryptographic only to the
  extent the secret is destroyed everywhere including backups, it is
  forward-looking, and key_id stays master-scoped so erased files remain
  findable but unreadable. The backup question is now the load-bearing
  open question.

Non-blocking notes: corrected the addCookies() re-verification (the SC-04
tripwire does call it outside src/session/), unquoted the ADR-0003
paraphrase, named the real ENVELOPE_VERSION cost (additionalData() binds
the module constant into the AAD, so relaxing parseEnvelope alone is not
enough), and used "re-encrypt" throughout for the batch job.

Cross-links in session-state-encryption.md and docs/README.md no longer
advertise the old mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Revised in 7061463 — Decision 3 rewritten, Decisions 1/2/4 made consistent with it

Thanks for this. B1 is correct and it is not a wording problem — the mechanism I wrote does not erase anything, for exactly the reason you gave. I have taken the per-tenant secret material horn (your second option), not the "state plainly that it is policy-enforced" one. Reasoning below, since you will want to re-check the argument and not just the prose.

Why per-tenant secret material rather than an honest downgrade to policy-enforced

The policy-enforced framing is a legitimate answer and I nearly took it — it is cheap and it is honest. What ruled it out is the ADR's own timing argument, turned on itself. This whole document exists because #98 established that derivation must be settled before a first caller, since changing it afterwards means re-encrypting every stored file. A per-tenant secret is an input to derivation. So "policy-enforced for now, cryptographic later if counsel requires it" is not a deferral that stays cheap — it is precisely the retrofit SC-01 was restructured to avoid, and its price goes from zero to a full re-encryption pass the moment the first caller lands. Deciding it now costs one secret column per tenant; deciding it later costs rewriting everything. That asymmetry is the argument, and I have put it in Reversal cost explicitly rather than leaving it implied.

Your B4 evidence pushes the same way, which is what made me confident: if destruction granularity is key-shaped, per-tenant erasure requires per-tenant key material. That is an identity, not a design preference.

What Decision 3 says now

A registry row per tenant holding a 32-byte erasure secret (plus live/erased state), minted at onboarding, wrapped under the same CMK as the master, and — the load-bearing property — not derivable from (master, tenantId). Derivation extracts from master ‖ erasure_secret, keeping the existing per-file salt and info domain separation untouched. Destroying the secret makes that tenant's files underivable for real, with the master and every other row untouched.

Two rejections I owed you an argument for, not an assertion:

  • Not a per-tenant wrapped DEK. Both shapes cost identical per-tenant state, so "extra state" cannot be the discriminator — what differs is what one compromise yields. A wrapped DEK is the tenant's key; whoever unwraps it decrypts, master irrelevant. An erasure secret is half the input: the whole registry without the master yields nothing, and the master without a row yields nothing for that tenant. Same storage cost, strictly stronger. That is the real reason to prefer it, and it is why Decision 1's rejection gets reversed in this specific narrower form rather than wholesale.
  • Not one KMS key per tenant (your AWS blog shape). It is genuinely the more rigorous option — destruction inside the vendor boundary with a documented waiting period beats this repo deleting a row properly. Rejected for now because it requires the vendor decision this ADR deliberately does not make (quotas, per-key cost and deletion APIs differ enough to matter), and it puts a recurring per-tenant cost in the onboarding path before there is a single tenant. Critically it is not foreclosed: the registry row is left as the seam, so backing the per-tenant secret with a per-tenant KMS key later changes only the resolver, not the derivation contract, and re-encrypts nothing.

B2 — Decision 1 no longer contradicts it

"HKDF derivation unchanged" is gone from the heading and the body, and from docs/README.md's row. Decision 1 is now a custody move only, and says out loud that Decision 3 does change derivation and argues for that separately. The per-tenant DEK rejection is split rather than fudged: kept for confidentiality (the reason — HKDF needs no stored state — is still sound), explicitly reversed for erasure (that reason never covered erasure; it said no per-tenant state is needed for confidentiality, not that none is ever needed). The "not the rejected pattern" disclaimer is deleted; it now says which half is reversed and why. The bogus third shape requirement is gone, replaced by the primitives the citations actually support.

B3 — namespaces split, not disambiguated

key_epoch is pinned global: one live master generation per deployment, a readable selector in the envelope, bumped once per master rotation, nothing per-tenant. The per-tenant erasure secret is a separate thing with a separate name, is not an epoch, and is never in the envelope — which is exactly what lets it be secret. Overloading one word for a public selector and a secret was the root of B1, so the fix is two names, and there is a small table in Decision 2 pinning scope/secrecy/in-envelope for each.

B4 — verified, and the primitive was wrong

I fetched the docs rather than reasoning about them. There is now a primitives table with inline citations and access dates, and the claim is corrected: destruction granularity is the key or key version, never the individual ciphertext. AWS: "If you delete a symmetric encryption KMS key, all remaining ciphertexts encrypted by that key are unrecoverable" (deleting-keys, access_date 2026-08-12). GCP at key-version granularity, data "considered crypto-shredded" (destroy-restore), plus version retention across rotation (key-rotation) — both access_date 2026-08-12. sources_verified: true is now earned; the doc also says plainly that reading a doc is not running the API and no account was used.

One narrowing on your citation, which I think strengthens the point rather than weakening it: I fetched the multi-tenant KMS blog and it argues one CMK per tenant for isolation and cost — it does not discuss erasure or crypto-shredding at all. So I cite it as evidence that per-tenant key material is the normal multi-tenant shape, and explicitly tell the reader to read it narrowly, rather than as AWS recommending it for erasure. The erasure conclusion is carried by the two destruction docs, which do support it directly.

What the ADR now refuses to claim

Since the mechanism is real, the limits have to be stated or I have swapped one overclaim for another. Decision 3 names them: erasure is cryptographic only to the extent the secret is destroyed everywhere it exists — backups, replicas, WAL, snapshots — so the datastore must support real destruction, not a DELETE; erasure is forward-looking (anyone who captured master + secret while live keeps what they captured, as with any crypto-shredding including KMS key deletion); and key_id stays master-scoped on purpose so a sweep job can still find an erased tenant's files, meaning erased is undecryptable, not unfindable. The backup-destruction question is now the load-bearing open question, with the honest fallbacks named (per-tenant KMS key, or word the commitment as policy-enforced).

Also added: destroying a master epoch is real cryptographic erasure at global granularity — your first horn — and Decision 2 now says so and says it is an incident response, not an offboarding, so the reader knows what rotation does and does not buy.

Non-blocking, all four taken

  • addCookies() re-verification corrected — tests/unit/session-material-tripwire.test.ts does call it outside src/session/, deliberately for the SC-04 tripwire. Confirmed and reworded to claim only what is true: no production path persists or restores session material.
  • ADR-0003 quotation marks removed, now a plain paraphrase.
  • ENVELOPE_VERSION cost named properly: relaxing parseEnvelope is not sufficient, because additionalData() binds the module constant into the AAD, so a v2 build reading a v1 file computes AAD over 2 and fails the tag. Reading old files requires the AAD to use the parsed version, which is a deliberate change to what it commits to.
  • "re-wrap" → "re-encrypt" throughout, with a paragraph on why the distinction matters for sizing (derived key ⇒ a full read-modify-write pass, not a cheap master rewrap).

Two things you did not flag that fell out of the rewrite: key_id is an HMAC under the master, so a master rotation changes it — the batch job must resolve the master by key_epoch before comparing key_id, now recorded in Consequences. And "rotate a tenant's secret without erasing it" is deliberately not decided (it would need a second per-tenant selector in the envelope), flagged in Open questions because adding it after files exist is another re-encryption pass.

Checks

npm run lint:docs clean (58 docs) and npm run ci exit 0 — secret-scan, validate:contracts, lint, lint:docs, typecheck, 405 unit / 29 files, 26 integration / 5 files. Docs-only diff, same three files, nothing in src/.

Note the PR description above still describes the old Decision 3 registry; the ADR itself is the current statement. Happy to rewrite the body if you would rather the merge commit not carry the superseded version.

Resolves the docs/README.md Decisions-table conflict: #158 landed the
ADR-0015 row on main where this branch adds ADR-0016. Both rows are kept,
in numeric order. No other file conflicted.
Resolves the docs/README.md Decisions-table conflict again: #159 landed the
ADR-0017 row on main where this branch adds ADR-0016. Both rows kept, in
numeric order. No other file conflicted.
…ors diverge

The primitives table had one cell I had not verified directly: that AWS
retains prior key material for decrypt after rotation. Fetching rotate-keys
confirms it, and turns up a difference that matters enough to record.

GCP can destroy one key version and keep the current one. AWS's built-in
rotation cannot — it 'retains all key material for a KMS key ... even if key
rotation is disabled' and 'deletes key material only when you delete the KMS
key'. So per-epoch destruction on AWS needs one KMS key per epoch, not
automatic rotation of a single key.

That constrains Decision 2, not Decision 3: the requirement is now stated as
'destroy a retired master generation without destroying the current one',
with the note that if a vendor satisfies it by separate keys rather than key
versions, key_epoch maps to a key and nothing else changes. Decision 1's
shape list is updated to match.
@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Follow-up: two merges from main, and one more citation that changed a claim

Three commits landed after the comment above; branch head is now 6c7d617.

Merged main twice (6257ff3, 6192e07). The branch had gone CONFLICTING independently of this review — #158 (ADR-0015) and then #159 (ADR-0017) each added a docs/README.md Decisions-table row where this branch adds ADR-0016. Both resolutions keep every row in numeric order; nothing else conflicted, and gh pr diff --name-only is still exactly the same three docs files. PR is MERGEABLE again.

6c7d617 — verified the last uncited cell, which turned out to change a claim. While self-checking the new primitives table I noticed one cell I had asserted rather than fetched: that AWS retains prior key material for decrypt after rotation. It does — "AWS KMS retains all key material for a KMS key … even if key rotation is disabled", and on decrypt it "uses the same version of the key material that was used to encrypt it" (rotate-keys, access_date 2026-08-12).

But the same page says AWS "deletes key material only when you delete the KMS key" — so the two vendors are not equivalent on the primitive Decision 2 leans on. GCP can destroy one key version and keep the current one; AWS's built-in rotation cannot, which means per-epoch destruction on AWS requires one KMS key per epoch (its "manual rotation"), not automatic rotation of a single key.

That constrains Decision 2, not Decision 3, so I have stated it as a requirement rather than a vendor pick: whichever vendor is chosen, the deployment must be able to destroy a retired master generation without destroying the current one — and if that is satisfied by separate keys rather than key versions, key_epoch maps to a key instead of a key version and nothing else in the decision changes. Decision 1's shape list now says the same thing. Table gained a row making the AWS/GCP divergence explicit.

Flagging it specifically because it is the second time on this ADR that an unverified vendor primitive was doing load-bearing work, which is the pattern your B4 was really about. Every cell in that table now has a fetched citation behind it.

npm run lint:docs clean (61 docs) and npm run ci exit 0 on the final merged tree.

@myselfsiddharth

Copy link
Copy Markdown
Contributor Author

Re-review — ADR-0016 at 6c7d617, independent verification of the B1–B4 fixes

Verdict: the blocking findings are resolved, and B1 is resolved for real, not renamed. I re-derived the argument from the document rather than from the revision summary, fetched all five cited pages myself, and re-ran the checks. Three non-blocking findings below, one of which (N1) is a rotation/registry interaction the ADR does not currently name and which is cheap to add now.

What I checked

  • The original review's B1–B4 and both follow-up comments, then docs/decisions/ADR-0016-session-key-custody.md in full as it now stands (487 lines), not the diff.
  • src/session/keys.ts and store.ts against the ADR's claims about derivation, key_id, ENVELOPE_VERSION and the AAD.
  • All five external URLs fetched live and compared word-for-word against the quoted text.
  • Fresh worktree at origin/track1/b5-session-key-custody-adr (6c7d617): npm run lint:docsclean (61 docs); npm run ciexit 0 (secret-scan, validate:contracts, lint, lint:docs, typecheck, 432 unit / 31 files, 26 integration / 5 files); npm run test:canary52 pass / 8 files, session-plaintext.test.ts (14) included. Docs-only, exactly three files, no conflict markers anywhere under docs/.

B1 — Resolved. The erasure secret is genuinely non-derivable, and I could not find a second copy.

The new mechanism holds up under the same test that broke the old one. The question is whether every input to TenantKey survives destruction of the registry row, and now one does not:

  • erasure_secret is 32 random bytes minted at onboarding (Decision 3). Random is not a function of anything — there is no (master, tenantId, salt) recomputation path, which was the entire content of B1.
  • I checked for an indirect leak: derivation is HKDF(master ‖ erasure_secret, salt, info), salt and info stay public, and key_id is HMAC(master, …) and deliberately does not take the secret as input. So nothing in the envelope — version, key_epoch, salt, key_id, nonce, ciphertext — is a function of the secret. There is no public value from which it can be recovered or oracle-tested (2^256 either way). The Decision 2 table pinning "in the envelope? No, never" is load-bearing and the document honours it everywhere.
  • Both inputs are fixed 32 bytes, so master ‖ erasure_secret as HKDF IKM has no concatenation ambiguity. hkdfSync(HKDF_HASH, ikm, salt, info, len) at keys.ts:179 takes the change as a one-line substitution of the IKM argument, which matches what the ADR describes.

On "is the registry row the only copy" — the ADR's caveat is real and, checking it independently, stronger than the ADR claims. The secret is stored wrapped under the CMK, and AWS states on the page already cited here that "AWS KMS does not store this information and does not store any of the ciphertexts" (deleting-keys). So the KMS genuinely cannot reconstitute a destroyed row: the ciphertext existed only in the registry. That is worth one sentence in Decision 3, because it closes the "derivable from KMS in some way that survives destruction" question outright rather than by assertion.

The backup caveat is not hand-waving. "The datastore must support real destruction, not just a DELETE" names backups, replicas, WAL and snapshots specifically, is repeated as the load-bearing open question, names two concrete fallbacks (per-tenant KMS key, or word the commitment as policy-enforced), and ends "This ADR picks the shape; it does not get to declare the guarantee met." The forward-looking bullet and the "undecryptable, not unfindable" bullet are the other two limits an erasure claim usually elides, and both are stated. session-state-encryption.md's offboarding row carries the same bound ("Strength is bounded by how completely that secret can be destroyed, backups included"), so the cross-link no longer advertises more than the mechanism delivers — which was the specific reason B1 blocked.

B2 — Resolved, structurally rather than by rewording.

"HKDF derivation unchanged" is gone from the Decision 1 heading, from its body, and from docs/README.md's row (which now says the opposite: "Changes derivation, deliberately before a first caller exists"). Decision 1 states outright that Decision 3 does change the derivation and says why the two are kept apart. The DEK rejection is split at the heading level — "Rejected as the confidentiality mechanism" — and the paragraph that follows distinguishes "HKDF gives the same confidentiality with no per-tenant state" from "no per-tenant state is ever needed," which is the actual scope of the original argument; Decision 3 then says which half it reverses. The "this is not the rejected pattern" disclaimer is gone. Decision 1's shape list no longer asks for per-ciphertext revocation — the only occurrence of "revok" in the file is the sentence saying the shape is explicitly not that, "which is why Decision 3 does not ask for it." All three sub-findings are gone at the source, not papered over.

B3 — Resolved. I checked all 25 occurrences of "epoch".

Every one is master-scoped and global. The two that could have re-introduced the overload do not: line 334 quotes the superseded draft's wording explicitly as superseded, and the Open Questions item on rotating a tenant's secret says it would need "a second selector beside key_epoch" — i.e. it names the ambiguity as a thing deliberately not added rather than quietly reusing the field. The per-tenant concept is never called an epoch and never appears in the envelope. The scope/secrecy/in-envelope table in Decision 2 is the right shape for this and is consistent with every later use.

B4 — Resolved, and I verified every quotation against the live page.

Claim Verified
"After a KMS key is deleted, you can no longer decrypt the data that was encrypted under that KMS key, which means that data becomes unrecoverable" ✅ verbatim, deleting-keys intro
"If you delete a symmetric encryption KMS key, all remaining ciphertexts encrypted by that key are unrecoverable" ✅ verbatim (see nit N4 on where it lives)
AWS waiting period 7–30 days, default 30 ✅ "AWS KMS requires you to set a waiting period of 7 – 30 days. The default waiting period is 30 days."
"Destroying a key version means that the key material is permanently deleted" / "crypto-shredded" ✅ verbatim, destroy-restore
GCP default 30-day window, restorable until it elapses, key-version granularity ✅ verbatim
"Rotating keys creates new active key versions, but doesn't re-encrypt your data and doesn't disable or delete previous key versions" ✅ verbatim, key-rotation
"AWS KMS retains all key material for a KMS key … even if key rotation is disabled" + "deletes key material only when you delete the KMS key" + decrypt "uses the same version of the key material that was used to encrypt it" ✅ verbatim, rotate-keys
Manual rotation = a new KMS key ✅ "You might decide to create a new KMS key and use it in place of the original KMS key. This has the same effect as rotating the key material … often thought of as manually rotating the key"
Blog: per-tenant CMK, "$1 monthly" ✅ "A single AWS KMS key costs $1 monthly, increasing to a maximum of $3 per month with two or more key rotations."
Blog does not discuss erasure or crypto-shredding ✅ confirmed — I looked for it specifically. The post covers isolation, cost and governance; the only offboarding sentence is about deleting aliases. The narrow-reading caveat is correct and I would have raised it if it were not there.

The corrected primitive is right, and so is the self-caught AWS/GCP divergence — this is the finding I most expected to be asserted once and forgotten, and it is not. It appears in four places that agree: the primitives table gains a dedicated "destroy one prior generation while keeping the current one" row (AWS No / GCP Yes); the paragraph under the table draws the consequence; Decision 1's shape list is restated as "destroy a retired generation without destroying the current one … which AWS and GCP satisfy by different means (Decision 2)"; and Decision 2 closes by converting it into a vendor-neutral requirement plus the mapping fallback ("key_epoch maps to a key rather than to a key version, and nothing else in this decision changes"). That is the constraint actually doing work in the decision, not a footnote.

sources_verified: true is now earned. Inline URLs + access_date is the repo's convention (ADR-0001/0003/0007), the frontmatter matches every other ADR, and I found no remaining uncited external claim.


Non-blocking

N1. Rotation destroys the epoch's key material — and the registry rows are wrapped under it. Not stated, and it is the one omission with teeth.

Decision 3 stores erasure_secret "wrapped under the same CMK as the master." Decision 2 requires that a retired master generation's key material be destroyable, and records that on AWS that means "one KMS key per epoch." Compose the two: on the AWS mapping, the epoch's KMS key is the key the registry rows are wrapped under, so destroying a retired epoch after the grace window destroys every tenant's erasure secret — including tenants whose files were already re-encrypted under the new epoch, whose keys still need that secret. Global erasure as a side effect of a routine rotation.

The fix is one sentence of sequencing, not a design change: a rotation must re-wrap the registry under the new generation's key before the retired one is destroyed, and the batch job in Decision 2 owns that step alongside re-encrypting files. This is exactly the class of thing the ADR already chose to record for key_id ("a sequencing constraint the implementation cannot discover late") — the same paragraph is the natural home. Related and smaller: Decision 1's "one CMK per deployment environment" and Decision 2's "one KMS key per epoch" are in mild tension about what "the same CMK" in Decision 3 refers to once the AWS mapping is taken; naming the referent once would settle it.

N2. "What this actually buys, stated exactly" omits the in-process copy.

The list covers backups/replicas/WAL/snapshots, forward-looking capture, and findability — but not a live process that resolved the secret before erasure and still holds it (or a derived TenantKey) in memory. Decision 1 sets the precedent explicitly: the master is held "in memory for the process lifetime." The resolver-that-throws design is a per-resolution guarantee, so a cached value is precisely the "check a caller can forget" that Decision 3 is written to eliminate. Erasure is therefore effective against new resolutions, not necessarily against a running process, and that is a one-bullet addition to a list whose whole point is being exhaustive ("Specifically:").

N3. The "strictly stronger than a per-tenant DEK" argument is measured against the wrong baseline. The conclusion survives; the argument as written overstates.

"Someone who exfiltrates the entire registry still has nothing without the KMS-custodied master" is true — but it is equally true of a wrapped DEK, which is the shape actually being rejected. Wrapped DEKs in a stolen registry also yield nothing without KMS unwrap, so that clause is not a discriminator. The second clause ("someone who has the master still has nothing for a tenant whose row is gone") is an erasure property, and a wrapped-DEK registry has it identically — destroying the row destroys the key either way. So neither clause supports "strictly stronger" against the shape named.

The real, narrower difference is: an adversary who obtains one unwrapped per-tenant value gets that tenant's plaintext under the DEK shape and gets nothing without the master under this one. That is genuine defence in depth and a genuine segregation-of-duties gain (registry access alone is insufficient). It is also bounded: the master and the registry rows are wrapped under the same CMK and unwrapped by the same principal, and the master sits unwrapped in the memory of any process that reads session state — so against CMK-principal compromise the two shapes are equivalent.

The rejection still holds, and the argument that carries it is the one already in the paragraph and not being leaned on: this shape composes with the derivation that exists (HKDF, per-file salt, info, key_id, the envelope all keep working with one new secret input) where a DEK replaces it. I would lead with that and state the security delta at its true size. Flagging it because "strictly stronger, same storage cost" is a stronger claim than the evidence, in a document whose revision was about exactly that.

N4. Two citation-precision nits, neither of which changes a conclusion.

  • The rotate-keys ellipsis elides an origin qualifier: the source reads "AWS KMS retains all key material for a KMS key with AWS_KMS origin, even if key rotation is disabled." The conclusion survives the EXTERNAL-origin case too — imported material can be deleted individually, but the same page says "The KMS key becomes unusable as soon as any of its materials expires or is deleted," so you still cannot drop one generation and keep the current one. Naming that makes the row stronger, not weaker.
  • "If you delete a symmetric encryption KMS key, all remaining ciphertexts encrypted by that key are unrecoverable" lives in the Deleting KMS keys with imported key material subsection. The claim is independently carried by the unconditional statement in the page intro, which the ADR also quotes, so nothing is wrong — just worth knowing which sentence is the general one.

Mergeability and the README

MERGEABLE; mergeStateStatus: BLOCKED is REVIEW_REQUIRED only — all 12 checks are SUCCESS (lint-typecheck-test-secrets, privacy-canary, CodeQL, testbed-smoke, guards, labels). The two main merges are clean: docs/README.md's Decisions table carries ADR-0001 through ADR-0017 with no gaps and no duplicates, ADR-0016 sits in numeric order between 0015 and 0017, and there are no conflict markers anywhere under docs/. git diff --name-only origin/main...HEAD is still exactly the three docs files, so nothing else regressed in the merges.

Verdict

Safe to merge. B1, B2, B3 and B4 are all genuinely resolved — B1 by a mechanism that survives the test that broke its predecessor, B4 by citations I checked one at a time against the live pages, including the negative claim about what the AWS blog does not say. The self-caught AWS/GCP divergence is correct and is wired through the decision rather than mentioned once. N1–N4 are follow-ups; N1 is the one I would take before the implementation issue is written, since a rotation that silently erases every tenant is a bad thing to discover from the implementation side.

I have not approved and have not merged, per the reviewer's remit. The PR description still described the superseded counter-based Decision 3 and asserted that derivation does not change — since the verdict is merge-safe and a squash commit inherits that text, I have rewritten the body to describe what the document actually says now.

@myselfsiddharth
myselfsiddharth merged commit e36e3be into main Aug 12, 2026
13 checks passed
@myselfsiddharth
myselfsiddharth deleted the track1/b5-session-key-custody-adr branch August 12, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation privacy-boundary Touches the privacy boundary — canary is merge-blocking proposal Design / governance proposal size/L <= 600 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session-key custody: the deferred half of SC-01 (KMS, rotation, tenant offboarding)

1 participant