Skip to content

feat(stack)!: EQL v3 audit-on-decrypt + collapse EncryptionV3 into Encryption (EQL v2 removal PR 3)#768

Merged
tobyhede merged 4 commits into
remove-v2from
feat/remove-eql-v2-pr3-stack-core
Jul 23, 2026
Merged

feat(stack)!: EQL v3 audit-on-decrypt + collapse EncryptionV3 into Encryption (EQL v2 removal PR 3)#768
tobyhede merged 4 commits into
remove-v2from
feat/remove-eql-v2-pr3-stack-core

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

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 two packages/stack commits. Implements docs/plans/2026-07-22-eql-v2-removal-pr3-plan.md.

Makes EQL v3 the sole generation packages/stack authors and writes, while preserving the minimal code needed to read existing v2 payloads — on the core client and through the DynamoDB adapter.

What changed

  • Audit-on-decrypt for the typed client. New MappedDecryptOperation wraps the underlying decrypt op, mapping the result through the precomputed Date reconstructor while delegating .audit() / .withLockContext(). The typed client's decryptModel / bulkDecryptModels return this chainable op instead of a bare Promise, so audit metadata forwards to ZeroKMS in both chaining orders — including through encryptedDynamoDB(...).decryptModel(...), which previously had nowhere to put it.
  • EncryptionV3 collapsed into Encryption. Encryption is now overloaded: a concrete EQL v3 schema set yields the strongly-typed client; a v2/loose set yields the nominal client. EncryptionV3 is a deprecated, type-identical alias. An explicit config.eqlVersion is honored (the migration escape hatch is retained).
  • DynamoDB v2 write removal (breaking). encryptModel / bulkEncryptModels no longer accept a v2 table. Decrypt still reads existing v2 items — the v2 envelope reconstruction and the v2 decrypt overloads are kept.
  • Deprecated (retained): ClientConfig.eqlVersion and the @cipherstash/stack/schema v2 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/stack minor — audit-on-decrypt + the EncryptionV3Encryption collapse.
  • @cipherstash/stack major — DynamoDB v2-write removal (own line, own breaking change).
  • stash patch — skills refresh (skills/ ships in the stash tarball).

Skills & meta

Updated skills/stash-dynamodb, skills/stash-encryption, and AGENTS.md for the collapse (encrypt v3-only / decrypt reads v2; Encryption canonical, EncryptionV3 deprecated; audit-on-decrypt works).

Testing

  • pnpm --filter @cipherstash/stack build / test:types (89 type tests) / test (credential-free) all green.
  • Sibling builds green: stack-supabase, stack-drizzle, stash, nextjs.
  • code:check error-free; changeset status clean in pre-mode; --frozen-lockfile clean.
  • New runtime tests prove audit metadata forwards on decrypt in both chaining orders (mocked FFI, credential-free), plus flipped type-surface assertions.
  • Live acceptance tests (integration/shared/v2-decrypt-compat.integration.test.ts) cover v2 read round-trip on the core client (#1a) and through DynamoDB (#1b); they run under test:integration with CS_* credentials.

Verification note

A multi-agent verification pass caught one regression that the sibling build could not: 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) one-arg) would throw at read time. Fixed by making the typed client's decrypt methods tolerate a missing table, degrading to nominal behavior — exactly what stack-supabase received before the collapse. See commit 04f19427.

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.

tobyhede added 2 commits July 22, 2026 23:28
…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.
@tobyhede
tobyhede requested a review from a team as a code owner July 22, 2026 23:58
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 93286ad

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack Major
stash Major
@cipherstash/bench Patch
@cipherstash/prisma-next Major
@cipherstash/stack-drizzle Major
@cipherstash/stack-supabase Major
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Major

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

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 809de1cf-6391-4bc1-987d-5879f5315669

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/remove-eql-v2-pr3-stack-core

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- 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 freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's decryptModel / bulkDecryptModels, which the pre-PR code silently dropped by await-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.
  • MappedDecryptOperation is correct and fail-safe. execute() never throws: unknown-table -> precomputed unknownTableFailure (fresh-cloned so no aliasing), underlying failure propagates unmapped (no Date reconstruction on failure), and map is a pure precomputed reconstructor. Audit delegates to the underlying op that actually runs the decrypt, so getAuditData() is read on the right instance. Both chaining orders (.audit().withLockContext() and .withLockContext().audit()) and the lockContext-as-arg form are covered by runtime tests asserting unverifiedContext + per-ciphertext lock context reach the (mocked) FFI. The DynamoDB half is covered in resolve-decrypt.test.ts.
  • The collapse does not change default wire format or defaults. Encryption returns the typed client only under isV3Only && eqlVersion === 3; isV3Only = schemas.every(hasBuildColumnKeyMap) uses the same v3 marker as resolveEqlVersion, so the two agree. Default (no config.eqlVersion) still auto-detects: all-v3 -> 3, v2 -> FFI v2 default, mixed -> throws. The empty-schema guard runs before isV3Only, so [].every() === true is never reached. Converting Encryption from a const arrow to a hoisted function declaration 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: 2 escape hatch and round-trips them through both the core client and the DynamoDB adapter.

Non-blocking suggestions

  1. Stale log + comment in resolveDecryptResult (packages/stack/src/dynamodb/helpers.ts). The outer doc comment was updated, but the inner comment and the logger.debug(...) still say "the typed EncryptionV3 client returns a plain promise with no decrypt audit surface ... use Encryption({ 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.
  2. MappedDecryptOperation.withLockContext silently ignores a second lock context when the underlying op is already lock-bound (e.g. decryptModel(input, table, lcA).withLockContext(lcB) -- the arg wins, lcB is dropped). The wrapper always exposes withLockContext, 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.
  3. Semver labelling nuance. Making Encryption({ schemas: [<v3>] }) (not EncryptionV3) now return the typed client changes both its return type and adds Date reconstruction on the two-arg decryptModel for existing plain-Encryption v3 callers. That is arguably a breaking type/runtime change rather than "minor." It is harmless in practice because the same package already carries a major changeset (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.
Base automatically changed from feat/remove-eql-v2-pr2-migrate to remove-v2 July 23, 2026 05:16
@tobyhede
tobyhede merged commit b52e17b into remove-v2 Jul 23, 2026
13 checks passed
@tobyhede
tobyhede deleted the feat/remove-eql-v2-pr3-stack-core branch July 23, 2026 05:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants