Skip to content

feat(stack): EQL v3 support in encryptedDynamoDB (#657)#725

Merged
tobyhede merged 20 commits into
mainfrom
feat/dynamodb-eql-v3
Jul 22, 2026
Merged

feat(stack): EQL v3 support in encryptedDynamoDB (#657)#725
tobyhede merged 20 commits into
mainfrom
feat/dynamodb-eql-v3

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Closes #657.

Implements EQL v3 support in encryptedDynamoDB (packages/stack/src/dynamodb, @cipherstash/stack/dynamodb), and gives the adapter its first-ever test coverage.

Scope correction

The issue body pointed at packages/protect-dynamodb; the API it describes (encryptedDynamoDB) lives in packages/stack/src/dynamodb. That adapter had zero tests — nothing referenced it but its own source. So this is a port plus the test suite that was never written, and the tests were the larger half.

Two things in the issue's analysis turned out wrong, both verified:

  • The cast_as / indexes.ste_vec coupling is not a coupling — a v3 column's build() emits the same { cast_as, indexes } shape as v2.
  • packages/protect-dynamodb's workspace:* peerDependency is not broken on npm — the publish tooling rewrites it (@cipherstash/protect-dynamodb@12.0.1 ships a concrete version).

What landed

  • v3 tables accepted alongside v2 — additive, not a breaking swap. DynamoDB has no EQL extension to install and no migration to run, so there's no forcing function to move existing callers.
  • The real coupling was the write path, not where the issue placed it: toEncryptedDynamoItem detected an encrypted value by its k: 'ct' tag, but v3 scalars carry no k at all — so every v3 scalar fell through and was written as a raw map. Now gated on the payload's v+i.
  • Nested v3 attributes work via a flat dotted column path ('profile.ssn': types.TextEq('profile.ssn')) — selected leaves encrypted in place, plaintext siblings preserved, __hmac intact for key conditions. Verified end-to-end; this was initially mislabelled a limitation.
  • Both client shapes accepted (EncryptionV3 typed client and nominal Encryption({ config: { eqlVersion: 3 } })). Audit-on-decrypt requires the nominal client; documented.
  • Strongly-typed surface — per-version overloads; the v3 arm derives the model from the table (V3ModelInput in, EncryptedAttributes mapped type out, with __hmac present only for equality domains). v2 arm unchanged.

Review and the bugs it caught

A three-way review (architecture / types / tests) after the first cut found four data-integrity bugs, two of them silent data loss, none caught by the 80 example-based tests (every test table used property === dbName). All fixed with regression tests:

  1. Critical — a v3 column whose JS property differs from its DB name (emailAddress: types.TextEq('email_address')) was never split: no __source, no __hmac, i block mangled. Equality querying silently did nothing. Now uses the core resolveEncryptColumnMap.
  2. Critical (introduced here) — widening the payload predicate to "has c" meant any nested plaintext object with a c key lost its siblings. Now gated on v+i.
  3. deepClone flattened Date to {}, making the whole Timestamp*/Date* domain family unusable. Now structuredClone.
  4. Read path dropped any attribute ending __hmac and treated any nested *__source as an envelope — now scoped to declared columns.

Tests

  • 5 files under packages/stack/__tests__/dynamodb/: v2 characterisation, v3 e2e, pure helpers (v2 + v3), and properties.test.ts (14 fast-check properties, proven non-vacuous by mutation — reintroducing the 4 bugs fails exactly 6 properties).
  • client-compat.test-d.ts — type-level assertions that both client shapes assign without a cast (the M1-class gap vitest.config.ts warns about).

Verification

  • pnpm --filter @cipherstash/stack test1006 passed / 3 skipped
  • dynamodb suite: 104 tests (from zero)
  • test:types 80, code:check 0 errors, build clean
  • src/ type errors 0 against the real compiler

Decisions on record

  • packages/protect-dynamodb not ported — recommend retiring in a separate PR.

Follow-ups (non-blocking)

Filed as issues:

Not yet filed:

  • v2 overload return type still returns the input model (v2 columns carry no capability in the type — larger change).
  • The live suite is now ~24s and can trip ZeroKMS throttling on repeated back-to-back runs.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added EQL v3 DynamoDB table support with dotted-path nested attributes and correct query-term storage (<col>__source / optional <col>__hmac).
    • Expanded encryptedDynamoDB overloads to work with both v3 client styles and improved stored-vs-plaintext typing.
    • Exported additional DynamoDB adapter types for clearer public signatures.
  • Bug Fixes
    • Fixed v3 encrypted-scalar writes to split query-term data correctly (not stored as raw maps).
  • Tests
    • Added live E2E, property-based, and TypeScript typing contract coverage across v2/v3.
  • Documentation
    • Updated DynamoDB EQL v3 schema and encryptQuery guidance.

@tobyhede
tobyhede requested a review from a team as a code owner July 20, 2026 23:34
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 23b677a

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 Minor
stash Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Minor

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 20, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

encryptedDynamoDB now supports EQL v3 tables and typed or nominal clients. It maps v3 payloads to __source and optional __hmac attributes, reconstructs version-specific envelopes, preserves v2 behavior, and adds extensive typing, integration, property-based, and documentation coverage.

Changes

DynamoDB EQL v3 integration

Layer / File(s) Summary
Public v3 contracts
packages/stack/src/dynamodb/types.ts, packages/stack/src/dynamodb/index.ts
Adds unified table/client types, v3 encrypted and decrypted attribute mappings, and overloads for single and bulk operations while retaining v2 signatures.
Version-aware payload mapping
packages/stack/src/dynamodb/helpers.ts, packages/stack/src/types.ts, packages/stack/src/encryption/..., packages/stack/src/wasm-inline.ts
Detects stored payloads structurally, splits v3 values into DynamoDB attributes, matches dotted column paths, reconstructs v2 or v3 envelopes, and centralizes v3 table detection.
Operation execution and client compatibility
packages/stack/src/dynamodb/operations/*, packages/stack/src/dynamodb/index.ts
Updates encryption and decryption operations to accept unified clients and tables, normalize results, resolve column paths, validate table registration, and preserve error codes.
Behavioral validation and documentation
packages/stack/__tests__/dynamodb/*, skills/stash-dynamodb/SKILL.md, AGENTS.md, .changeset/dynamodb-eql-v3.md
Adds type, live, helper, regression, property-based, and domain coverage and documents EQL v2/v3 schemas, nested paths, query limitations, and wire formats.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • Issue #657 — directly tracks EQL v3 support in encryptedDynamoDB.
  • Issue #534 — relates to adopting EQL v3 domain types in DynamoDB schemas.
  • Issue #707 — covers prerequisite DynamoDB EQL v3 adapter work.

Possibly related PRs

Suggested reviewers: freshtonic

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes add v3 table support, v3 payload handling, typed overloads, and retain v2 compatibility as requested in #657.
Out of Scope Changes check ✅ Passed The additional tests, typing, error handling, and documentation updates all support the v3 DynamoDB adapter work and are not unrelated.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: EQL v3 support in encryptedDynamoDB.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dynamodb-eql-v3

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
.changeset/dynamodb-eql-v3.md (1)

39-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider dropping the hardcoded test count. "74 tests" is already inconsistent with the PR's reported DynamoDB test count and will drift as tests are added; a changeset reads better describing the capability rather than a fixed number.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/dynamodb-eql-v3.md around lines 39 - 40, Remove the hardcoded “74
tests” count from the DynamoDB adapter description in the changeset, while
preserving the statement that v2 and v3 paths now have test coverage where
previously none existed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts`:
- Around line 196-239: Update the documentation around the live property test to
state the actual numRuns cap of 5 instead of 15, including the description of
the intended run count. Keep the test configuration and behavior unchanged.
- Around line 17-19: Guard the encrypted DynamoDB integration suite with the
existing suite-level credential check, such as skipIf(...) or
requireIntegrationEnv(['cipherstash']), before beforeAll runs. Ensure the check
covers CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, and CS_CLIENT_ACCESS_KEY
so the suite is skipped rather than failing when CipherStash credentials are
missing.

In `@packages/stack/src/dynamodb/operations/encrypt-model.ts`:
- Around line 53-63: The encrypt-model and bulk-encrypt-model flows pass
property names directly to toEncryptedDynamoItem, so update both call sites to
provide this.table or the equivalent toColumnName mapping and emit configured
database column names while still reading client data by property name. Apply
this in packages/stack/src/dynamodb/operations/encrypt-model.ts lines 53-63 and
packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts lines 56-68.

---

Nitpick comments:
In @.changeset/dynamodb-eql-v3.md:
- Around line 39-40: Remove the hardcoded “74 tests” count from the DynamoDB
adapter description in the changeset, while preserving the statement that v2 and
v3 paths now have test coverage where previously none existed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a71d6a8-907f-4b7d-9fef-684b4b9c6a69

📥 Commits

Reviewing files that changed from the base of the PR and between 4888995 and b8e9fa5.

📒 Files selected for processing (16)
  • .changeset/dynamodb-eql-v3.md
  • AGENTS.md
  • packages/stack/__tests__/dynamodb/client-compat.test-d.ts
  • packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts
  • packages/stack/__tests__/dynamodb/encrypted-dynamodb.test.ts
  • packages/stack/__tests__/dynamodb/helpers-v3.test.ts
  • packages/stack/__tests__/dynamodb/helpers.test.ts
  • packages/stack/__tests__/dynamodb/properties.test.ts
  • packages/stack/src/dynamodb/helpers.ts
  • packages/stack/src/dynamodb/index.ts
  • packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts
  • packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts
  • packages/stack/src/dynamodb/operations/decrypt-model.ts
  • packages/stack/src/dynamodb/operations/encrypt-model.ts
  • packages/stack/src/dynamodb/types.ts
  • skills/stash-dynamodb/SKILL.md

Comment thread packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts
Comment thread packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts
Comment thread packages/stack/src/dynamodb/operations/encrypt-model.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/stack/src/dynamodb/helpers.ts (2)

346-351: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove unused isNested parameter.

The isNested parameter is declared here but never actually used within this version of processValue. You can safely remove it to simplify the function signature.

🧹 Proposed refactor to remove the unused parameter

Update the function signature:

-  function processValue(
-    attrName: string,
-    attrValue: unknown,
-    isNested: boolean,
-    prefix = '',
-  ): Record<string, unknown> {
+  function processValue(
+    attrName: string,
+    attrValue: unknown,
+    prefix = '',
+  ): Record<string, unknown> {

Update the recursive call site (around line 424):

-          const processed = processValue(
-            key,
-            val,
-            true,
-            prefix ? `${prefix}.${attrName}` : attrName,
-          )
+          const processed = processValue(
+            key,
+            val,
+            prefix ? `${prefix}.${attrName}` : attrName,
+          )

Update the initial call site (around line 441):

-      const processed = processValue(attrName, attrValue, false)
+      const processed = processValue(attrName, attrValue)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/dynamodb/helpers.ts` around lines 346 - 351, Remove the
unused isNested parameter from processValue, then update its recursive and
initial call sites to match the simplified signature while preserving all
existing value-processing behavior.

439-445: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Avoid O(N²) allocations in reduce accumulators.

Creating a new object on each iteration with Object.assign({}, acc, ...) inside a reduce loop causes unnecessary garbage collection overhead on hot paths, such as when parsing wide database responses. Since the accumulator is safely initialized as a new empty object {} for these loops, you can safely mutate it directly instead.

  • packages/stack/src/dynamodb/helpers.ts#L439-L445: mutate the top-level accumulator directly via Object.assign(formattedItem, processed).
  • packages/stack/src/dynamodb/helpers.ts#L418-L431: mutate the nested accumulator directly via Object.assign(acc, processed).
  • packages/stack/src/dynamodb/helpers.ts#L261-L269: mutate the nested accumulator directly via Object.assign(acc, processed).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/dynamodb/helpers.ts` around lines 439 - 445, Update the
reduce accumulators in packages/stack/src/dynamodb/helpers.ts at lines 439-445,
418-431, and 261-269 to mutate the safely initialized accumulator with
Object.assign(accumulator, processed) instead of creating a new object from the
previous accumulator on each iteration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/stack/src/dynamodb/helpers.ts`:
- Around line 346-351: Remove the unused isNested parameter from processValue,
then update its recursive and initial call sites to match the simplified
signature while preserving all existing value-processing behavior.
- Around line 439-445: Update the reduce accumulators in
packages/stack/src/dynamodb/helpers.ts at lines 439-445, 418-431, and 261-269 to
mutate the safely initialized accumulator with Object.assign(accumulator,
processed) instead of creating a new object from the previous accumulator on
each iteration.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1c89fa0a-2ad9-4861-ad67-4ad693318043

📥 Commits

Reviewing files that changed from the base of the PR and between b8e9fa5 and e35e5bf.

📒 Files selected for processing (6)
  • packages/stack/__tests__/dynamodb/client-compat.test-d.ts
  • packages/stack/__tests__/dynamodb/encrypted-dynamodb.test.ts
  • packages/stack/__tests__/dynamodb/helpers-v3.test.ts
  • packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts
  • packages/stack/src/dynamodb/helpers.ts
  • packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/stack/tests/dynamodb/client-compat.test-d.ts
  • packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts
  • packages/stack/tests/dynamodb/encrypted-dynamodb.test.ts
  • packages/stack/tests/dynamodb/helpers-v3.test.ts

@tobyhede
tobyhede requested a review from freshtonic July 21, 2026 00:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
skills/stash-dynamodb/SKILL.md (1)

79-85: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't describe profile.ssn__hmac as a key-condition attribute. DynamoDB key schema attributes must be top-level scalars, so a nested path like profile.ssn__hmac can't back a table or index key. If this is meant to illustrate queryability, move the HMAC to a top-level attribute or note that nested fields can be stored and filtered but not used as key schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/stash-dynamodb/SKILL.md` around lines 79 - 85, Update the DynamoDB
layout example and accompanying compatibility note to remove the claim that
nested profile.ssn__hmac supports key conditions. Describe it only as a stored
nested field usable for filtering, or move the HMAC to a top-level scalar
attribute if key-schema queryability is required.

Source: MCP tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@skills/stash-dynamodb/SKILL.md`:
- Around line 79-85: Update the DynamoDB layout example and accompanying
compatibility note to remove the claim that nested profile.ssn__hmac supports
key conditions. Describe it only as a stored nested field usable for filtering,
or move the HMAC to a top-level scalar attribute if key-schema queryability is
required.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef911601-989b-44fd-a552-ff4ffa8900fc

📥 Commits

Reviewing files that changed from the base of the PR and between e35e5bf and be488bb.

📒 Files selected for processing (2)
  • packages/stack/__tests__/dynamodb/hmac-domains.test.ts
  • skills/stash-dynamodb/SKILL.md

@coderdan
coderdan self-requested a review July 21, 2026 03:56

@coderdan coderdan 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.

Test coverage review

This is an unusually well-tested diff. Eight new test files (~2,580 lines) cover the pure attribute mapping in both directions, seven cross-cutting property-based invariants, live E2E against both client shapes, a per-domain __hmac ground truth that guards the shipped skill doc, and a *.test-d.ts contract that runs under tsc. Several of the tests explicitly pin bugs found in review. I have no complaints about the overall shape.

The gaps that remain are narrow, and almost all of them are the same pattern: a fix or invariant is covered on one path but not on its twin. The two I'd prioritise:

  • deepClone's catch fallback (helpers.ts:162) is the one genuinely untested branch in the new code, and it silently degrades the "never mutate a caller's object" guarantee the function exists to provide.
  • The resolveEncryptColumnMap fix is applied identically in encrypt-model.ts and bulk-encrypt-models.ts, but only the single-item path is covered by the renamed-table regression suite. Reverting line 62 of the bulk file passes CI today.

I verified the two type-level sketches (#3, #7) and the deepClone sketch (#1) actually compile/pass against this branch, so they lock current behaviour rather than asserting a wish.

Additional coverage gaps not posted inline

  • Write/read asymmetry for an undeclared nested payload. toEncryptedDynamoItem splits any nested value that passes isStoredEqlPayload, regardless of whether its name resolves to a column; toItemWithEqlPayloads now refuses to rebuild an unmatched nested *__source (correctly — that fix is well covered). The composition means a payload stored under an undeclared nested name is written split and then never rebuilt, i.e. becomes undecryptable. Reachable only if a caller hands the adapter a pre-encrypted payload, so it may be acceptable — but it deserves either a test pinning it as intended or a matchColumn gate on the write path too.
  • isStoredEqlPayload vs. the write path's truthiness gate. { v, i, c: '' } passes the detector but fails if (encryptPayload?.c), so it falls through to the nested-object branch and gets written out as a raw map — leaking v/i into a stored attribute, which invariant 6 in properties.test.ts otherwise forbids. properties.test.ts:74 documents empty ciphertexts as out of scope; a one-line example test would make that a decision rather than an omission.
  • v2 nested equality field's __hmac. helpers.test.ts:114 covers a nested payload with no hm; the nested-hm-drop path through matchColumn's v2 bare-leaf fallback is only exercised for v3 dotted paths (live) and never for v2.
  • The documented EncryptedAttributes dotted-path limitation (types.ts:176-179) — that a 'profile.ssn' column passes through as profile untyped — has no type test pinning it, so a future change could silently "fix" or worsen it.

Out of scope

No crypto or security issues noticed. One adjacent correctness observation, raised inline as a test gap rather than a finding: for a required nullable column (email: string | null), EncryptedAttributes types email__source as a required string, while at runtime a null value produces no __source attribute at all. Optional columns (email?: string | null) are fine — the homomorphic mapped type preserves the modifier. See comment #8.

Comment thread packages/stack/src/dynamodb/helpers.ts
Comment thread packages/stack/src/dynamodb/operations/bulk-encrypt-models.ts
Comment thread packages/stack/src/dynamodb/types.ts
Comment thread packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts Outdated
Comment thread packages/stack/src/dynamodb/operations/bulk-decrypt-models.ts
Comment thread packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts
Comment thread packages/stack/src/dynamodb/types.ts
Comment thread packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts Outdated

@coderdan coderdan 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.

Review — EQL v3 support in encryptedDynamoDB

Deep pass over the adapter. The core design holds up: buildColumnKeyMap as the version marker, matching on property names while identifying by DB column name (the emailAddress: types.TextEq('email_address') case), scoping the bare-leaf fallback to nested v2 only, and requiring v+i before treating a value as a payload rather than sniffing for c — that last one in particular kills a real data-destroying bug, since c is an ordinary attribute name. 155/155 tests pass locally, and the property tests are doing genuine work.

I exercised the mapping functions directly rather than reading only. Confirmed working: v3 untagged scalars, k: 'sv' retention for JSON, nested dotted paths, and the __hmac pass-through for unrelated attributes.

Eight findings, seven inline. The one below sits on an unchanged line so GitHub won't anchor it.

I also checked skills/stash-dynamodb/SKILL.md against the two behaviours most likely to be under-documented — decrypt-side audit metadata with an EncryptionV3 client, and ordering/free-text terms not being stored. Both are already covered (lines 153-156 and 43, cross-referenced at 478). No finding there; noting it so the check is visible.


Arrays are never recursed on the write path — helpers.ts:260

toEncryptedDynamoItem guards the recursion with typeof attrValue === 'object' && !Array.isArray(attrValue), so a payload inside an array falls through to the pass-through branch. Verified:

toEncryptedDynamoItem({ items: [<v3 payload>] }, ['items'])
// => { items: [ { v: 3, i: {...}, c: 'CT' } ] }

The full envelope goes into DynamoDB unsplit. It does round-trip — the read path skips arrays too, so the payload reaches the FFI intact — but it stores the item in a shape the adapter's own storage model says it does not use, and EncryptedAttributes doesn't describe it. Someone writing a key condition against items__hmac, or reasoning about at-rest layout from the skill's __source/__hmac table, is misled.

Either recurse into arrays for symmetry, or state the carve-out alongside the other limitations. Worth a decision either way rather than leaving it implicit in a !Array.isArray.

Comment thread packages/stack/src/dynamodb/helpers.ts Outdated
Comment thread packages/stack/src/dynamodb/helpers.ts
Comment thread packages/stack/src/dynamodb/types.ts
Comment thread packages/stack/src/dynamodb/helpers.ts
Comment thread packages/stack/src/dynamodb/helpers.ts
Comment thread packages/stack/src/dynamodb/helpers.ts
Comment thread packages/stack/src/dynamodb/index.ts Outdated
tobyhede added a commit that referenced this pull request Jul 21, 2026
Implements the review fixes from PR #725 (coderdan / coderabbit), each with a
failing test written first (behavior changes) or mutation-proven coverage
(already-correct paths).

Behavior:
- deepClone: non-cloneable input now falls back to a fresh per-key clone, so the
  "encryption never mutates a caller's object" guarantee holds even when
  structuredClone throws (function/symbol-valued properties).
- Read path logs a debug diagnostic when a `*__source` attribute names no
  declared column, instead of silently returning raw ciphertext.
- resolveDecryptResult surfaces a malformed client result as a failure rather
  than a silent `undefined` success.
- encryptedDynamoDB throws a clear, table-named error when a v3 table is used
  with a client that never registered it (was an opaque FFI failure later).

Types/refactor:
- Per-method casts on the operations object so drift from the overloaded public
  type is caught by tsc (was one broad `as`).
- Single `hasBuildColumnKeyMap` predicate in the leaf `types.ts`; the four
  hand-written v3-marker checks now route through it (wasm-inline stays
  native-free).
- Remove dead `isNested` param; convert O(N^2) reduce accumulators to in-place.

Tests: v3 bulk-renamed regression, v3 bulk error paths, empty-batch + null
column, read-context-once invariant, audit-metadata log, deepClone fallback,
type-level HasSearchTerm/DecryptedAttributes coverage, and a suite-level
credential guard (describe.skipIf) so the live v3 suite skips without creds.

Refs #725

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/stack/__tests__/dynamodb/construct-guard.test.ts (1)

45-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove redundant biome-ignore directives and correct the rationale.

The biome-ignore lint/suspicious/noExplicitAny directives are unnecessary because the Biome configuration in this repository excludes __tests__ directories (as per learnings). Additionally, the comment claiming .test.ts is not typechecked is inaccurate, as TypeScript does typecheck test files—which is exactly why the as any cast is needed to bypass the compiler here.

  • packages/stack/__tests__/dynamodb/construct-guard.test.ts#L45-L46: Remove the redundant biome-ignore comment and keep the as any cast for the stubClient.
  • packages/stack/__tests__/dynamodb/construct-guard.test.ts#L119-L120: Remove the redundant biome-ignore comment for the inline test stub.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/__tests__/dynamodb/construct-guard.test.ts` around lines 45 -
46, Remove the redundant biome-ignore directive at
packages/stack/__tests__/dynamodb/construct-guard.test.ts lines 45-46 while
retaining the stubClient as any cast, and remove the corresponding directive at
lines 119-120 for the inline test stub. Do not retain or replace the inaccurate
rationale comment.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/stack/__tests__/dynamodb/construct-guard.test.ts`:
- Around line 45-46: Remove the redundant biome-ignore directive at
packages/stack/__tests__/dynamodb/construct-guard.test.ts lines 45-46 while
retaining the stubClient as any cast, and remove the corresponding directive at
lines 119-120 for the inline test stub. Do not retain or replace the inaccurate
rationale comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ff535fbe-d6ae-4f5d-862c-d2a979ece004

📥 Commits

Reviewing files that changed from the base of the PR and between 57fb469 and abd19dc.

📒 Files selected for processing (12)
  • .changeset/dynamodb-eql-v3.md
  • packages/stack/__tests__/dynamodb/client-compat.test-d.ts
  • packages/stack/__tests__/dynamodb/construct-guard.test.ts
  • packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts
  • packages/stack/__tests__/dynamodb/helpers-v3.test.ts
  • packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts
  • packages/stack/src/dynamodb/helpers.ts
  • packages/stack/src/dynamodb/index.ts
  • packages/stack/src/encryption/index.ts
  • packages/stack/src/types.ts
  • packages/stack/src/wasm-inline.ts
  • skills/stash-dynamodb/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/stack/tests/dynamodb/resolve-decrypt.test.ts
  • .changeset/dynamodb-eql-v3.md
  • packages/stack/tests/dynamodb/client-compat.test-d.ts
  • packages/stack/tests/dynamodb/helpers-v3.test.ts
  • skills/stash-dynamodb/SKILL.md
  • packages/stack/tests/dynamodb/encrypted-dynamodb-v3.test.ts
  • packages/stack/src/dynamodb/helpers.ts

tobyhede added 14 commits July 21, 2026 21:22
The shipping DynamoDB adapter (packages/stack/src/dynamodb, exported as
@cipherstash/stack/dynamodb) had zero test coverage. All 861 lines of
DynamoDB tests in this repo exercise `protectDynamoDB` in the forked
packages/protect-dynamodb, not this one.

Pin the current EQL v2 behaviour before the v3 port (#657):

- helpers.test.ts (21 tests, pure/offline) — the attribute-mapping
  contract: the `__source`/`__hmac` split, ste_vec vs scalar envelope
  reconstruction, nested-field recursion, null/undefined and array
  handling, deepClone, and error-code fallback.
- encrypted-dynamodb.test.ts (22 tests, live) — end-to-end round trips
  through a real client, bulk operations, audit chaining, FFI error-code
  propagation, and the __hmac key-condition path: that `encryptQuery`
  mints exactly the HMAC the item was stored under.

No production code changes. This is the baseline the v3 port is
verified against.
Closes #657.

`encryptedDynamoDB` now accepts tables built with `encryptedTable` + the
`types.*` domains from `@cipherstash/stack/v3`, alongside the existing EQL
v2 tables. Additive, not a breaking swap: DynamoDB shares none of the v2
Postgres machinery — no EQL extension to install, no migration to run — so
there is no reason to force existing callers off v2.

The real coupling was on the write path, not where the issue placed it:

- `toEncryptedDynamoItem` detected an encrypted value by its `k: 'ct'` tag.
  EQL v3 scalars carry NO `k` discriminator, so every v3 scalar fell through
  to the nested-object branch and was written out as a raw map rather than
  being split into `<attr>__source` / `<attr>__hmac`. Now keyed off the
  presence of a ciphertext, with `k: 'sv'` still routing JSON documents.
- `toItemWithEqlPayloads` hardcoded `v = 2` and `k: 'ct'`. It now synthesizes
  the envelope matching the table's version — v3 scalars untagged, JSON
  documents keeping the mandatory `k: 'sv'`.
- Signatures across the four public methods and four operation classes widen
  to `AnyEncryptedTable`.

Not a coupling, contrary to the issue: `cast_as` / `indexes.ste_vec`. A v3
column's `build()` emits the same `{ cast_as, indexes }` shape as a v2 one,
so that detection needed no version branch.

Client shapes: both are accepted. `EncryptionV3` returns a typed client whose
`decryptModel` is a plain Promise taking the table as a second argument; the
nominal client returns a chainable operation. The decrypt paths resolve
either. Audit metadata on decrypt requires the nominal client — the typed one
has no audit surface there, which is documented rather than silently ignored.

Capability notes, documented in the skill:
- Only equality is usable on DynamoDB. `__hmac` is written for domains that
  mint an `hm` term (the `*Eq` family plus TextOrd/TextOrdOre/TextSearch).
  Ordering and bloom-filter terms have no key-condition surface and are not
  stored, so those columns stay decryptable but not queryable.
- EQL v3 has no `encryptedField` equivalent, so nested object encryption
  still requires a v2 table.

Adds 31 tests for the v3 paths (74 total for the adapter, from zero before
this branch). The 43 v2 characterisation tests pass unchanged, which is what
establishes this as backward compatible.

Also corrects two stale claims in AGENTS.md: the package attribution for
`encryptedDynamoDB`, and the note that DynamoDB requires EQL v2.
The write-path rewrite dropped the `k === 'ct'` check that was narrowing
`EncryptedValue` to `EncryptedScalar`, so reading `c`/`hm` off the union no
longer compiled — `EncryptedValue` is v2-only (`EncryptedScalar |
EncryptedSteVec`) and has no member describing an untagged v3 scalar.

Introduce a local `StoredEqlPayload` stating the four keys the mapping
actually touches. Caught by running tsc directly: `npx tsc` resolves to a
different package in this workspace and prints a banner instead of
compiling, so it silently reported no errors.
The previous wording — "nested object encryption requires v2" — was too
absolute. v3 encrypts a nested object wholesale via `types.Json`; what it
cannot do is v2's per-leaf `encryptedField`, which encrypts selected leaves
in place and leaves siblings plaintext. Show both shapes and when to pick
each.
The previous wording implied a closed design decision. The code and the
original scoping spec both frame it as deferral — `columns.ts`: "Nested
fields are deferred to later increments"; the v3 schema spec lists nested
`encryptedField` under increment-1 non-goals; the table type helpers carry
a comment marking the flat shape as a future extension point.

No ADR, issue, or commit anywhere records a rationale for dropping it, so
do not imply one.
Verified empirically: nested encrypt AND decrypt round-trip correctly under
EQL v3 today with no code change, provided the column path is dotted.

    encryptedTable('t', { 'profile.ssn': types.TextEq('profile.ssn') })
    encryptModel({ profile: { ssn: 'secret' } })  ->  encrypts
    decryptModel(...)                             ->  { profile: { ssn: 'secret' } }

So it is not deferred work and not a design decision. The nested model walk
in encryption/helpers/model-helpers.ts is generic and already shared. v3
fails only because resolveEncryptColumnMap takes the buildColumnKeyMap()
branch, which returns flat JS property names, while the walk builds dotted
model paths. v2 takes the other branch and gets dotted DB column names.

What is genuinely missing is the authoring form: EncryptedV3TableColumn does
not admit nested groups, and build()/buildColumnKeyMap() do not flatten them.
DynamoDB items are natively nested, so v3 support for this adapter is not
complete without them. It turns out nothing needed building: the nested
model walk in encryption/helpers/model-helpers.ts is shared with v2 and
matches on dotted paths, so a v3 column declared with a dotted path works
end to end today.

    encryptedTable('users', { 'profile.ssn': types.TextEq('profile.ssn') })
    encryptModel({ pk, profile: { ssn, city } })
      -> { pk, profile: { ssn__source, ssn__hmac, city } }

Nested equality attributes keep their __hmac, so a key condition on
profile.ssn__hmac works, and plaintext siblings are untouched.

Also fixes the nested identifier. The read path rebuilt `i.c` from the leaf
attribute name (`ssn`) rather than the registered column (`profile.ssn`).
That only ever worked because the FFI treats `i` as a detection key and
never validates it against the ciphertext — a latent trap that would bite if
that were ever tightened. It now prefers the registered dotted path and falls
back to the leaf, so both v2 authoring conventions —
`encryptedField('example.protected')` and `encryptedField('amount')` under a
group — rebuild an identifier matching how the column was declared.

The one characterisation test pinning the old leaf-name behaviour is updated
with the reasoning; every live round-trip test passed unchanged, which is
what shows the identifier change is safe.

What still has no v3 equivalent is only the nested-group *authoring* form.
Adding it means widening EncryptedV3TableColumn, which breaks six flat
iterations of columnBuilders in prisma-next and stack-supabase — ergonomics,
not capability.
Three-way review of the branch (architecture, types, tests) found four
defects, two of them silent data loss. All are now fixed with regression
tests; none were caught by the 80 existing tests because every test table
used property === dbName and every test item was well-formed.

1. CRITICAL — a v3 column whose JS property differs from its DB name was
   silently corrupted. `encryptedAttrs` came from `build().columns`, which for
   v3 is keyed by DB name, while the encrypted model is keyed by property
   name. For `emailAddress: types.TextEq('email_address')` the two never
   matched, so the attribute was never split:

     stored: { emailAddress: { v:3, i__source:'email_address', c:'…', hm:'…' } }

   No `__source`, no `__hmac`, and the payload's `i` block rewritten to
   `i__source` — a violation of the payload-shape contract in AGENTS.md.
   Equality querying, the adapter's entire purpose, silently did nothing, and
   round-trip decrypt still passed so nothing surfaced it. Both encrypt paths
   and the read path now use `resolveEncryptColumnMap` from
   `encryption/helpers/model-helpers` — the core resolver that exists for
   exactly this — matching on property names and identifying by DB name.

2. CRITICAL, introduced by this branch — widening the payload predicate from
   `k === 'ct'` to "has c" meant any nested plaintext object with a `c` key
   was treated as an envelope:

     { wrapper: { inner: { c: 'AU', d: 1 } } }  ->  { wrapper: { inner__source: 'AU' } }

   `d` silently discarded on write. `c` is an ordinary attribute name
   (country, currency, count). Now gated on `isStoredEqlPayload`, which
   requires `v` and `i` — the same keys the FFI itself uses to detect an
   encrypted value.

3. `deepClone` flattened every non-plain object to `{}`, destroying `Date`
   before it reached the FFI. That made the entire `types.Timestamp*` /
   `types.Date*` domain family unusable through this adapter, and circular
   references threw a stack overflow rather than a handled error. Replaced
   with `structuredClone`.

4. The read path dropped ANY attribute ending in `__hmac` and treated ANY
   nested `*__source` as an envelope, both unconditionally. A customer
   attribute named `signature__hmac` vanished on read; an unregistered nested
   `*__source` was handed to the FFI as a decrypt target. Both are now scoped
   to columns the schema actually declares.

Also adopts the canonical v3 marker (`buildColumnKeyMap`, per
`resolveEqlVersion`) instead of duck-typing `getQueryCapabilities`, and makes
`isV3Table` a type predicate. `build()` and the column map are hoisted out of
the per-attribute recursion.

89 adapter tests (up from 80), 991 passing overall.
Closes the type-surface and test-level gaps a three-way review flagged after
the runtime fixes landed.

Types (src/dynamodb/types.ts, index.ts) — zero runtime change:

- encryptModel / bulkEncryptModels / decryptModel / bulkDecryptModels are now
  overloaded per wire version. The v3 arm derives the model from the table:
  input via `V3ModelInput<Table, T>` (schema fields pinned to their domain
  plaintext, pk/sk/GSI keys passing through), output via a new
  `EncryptedAttributes<Table, T>` mapped type that expresses the storage split
  — `<K>__source` plus an OPTIONAL `<K>__hmac`, present only for domains that
  mint one (`HasSearchTerm` mirrors `indexesForCapabilities`: the *Eq family
  plus TextOrd/TextOrdOre/TextSearch). `DecryptedAttributes` is the inverse.
  The v2 arm is byte-identical to before, so existing callers are untouched.
  This replaces `encryptModel<T>(item, table): EncryptModelOperation<T>`, which
  both left `T` unrelated to the table (`{ emial: 42 }` compiled) and lied
  about the return (`result.data.email` type-checked but was undefined at
  runtime; `result.data.email__source` did not type-check).

- `DynamoDBEncryptionClient.decryptModel`/`bulkDecryptModels` take `table:
  never` (required) not `table?: never`. The optional form collapsed to
  `undefined`, which the required-table `TypedEncryptionClient` did not satisfy
  — so the type's own claim that both clients fit without a cast was false.

- Export `AnyEncryptedTable`, `DynamoDBEncryptionClient`, `EncryptedAttributes`,
  `DecryptedAttributes`, `AuditConfig` — all appeared in public signatures but
  were un-nameable by consumers.

Suffix literals for the mapped types are sourced from the helpers.ts constants
via a type-only import, so the type and the runtime cannot drift.

Tests:

- All 26 type errors in the dynamodb test files are gone (they were the old
  return type being unsound). No model type was widened with an index
  signature to hide a defect: the v2 test names the real wire shape as
  `StoredUser`, and one genuine defect surfaced and was tightened — a
  `Record<string, unknown>` model against a `types.Json` column, now
  `Record<string, JsonValue>`.
- client-compat.test-d.ts (16 assertions) locks the claim that had none: both
  client shapes assign with no cast, all four methods take a v3 and a v2 table,
  .audit() chains, awaiting yields a discriminated Result, a bare object is
  rejected. This is the M1-class gap vitest.config.ts warns about.
- properties.test.ts (14 pure fast-check properties, ~200ms) pins each bug the
  review found: passthrough with hostile keys (c/hm/v/i/__hmac) at 1000 runs,
  property-vs-DB-name, deepClone/Date, version-follows-table, no metadata leak.
  Verified non-vacuous by mutation: reintroducing the four bugs fails exactly
  6 properties. A capped (numRuns: 5) live round-trip property covers the
  mapping composed with real encryption.

test:types 80 (was 64); dynamodb suite 104 (was 89); 1006 passing overall.

Two documented type limitations, both runtime-correct: the v2 overload still
returns the input model (v2 columns carry no capability in the type), and a
nested dotted-path v3 column (`'profile.ssn'`) splits correctly at runtime but
the model type is silent about it.
A four-dimension review (architecture / types / tests / v2-v3 parity) of the
completed branch. The parity audit found no accidental divergences — every
v2/v3 difference is intentional and traces to the wire format or the FFI. The
test review found one confirmed HIGH bug; the rest are hardening.

HIGH — leaf-fallback over-match (read path). `matchColumn` fell back from the
dotted path to the bare leaf, which is needed for v2 `encryptedField('amount')`
groups but wrongly fired for v3: a nested `note__source` matched a same-named
TOP-LEVEL `note` column and rewrote a plaintext sibling as an envelope, handing
it to the FFI as a decrypt target. v3 always registers full dotted paths, so it
never needs the fallback — scoped it to nested v2 attributes only. Regression
test plus dotted-path and 3-level-nesting tests added.

MEDIUM — decrypt path rebuilt the table config + column map once PER ITEM
(`toItemWithEqlPayloads` called `build()` and `resolveEncryptColumnMap`
internally), asymmetric with the encrypt path which resolves once. Extracted
`buildReadContext`; bulk-decrypt now builds it once and passes it, O(N)→O(1).

Audit-on-decrypt is a silent no-op with the typed EncryptionV3 client (it has
no decrypt audit surface). Both the parity and architecture reviews flagged the
silence; `resolveDecryptResult` now logs a debug line when metadata is dropped,
so it is observable. Behaviour unchanged.

Tests:
- resolve-decrypt.test.ts — pure coverage for `resolveDecryptResult` (both
  client shapes) and `throwPreservingCode`, previously only exercised live.
- client-compat.test-d.ts — bulk return-type storage split now type-locked
  (was callability-only), plus a second wrong-plaintext-type rejection.
- all-or-nothing bulk decrypt semantics pinned (one bad item fails the batch).

Not changed, with reasons: the passthrough property is not widened to the
collision case — a top-level `<col>__source` is legitimately the storage form
of that column, so it cannot be folded into a passthrough oracle; the direct
regression test is the correct pin. The v2 overload return type and the nested
dotted-path type gap remain documented limitations (runtime-correct). A
DynamoDB-Local / marshalling integration test remains a follow-up (needs an AWS
SDK dev dependency decision).

1015 passing overall; dynamodb suite 68 pure + type + live.
…omains

Three documentation findings, all in skills/stash-dynamodb/SKILL.md (which
ships to customers and has no automated guard). Verified each against source.

F3 (test-driven) — the `__hmac` "only for equality-capable columns" wording
(line 39) was imprecise and contradicted the file's own precise domain table:
several `*Ord` domains ARE equality-capable yet write no `__hmac`, because the
adapter writes it only when the payload carries an `hm` term (gate at
helpers.ts:251), which the FFI emits only for the `unique` index. Added
__tests__/dynamodb/hmac-domains.test.ts pinning, for every one of the 40 v3
domains, whether it builds the `unique` index that produces `__hmac` — the
ground truth the doc rests on. The definitive set is 12 domains (9 `*Eq` +
TextOrd + TextOrdOre + TextSearch); running it corrected an off-by-one in the
review's stated count (13). Reworded line 39 to match the test and the table.

F1 — three encryptQuery snippets (lines 492, 499, 548) read `result.data?.hm`
without checking the failure branch; `?.` masks an encryption failure (and a
null-plaintext result) as `undefined`, producing a malformed key condition
instead of a clear error. All three now check `if (result.failure) throw`
first, matching the correct example already in the file at line 318.

F2 — sharpened the v2/v3 "no data migration" wording so it can't be misread as
"none needed": no infrastructure migration, but no automatic data migration
either — items written under one version cannot be decrypted by the other.

No changeset needed: the branch's existing dynamodb-eql-v3.md already carries a
`stash` patch, which covers the skill; the new test is test-only.
CodeRabbit flagged 'Both produce the same DynamoDB item' as misleading: v2 and
v3 produce the same attribute LAYOUT (identical __source/__hmac keys), but the
encrypted contents are version-specific and not cross-decryptable — which the
compatibility note earlier in the file already states. Reworded to say layout,
not item, and cross-link the incompatibility.
Two doc-accuracy findings from CodeRabbit; the other two verified as
design-preferences and skipped (see below).

- The live property test's docstring said "capped hard at 15 runs" while the
  code was lowered to `numRuns: 5` last round. Corrected the prose.
- The changeset hardcoded "74 tests"; the adapter now has 116 runtime cases.
  Dropped the number, kept the "first test coverage across v2 and v3" statement.

Skipped, with reasons:
- Suite-level credential skipIf guard: every sibling live suite under
  packages/stack/__tests__/ runs beforeAll unconditionally and throws without
  CS_* creds — that is the repo convention (the throw-not-skip comment in
  vitest.config.ts). A guard here would diverge, not conform. No requireIntegrationEnv
  helper is used as a gate by any live suite.
- Emit DB column name instead of property name as the stored attribute: not a
  bug. The DynamoDB attribute name is a free internal choice (no external DDL,
  unlike Postgres). The current property-name behaviour round-trips and its
  __hmac matches the query term (verified live, locked by the "property differs
  from its DB name" tests). CodeRabbit's write-only change would break the round
  trip — the read path matches by property name via resolveEncryptColumnMap, so
  a DB-name attribute would miss `matchColumn` and pass through undecrypted.
Implements the review fixes from PR #725 (coderdan / coderabbit), each with a
failing test written first (behavior changes) or mutation-proven coverage
(already-correct paths).

Behavior:
- deepClone: non-cloneable input now falls back to a fresh per-key clone, so the
  "encryption never mutates a caller's object" guarantee holds even when
  structuredClone throws (function/symbol-valued properties).
- Read path logs a debug diagnostic when a `*__source` attribute names no
  declared column, instead of silently returning raw ciphertext.
- resolveDecryptResult surfaces a malformed client result as a failure rather
  than a silent `undefined` success.
- encryptedDynamoDB throws a clear, table-named error when a v3 table is used
  with a client that never registered it (was an opaque FFI failure later).

Types/refactor:
- Per-method casts on the operations object so drift from the overloaded public
  type is caught by tsc (was one broad `as`).
- Single `hasBuildColumnKeyMap` predicate in the leaf `types.ts`; the four
  hand-written v3-marker checks now route through it (wasm-inline stays
  native-free).
- Remove dead `isNested` param; convert O(N^2) reduce accumulators to in-place.

Tests: v3 bulk-renamed regression, v3 bulk error paths, empty-batch + null
column, read-context-once invariant, audit-metadata log, deepClone fallback,
type-level HasSearchTerm/DecryptedAttributes coverage, and a suite-level
credential guard (describe.skipIf) so the live v3 suite skips without creds.

Refs #725
@tobyhede
tobyhede force-pushed the feat/dynamodb-eql-v3 branch from abd19dc to f9c48c1 Compare July 21, 2026 11:46
After rebasing onto main (protect-ffi 0.29 → 0.30), two 0.30 changes surfaced:

- SteVec (searchable JSON) documents now carry a required per-document
  KeyHeader `h` (`{v, k, i, h, sv}`), and 0.30 decrypt is strict — a document
  missing `h` fails with "missing field `h`". The adapter split a JSON payload
  down to its `sv` entries only, discarding `h`, so v3 JSON round-trips broke.
  It now stores `{ h, sv }` under `__source` (the non-reconstructable parts)
  and rebuilds `{v, i, k: 'sv', h, sv}` on read — `v`/`i`/`k` stay reconstructed
  so no envelope metadata leaks into storage (invariant 6 holds).
- protect-ffi 0.30 dropped EQL v2 `searchableJson` (SteVec) entirely;
  `resolveEqlVersion` now throws for a v2 ste_vec schema. Removed the v2
  `searchableJson` column and its cases from the v2 characterisation suites
  (encrypted-dynamodb.test.ts, helpers.test.ts) — the capability no longer
  exists. v3 JSON (`types.Json`) is the supported path and is covered by the
  helpers-v3 / properties SteVec tests, updated to pin the `h` KeyHeader.

Pure mapping tests (helpers, helpers-v3, properties) and test:types pass
locally; the live v3/v2 suites run under 0.30 in CI (the 0.30 CTS auth
endpoint is unreachable from the local sandbox, so live paths are CI-verified).
@tobyhede
tobyhede force-pushed the feat/dynamodb-eql-v3 branch from f9c48c1 to 75b1558 Compare July 21, 2026 11:51
Findings raised in review bodies (never anchored, so untracked on GitHub),
now fixed and covered:

- Write path is column-aware: split only declared columns, matched on the
  same property path the read path rebuilds from (shared makeColumnMatcher).
  An undeclared nested payload is stored whole and round-trips instead of
  being split into a <attr>__source the read path could never reassemble.
- Empty-string ciphertext: the scalar arm gates on presence ('c' in payload),
  so { v, i, c: '' } splits to <attr>__source: '' instead of falling through
  and leaking v/i as a raw map.
- v2 nested __hmac drop via the bare-leaf fallback now has a pure test.
- EncryptedAttributes dotted-path limitation pinned by a .test-d.ts assertion.
- Arrays documented as a deliberate carve-out (code comments both paths, the
  EncryptedAttributes docblock, and the DynamoDB skill limitations).

Coverage: real-payload array carve-out tests (write-whole, read-whole,
round-trip) for v2 and v3, declared-column-holds-array, array-of-records,
empty-ciphertext read round-trip, the v2 undeclared-nested twin, and two
fast-check properties (array-nested envelope never split; write path never
splits an undeclared payload). The shared ciphertext generator now includes
the empty string. Non-vacuity proven by mutation (recursing arrays fails 8).

88 pure runtime tests, 85 type tests, 0 type errors, lint + build clean.
@tobyhede

Copy link
Copy Markdown
Contributor Author

Addressed the five findings raised in the two review bodies (they were posted un-anchored, so there were no GitHub threads to resolve for them). All fixed in ebddb2c, with tests:

  • Write/read asymmetry (undeclared nested payload). The write path is now column-aware: it splits only declared columns, matched on the same property path the read path rebuilds from (a shared makeColumnMatcher now used by both directions). A pre-encrypted payload under an undeclared nested name is stored whole and round-trips, instead of being split into a <attr>__source the read path could never reassemble. v3 and v2 example tests plus a fast-check property across generated schemas.
  • { v, i, c: } truthiness gate. The scalar arm now gates on presence (c in payload), so an empty ciphertext splits to <attr>__source: instead of falling through and leaking v/i as a raw map. Read round-trip asserted, and the properties.test.ts ciphertext generator now includes the empty string (was minLength: 1), so invariants 1 and 6 cover it rather than a single example.
  • v2 nested __hmac via the bare-leaf fallback. Added the v2 pure test (the drop path was previously exercised only through v3 dotted paths in the live suite).
  • EncryptedAttributes dotted-path limitation. Pinned with a .test-d.ts assertion; the parent key passes through untyped and no split attribute is minted.
  • Arrays not recursed on the write path. Documented as a deliberate carve-out — code comments on both paths, the EncryptedAttributes docblock, and the DynamoDB skill limitations. Real-payload tests (write-whole, read-whole, round-trip) for v2 and v3, plus a fast-check property. Non-vacuity proven by mutation (recursing arrays fails 8 tests) — the prior "leaves arrays untouched" test used a { c } lookalike the detector skips anyway.

Verification: 88 pure runtime tests, 85 type tests, 0 type errors, lint + build clean. The hardening is folded into the existing dynamodb-eql-v3 changeset.

@tobyhede
tobyhede requested a review from coderdan July 22, 2026 00:53
Follow-up to the PR #725 review-thread audit. Closes the two review
requests that were resolved with a real gap versus what the reviewer
asked, and hardens two flaky spots found during verification.
Test-only; no production, public-API, or payload-shape change.

- Thread 11: add the required-nullable type assertion to
  client-compat.test-d.ts — a required `string | null` column keeps
  `__source` required, while an optional column preserves `?`. The
  runtime tests existed; the requested type-level pin did not.
  Non-vacuity verified (required->optional fails test:types).
- Thread 8: assert the 3-arg (prebuilt context) form of
  toItemWithEqlPayloads equals the 2-arg (default context) form — the
  equivalence the reviewer asked for alongside the build-once invariant.
- Spy isolation: add afterEach(vi.restoreAllMocks()) to helpers-v3 and
  resolve-decrypt, which both spy the shared logger.debug singleton, so
  a spy can never bleed past a test boundary.
- Live-suite flakiness: retry the live ZeroKMS v3 suites (retry: 2),
  scoped to those suites so pure/unit tests are never retried.
Comment thread packages/stack/__tests__/dynamodb/client-compat.test-d.ts Outdated
Comment thread packages/stack/__tests__/dynamodb/client-compat.test-d.ts Outdated
Comment thread packages/stack/__tests__/dynamodb/encrypted-dynamodb-v3.test.ts Outdated
Review feedback (PR #725): the type tests guarded the discriminated
Result with `if (result.failure) return`, which silently passes the
early-out branch instead of surfacing it. Match the repo's runtime
dynamodb-test convention — `throw new Error(result.failure.message)` —
so a failure is a hard failure, not a green no-op. Applied to all nine
occurrences in the file, not only the two flagged, so the pattern is
uniform. Type-narrowing is unchanged (throw terminates like return);
test:types stays green.

@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.

(Duplicate submission — a stale GitHub read-API made an earlier submit look like it failed, so it was retried. The full review is here. Disregard this copy.)

@coderdan coderdan 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.

Some of the tests will silently skip if creds are not set or if there is an error in a step (see comments). Should be addressed before merging.

@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.

(Duplicate submission — a stale GitHub read-API made an earlier submit look like it failed, so it was retried. The full review is here. Disregard this copy.)

@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.

Overview

Adds EQL v3 support to encryptedDynamoDB (@cipherstash/stack/dynamodb) — additive alongside v2, no forcing migration (DynamoDB has no EQL extension to install) — and gives the adapter its first-ever test coverage (it previously had zero, referenced only by its own source). The bulk of the +4500 lines is that suite. The PR also reports finding and fixing 4 data-integrity bugs (2 silent data loss) during a three-way review.

I read the write-path envelope detection myself and ran four focused verifications (write path, read path, types+core, test quality). No confirmed bugs surfaced. LGTM / approvable — details below.

What I verified

Write path — all 4 bugs genuinely fixed.

  • The envelope detector is matchColumn(attrName, prefix) && isStoredEqlPayload(value) where isStoredEqlPayload = v ∧ i ∧ (c ∨ sv). It correctly matches v3 scalars {v,i,c} (which the old k:'ct' guard dropped to the raw-map branch — the real bug), v3 SteVec {v,i,k:'sv',sv}, and v2 — and the matchColumn gate means a plaintext {c:'AU',…} object is never split (bug #2). The property!=DB-name split (Critical #1) now routes through the core resolveEncryptColumnMap, so emailAddress: types.TextEq('email_address') emits emailAddress__source/__hmac with i.c = the DB name. deepClonestructuredClone (bug #3, Date no longer flattened to {}).

Read path — correct and symmetric. Round-trips all four cases (property==dbName, property!=dbName, nested profile.ssn, plaintext siblings); bug #4's __hmac/__source handling is now scoped to declared columns via the same shared makeColumnMatcher, so a user's own plaintext foo__source/bar__hmac attributes survive untouched (directly tested). Write/read symmetry through that single shared matcher is the load-bearing invariant. Missing/null and bulk index-alignment all hold.

Types + core — sound. The per-version overloads list v3 first, keyed on the v3-only non-optional buildColumnKeyMap, so a v2 table can't match the v3 arm (no ordering ambiguity). __hmac presence in the EncryptedAttributes mapped type keys off equality capability (HasSearchTerm mirrors indexesForCapabilities) exactly as the runtime emits it. client-compat.test-d.ts genuinely asserts both client shapes (EncryptionV3 typed + nominal Encryption({config:{eqlVersion:3}})) are callable without a cast — no as any. The remaining as casts are documented type-erasure at the overload boundary, not covers for a model mismatch. The three core-file changes (types.ts, encryption/index.ts, wasm-inline.ts) are purely additive (a new hasBuildColumnKeyMap guard replacing an inline typeof check) — no v2 behaviour change, Result contract intact.

Tests — non-vacuous. properties.test.ts has 16 fast-check properties (all exact toEqual/value assertions, not "doesn't throw"), and each of the 4 bugs has a guarding assertion (round-trip preservation, plaintext-sibling passthrough at 1000 runs, Date instanceof+getTime, property≠dbName with distinctNames). The live e2e (encrypted-dynamodb-v3.test.ts) is real ZeroKMS, correctly describe.skipIf(!hasCreds) so no-creds CI stays green, and actually exercises the critical paths. Changeset is correct (@cipherstash/stack minor + stash patch, not breaking); AGENTS.md and the DynamoDB skill are accurate.

Notes — non-blocking

  1. The adapter's local isStoredEqlPayload is looser than the core isEncryptedPayload. The core additionally requires typeof v === 'number' and typeof i === 'object'; the local copy omits both. In practice it's safe — the matchColumn gate means it only ever runs against values at a declared encrypted column, where the value is the real envelope — but reusing the core predicate (or adding those two typeof guards) would close the residual window and stop two envelope-detection predicates from drifting apart over time. Cheap defense-in-depth for exactly the silent-sibling-loss class this PR hardened against.
  2. Type-model approximations (already tracked as #747): the v3 result type drops | null on __source, and doesn't reflect the dotted-path or array-nested splits. All three are disclosed in the EncryptedAttributes docblock and pinned by test-d cases, so they can't drift silently — runtime behaviour is correct, only the static type is optimistic.
  3. One live-coverage inference gap (already tracked as #748): the __hmac GSI key condition is verified by value-equality (encryptQuery(x).hm === stored.x__hmac), never by an actual DynamoDB Query/GSI round trip — no DynamoDB client is instantiated in the suite. Everything else runs through real ZeroKMS. A DynamoDB-Local integration test would close it.
  4. Cosmetic: the property-count is under-stated (16 actual vs "14" in the header/PR), and the top-of-file summary numbers invariants 1–7 while the file adds #8 (array carve-out) and #9 (column-aware gate).

Verdict

Correct, symmetric (write/read share one matcher), and unusually well-tested for a first-coverage port — the 4 reported bugs are genuinely fixed with non-vacuous regression properties, the type surface matches the runtime, and the core changes are additive with v2 untouched. All notes are non-blocking; item 1 (align the local envelope predicate with the core one) is the only one I'd suggest picking up, as cheap hardening. I'd approve this — say the word and I'll flip it to a formal approval.

tobyhede added 2 commits July 22, 2026 11:52
Review feedback (PR #725): the `describe.skipIf(!hasCipherStashCreds)`
gate turned absent credentials into a silent whole-suite skip on a green
job — proving nothing and hiding regressions. This was the lone outlier:
of the 23 live suites in packages/stack/__tests__, only this one gated;
the other 22 (incl. the v2 dynamodb suite) already fail without creds.
It also contradicted the test-kit contract, whose own header says
"Integration suites FAIL when they are not configured. They never skip."

Replace the skipIf gate with `requireIntegrationEnv(['cipherstash'])` in
beforeAll — the purpose-built helper, which throws naming exactly which
CS_* vars are missing (resolving from env vars OR a ~/.cipherstash
profile). Drop the hasCipherStashCreds constant and the misleading
docblock. The throttling `retry` is unchanged.

Verified: green with creds; hard red (exit 1, not a skip) without them.
Tighten the adapter's local envelope detector to mirror the core
isEncryptedPayload shape checks exactly (v must be a number, i must be
an object), so the two predicates cannot drift apart. Safe today via the
matchColumn gate, but this closes the residual window where a plaintext
{ v, i } lookalike at a declared column could be split. Raised as
non-blocking hardening in review.

Also reword the two construct-guard test biome-ignore rationales: the
noExplicitAny rule does fire on `as any` in __tests__ (only the GritQL
type-erasing-assertions plugin is scoped away from tests), so the
directives are required — the old ".test.ts is not typechecked"
rationale was inaccurate.
@tobyhede
tobyhede merged commit 6ce5381 into main Jul 22, 2026
15 checks passed
@tobyhede
tobyhede deleted the feat/dynamodb-eql-v3 branch July 22, 2026 02:49
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.

DynamoDB: support EQL v3 schemas in encryptedDynamoDB

3 participants