feat(stack)!: EQL v3 audit-on-decrypt + collapse EncryptionV3 into Encryption (EQL v2 removal PR 3)#768
Conversation
…cryption PR 3 of the EQL v2 removal (#707). Makes EQL v3 the sole generation the client authors and writes, while preserving the minimal v2 READ path on the core client and through the DynamoDB adapter. - Add MappedDecryptOperation: the typed client's decryptModel/bulkDecryptModels are now audit-chainable (.audit()/.withLockContext()) instead of a bare Promise, restoring audited decrypt including through encryptedDynamoDB. - Collapse EncryptionV3 into an overloaded Encryption; EncryptionV3 is now a deprecated, type-identical alias. An explicit config.eqlVersion is honored (the migration escape hatch is retained). - BREAKING: remove the DynamoDB v2 WRITE overloads (encryptModel/bulkEncryptModels); decrypt still reads existing v2 items. - Deprecate (retain) ClientConfig.eqlVersion and the ./schema v2 builders for legacy v2 read/migrate; siblings still consume them, so full removal is deferred to a later PR. - Tests: flip the audit-surface type assertions, add runtime audit-forwarding tests (both chaining orders), and v2-read acceptance integration tests (core + DynamoDB). - Update stash-dynamodb and stash-encryption skills and AGENTS.md. Changesets: @cipherstash/stack minor (audit-on-decrypt), @cipherstash/stack major (DynamoDB v2-write removal), stash patch (skills).
Verification of the EncryptionV3->Encryption collapse surfaced a regression: because Encryption now returns the TYPED client for a v3 schema set, stack-supabase's encryptedSupabaseV3 (which casts to the nominal overload and calls decryptModel(row)/bulkDecryptModels(rows) one-arg) hit the typed methods with no table argument and threw on undefined.tableName. The sibling build passed because it is a type/runtime mismatch with no v3 runtime coverage. Fix: the typed client's decryptModel/bulkDecryptModels now tolerate a missing table, degrading to nominal behaviour (decrypt without date reconstruction) instead of dereferencing undefined — restoring exactly what stack-supabase received before the collapse. table stays required for genuinely-typed callers. - Add regression tests: typed client one-arg decryptModel/bulkDecryptModels decrypt without throwing (no reconstruction). - Correct now-stale customer-visible source docs flagged in review: the resolveDecryptResult docblock, the encryptedDynamoDB version-mismatch error string and @example, and the EncryptedDynamoDBConfig JSDoc (all pointed at EncryptionV3 / needless eqlVersion:3). Update construct-guard.test.ts to the new error wording. Follows 686004f.
🦋 Changeset detectedLatest commit: 93286ad The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- MappedDecryptOperation.execute returns a fresh Result on the unknown-table path, so ops can't alias/mutate a shared failure object. - Document the AnyV3Table cast in Encryption with a biome-ignore explaining the runtime isV3Only guard the compiler can't see. - Restore CS_WORKSPACE_CRN in decrypt-audit-forwarding.test.ts afterEach so it doesn't leak env state across suites. Verified the v2-read acceptance tests (integration/shared/v2-decrypt-compat) match the CI CS_IT_SUITE glob, so #1a/#1b run under test:integration.
freshtonic
left a comment
There was a problem hiding this comment.
Reviewed by Claude Code on behalf of James Sadler.
Verdict: Approve (non-blocking suggestions)
I reviewed the two packages/stack commits against the base branch, with focus on the three risk areas: the audit-on-decrypt path, the EncryptionV3->Encryption collapse not silently changing crypto behavior/defaults, and security. No blocking correctness or security defects found. The design is sound and the tests are well-targeted at the genuinely risky bits.
What I verified
- "Audit-on-decrypt" is opt-in metadata forwarding, not a mandatory audit gate. This restores the v2-era
.audit()surface on the typed client'sdecryptModel/bulkDecryptModels, which the pre-PR code silently dropped byawait-ing and mapping (collapsing the chain). There is no path where audit was previously guaranteed and is now skipped -- auditing was always caller-driven. So there is no "some decrypts skip their audit" gap; this is strictly an auditability improvement. MappedDecryptOperationis correct and fail-safe.execute()never throws: unknown-table -> precomputedunknownTableFailure(fresh-cloned so no aliasing), underlying failure propagates unmapped (no Date reconstruction on failure), andmapis a pure precomputed reconstructor. Audit delegates to the underlying op that actually runs the decrypt, sogetAuditData()is read on the right instance. Both chaining orders (.audit().withLockContext()and.withLockContext().audit()) and thelockContext-as-arg form are covered by runtime tests assertingunverifiedContext+ per-ciphertext lock context reach the (mocked) FFI. The DynamoDB half is covered inresolve-decrypt.test.ts.- The collapse does not change default wire format or defaults.
Encryptionreturns the typed client only underisV3Only && eqlVersion === 3;isV3Only = schemas.every(hasBuildColumnKeyMap)uses the same v3 marker asresolveEqlVersion, so the two agree. Default (noconfig.eqlVersion) still auto-detects: all-v3 -> 3, v2 -> FFI v2 default, mixed -> throws. The empty-schema guard runs beforeisV3Only, so[].every() === trueis never reached. ConvertingEncryptionfrom aconstarrow to a hoistedfunctiondeclaration is what makes the new circular import with./v3(EncryptionV3 = Encryption) safe -- function declarations are initialized at link time, so the binding is never observed uninitialised. Correct and necessary. - v2 read compatibility is preserved and tested. Encrypt/write narrows to v3; decrypt keeps the v2 envelope reconstruction. The new live integration test mints genuine v2 payloads via the retained
eqlVersion: 2escape hatch and round-trips them through both the core client and the DynamoDB adapter.
Non-blocking suggestions
- Stale log + comment in
resolveDecryptResult(packages/stack/src/dynamodb/helpers.ts). The outer doc comment was updated, but the inner comment and thelogger.debug(...)still say "the typed EncryptionV3 client returns a plain promise with no decrypt audit surface ... useEncryption({ config: { eqlVersion: 3 } })for audited decrypts." That is now false -- the typed client has the audit surface. The branch only fires for a non-conforming custom client with no.audit(), so it is harmless, but the guidance is misleading. Update or drop the message. MappedDecryptOperation.withLockContextsilently ignores a second lock context when the underlying op is already lock-bound (e.g.decryptModel(input, table, lcA).withLockContext(lcB)-- the arg wins,lcBis dropped). The wrapper always exposeswithLockContext, so this widens the footgun slightly versus the nominal path where the method is absent post-bind. It fails closed (a mismatched context makes ZeroKMS reject the decrypt; no unauthorized success or data leak), so it is not a security hole -- but consider throwing on a re-bind, or documenting it.- Semver labelling nuance. Making
Encryption({ schemas: [<v3>] })(notEncryptionV3) now return the typed client changes both its return type and adds Date reconstruction on the two-argdecryptModelfor existing plain-Encryptionv3 callers. That is arguably a breaking type/runtime change rather than "minor." It is harmless in practice because the same package already carries amajorchangeset (DynamoDB v2-write removal), so the released version bump is correct either way -- noting only for accuracy.
Nice work -- the multi-agent verification catch on stack-supabase's one-arg nominal decrypt (the passthroughRow degradation) is exactly the kind of cross-package runtime break that a build-green check would miss, and it is now pinned by a test.
…ent lock-context re-bind
- resolveDecryptResult: the inner comment and `logger.debug` message still
said the typed client has no decrypt audit surface and told the reader to
use `Encryption({ config: { eqlVersion: 3 } })`. Both shipped clients now
carry `.audit()` on decrypt, so the branch only fires for a non-conforming
custom client. Message rewritten to describe that, with a regression test
asserting it names neither `eqlVersion` nor `EncryptionV3`. The same false
claim had propagated to the resolve-decrypt test header, `dynamodb/types.ts`,
a test literally named "though decrypt cannot carry it", and a comment in
`stack-supabase` — all corrected.
- MappedDecryptOperation.withLockContext: chaining a second lock context onto
an already-bound op silently dropped it. The wrapper always exposes the
method, unlike the nominal path where it is absent after binding, so the
re-bind type-checks. It now throws. Verified no internal or sibling call
site chains after a positional bind (stack-supabase calls `decryptModel(row)`
one-arg then chains, so its underlying op is unbound). Tests cover both
`decryptModel` and `bulkDecryptModels`.
- Changeset bumped minor -> major. Making `Encryption({ schemas: [<v3>] })`
return the typed client changes its return type AND adds `Date`
reconstruction on the two-arg `decryptModel` for existing plain-`Encryption`
v3 callers. The package already carried a major, so the released bump is
unchanged — the changelog is just accurate now.
- skills/stash-encryption documented `decryptModel`/`bulkDecryptModels` as
returning `Promise<Result<…>>` and said only encrypt-side ops are chainable.
It ships in the `stash` tarball, so that was wrong guidance in customer
repos. Updated, including the one-or-the-other lock-context rule.
EQL v2 removal — PR 3 (stack core)
Part of #707. Stacks on #761 (PR 2); base branch is
feat/remove-eql-v2-pr2-migrate, so this PR's diff is just the twopackages/stackcommits. Implementsdocs/plans/2026-07-22-eql-v2-removal-pr3-plan.md.Makes EQL v3 the sole generation
packages/stackauthors and writes, while preserving the minimal code needed to read existing v2 payloads — on the core client and through the DynamoDB adapter.What changed
MappedDecryptOperationwraps the underlying decrypt op, mapping the result through the precomputedDatereconstructor while delegating.audit()/.withLockContext(). The typed client'sdecryptModel/bulkDecryptModelsreturn this chainable op instead of a barePromise, so audit metadata forwards to ZeroKMS in both chaining orders — including throughencryptedDynamoDB(...).decryptModel(...), which previously had nowhere to put it.EncryptionV3collapsed intoEncryption.Encryptionis now overloaded: a concrete EQL v3 schema set yields the strongly-typed client; a v2/loose set yields the nominal client.EncryptionV3is a deprecated, type-identical alias. An explicitconfig.eqlVersionis honored (the migration escape hatch is retained).encryptModel/bulkEncryptModelsno longer accept a v2 table. Decrypt still reads existing v2 items — the v2 envelope reconstruction and the v2 decrypt overloads are kept.ClientConfig.eqlVersionand the@cipherstash/stack/schemav2 builders, for legacy v2 read/migrate. Siblings (stack-supabase → PR 4, stack-drizzle → PR 5) still consume these, so full removal is deferred per the plan's sequencing constraint.Changesets
@cipherstash/stackminor — audit-on-decrypt + theEncryptionV3→Encryptioncollapse.@cipherstash/stackmajor — DynamoDB v2-write removal (own line, own breaking change).stashpatch — skills refresh (skills/ships in thestashtarball).Skills & meta
Updated
skills/stash-dynamodb,skills/stash-encryption, andAGENTS.mdfor the collapse (encrypt v3-only / decrypt reads v2;Encryptioncanonical,EncryptionV3deprecated; audit-on-decrypt works).Testing
pnpm --filter @cipherstash/stack build/test:types(89 type tests) /test(credential-free) all green.stack-supabase,stack-drizzle,stash,nextjs.code:checkerror-free;changeset statusclean in pre-mode;--frozen-lockfileclean.integration/shared/v2-decrypt-compat.integration.test.ts) cover v2 read round-trip on the core client (#1a) and through DynamoDB (#1b); they run undertest:integrationwithCS_*credentials.Verification note
A multi-agent verification pass caught one regression that the sibling build could not: because
Encryptionnow returns the typed client for a v3 schema set,stack-supabase'sencryptedSupabaseV3(which casts to the nominal overload and callsdecryptModel(row)one-arg) would throw at read time. Fixed by making the typed client's decrypt methods tolerate a missingtable, degrading to nominal behavior — exactly what stack-supabase received before the collapse. See commit04f19427.Follow-ups for later PRs (out of scope here): add v3 runtime coverage for
encryptedSupabaseV3, and update the wizard's generated decrypt guidance to the two-arg form.