Skip to content

feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + bulk forms#767

Open
coderdan wants to merge 2 commits into
mainfrom
feat/wasm-inline-model-helpers
Open

feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + bulk forms#767
coderdan wants to merge 2 commits into
mainfrom
feat/wasm-inline-model-helpers

Conversation

@coderdan

@coderdan coderdan commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes #742.

Problem

@cipherstash/stack/wasm-inline exposed no model operations, so edge code hand-rolled the per-field bulkEncrypt mapping for every read and write. The failure mode is the quiet one the issue describes: add a column to the schema, forget to extend the hand-written mapping, and the field silently persists in plaintext. #741 deliberately shipped only the value-level bulk halves; this is the remaining, larger half.

Approach: share the traversal, don't port it

The pure model walk — field matching by JS property name, dotted-path nesting, null bookkeeping, nested placement, per-field numeric validation, and the property→DB-name column map (resolveEncryptColumnMap) — moves out of packages/stack/src/encryption/helpers/model-helpers.ts into a new bundle-safe model-traversal.ts (no runtime protect-ffi imports; the existing wasm-inline-bundle-isolation test enforces that at the artifact level). Both entries now consume it:

  • the native model helpers pair it with the NAPI encryptBulk/decryptBulk exactly as before (pure move — no behaviour change, resolveEncryptColumnMap re-exported);
  • the WASM entry pairs it with the /wasm-inline FFI batch calls.

The traversal is where the plaintext-leak class of bug lives, so the two entries can no longer drift on which fields get encrypted.

The new surface

Follows this module's local conventions, per the issue's guidance and the #741 precedent (note: the issue's "this surface throws" is pre-cipherstash/stack#741 — the entry returns { data } | { failure } Results now, and the new methods do too):

Method Notes
encryptModel(model, table) Schema columns encrypted (matched by JS property name; nested fields via the column's dotted path), everything else passes through, null/undefined preserved without reaching ZeroKMS
bulkEncryptModels(models, table) All fields across all models in one FFI crossing; result index-aligned; []{ data: [] } with no call
decryptModel(model, table) Schema-blind collection (any EQL envelope decrypts, wherever it nests); table drives Date reconstruction from cast_as
bulkDecryptModels(models, table) One crossing; failures labelled [model <i>] <field.path>

Details:

  • One ZeroKMS round trip per call, with assertBatchLength guarding the positional pairing (the model batches don't route through runBatch — they're never sparse and carry { modelIndex, fieldKey } structure; its docblock is updated accordingly).
  • Per-field failure reporting on decrypt via decryptBulkFallible: one bad field doesn't mask the rest; the single { failure } names every failed field by its model path with its per-item code.
  • Date columns round-trip Date Date: encrypt serializes to ISO-8601 (WasmPlaintext deliberately carries no Date — a raw Date across the wasm serde would arrive as {}, silent corruption), decrypt rebuilds via the table's cast_as, mirroring the native v3 client's rowReconstructor.
  • No .withLockContext() — identity-bound encryption on the edge is configured at client construction via config.authStrategy (wasm-inline has no auto/dev-profile auth strategy — credential-free local dev impossible on WASM #663 context), noted in the class docblock.
  • Types reuse the v3 model types the native typed client already exports (V3ModelInput / V3EncryptedModel / V3DecryptedModel), so date columns demand Date in and yield Date out at the type level too.

Changes

  • packages/stack/src/encryption/helpers/model-traversal.ts — new shared pure-traversal module (moved code, not new logic)
  • packages/stack/src/encryption/helpers/model-helpers.ts — slimmed to the native FFI pairing; imports the traversal
  • packages/stack/src/wasm-inline.ts — the four methods + two private batch engines, FallibleDecryptItem hoisted for sharing, docblocks updated (the class doc recorded this exact gap)
  • packages/stack/__tests__/wasm-inline-models.test.ts — 13 mocked tests: FFI payload shapes, single-crossing batching, property→DB-name mapping (createdOncreated_on), dotted-path nesting, null/passthrough preservation, Date round-trip, NaN pre-FFI rejection, short-response refusal, per-field failure labels, empty-batch short-circuits
  • e2e/wasm/roundtrip.test.ts — live Deno coverage for all four methods (gated on real CS_* creds, as the rest of that suite)
  • skills/stash-encryption/SKILL.md — replaces "model helpers are not available on the WASM entry" with the actual contract + example
  • .changeset/wasm-inline-model-helpers.md@cipherstash/stack minor

Testing

  • pnpm --filter @cipherstash/stack test — 1034 tests pass (includes the 13 new ones and the bundle-isolation suite against a fresh dist/)
  • pnpm --filter @cipherstash/stack build — clean, including .d.ts emission
  • pnpm run test:types — no type errors; tsc --noEmit reports zero errors in every touched file (remaining repo-wide errors are pre-existing in integration files)
  • pnpm -w run code:check — clean (exit 0)
  • Deno e2e not run locally (needs live credentials); the gated CI job covers it

Out of scope

Summary by CodeRabbit

  • New Features
    • Added model-level encryption/decryption helpers to the WASM inline client, including single and bulk operations.
    • Supports nested/schema-mapped fields, correct Date handling, and preserves null/undefined values.
  • Bug Fixes
    • Improved encryption safety by validating Date inputs and normalizing them consistently.
    • Decryption failures now report the specific failing model field/path.
  • Documentation
    • Updated WASM “Bulk Encrypt / Decrypt (Raw Values)” guidance to include the new model helper behavior.
  • Tests
    • Added WASM roundtrip e2e and expanded model helper test coverage.

…ryptModel + bulk forms

`@cipherstash/stack/wasm-inline` exposed no model operations, so edge code
hand-rolled the per-field bulkEncrypt mapping for every read and write. The
failure mode of that mapping is the quiet one #742 describes: add a column
to the schema, forget to extend the mapping, and the field silently
persists in PLAINTEXT. The model helpers close that gap by construction.

The traversal is SHARED, not ported: the pure walk (field matching by JS
property name, dotted-path nesting, null bookkeeping, nested placement,
numeric validation, the property→DB-name column map) moves out of
packages/stack's model-helpers.ts into a new bundle-safe
encryption/helpers/model-traversal.ts with no runtime protect-ffi imports,
and both entries consume it — the native model helpers pair it with the
NAPI batch calls exactly as before, the WASM entry pairs it with the
/wasm-inline FFI batch calls. The two entries can no longer drift on which
fields get encrypted.

The new surface follows this entry's local conventions (#741 precedent):

- Every method returns the `{ data } | { failure }` WasmResult; arguments
  are plain models plus a v3 table — no `{ id, … }` envelopes.
- One ZeroKMS round trip per call, however many fields/models it covers,
  with assertBatchLength guarding the positional pairing.
- decryptModel/bulkDecryptModels ride decryptBulkFallible: one bad field
  doesn't mask the rest, and the single failure names EVERY failed field
  by its model path (`[model 1] profile.ssn`), with per-item codes.
- Date columns round-trip Date → Date: encrypt serializes to ISO-8601
  (WasmPlaintext carries no Date across the wasm serde — a raw Date would
  arrive as `{}`), decrypt rebuilds via the table's cast_as, mirroring the
  native v3 client's rowReconstructor.
- No .withLockContext(): identity-bound encryption on the edge is
  configured at client construction via config.authStrategy.

13 new mocked unit tests pin the FFI payload shapes, single-crossing
batching, passthrough/null preservation, the property→DB-name mapping,
dotted-path nesting, Date round-trip, per-field failure labelling, and the
short-response refusal; the gated Deno e2e gains live round-trip coverage
for all four methods. skills/stash-encryption drops its "not available on
the WASM entry" caveat in favour of the real contract.

Closes #742
@coderdan
coderdan requested a review from a team as a code owner July 22, 2026 13:47
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e6e2e38

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

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds single and bulk model encryption/decryption to @cipherstash/stack/wasm-inline, using shared schema traversal, nested field mapping, null preservation, date reconstruction, batched FFI calls, aggregated failures, and unit/e2e coverage.

Changes

WASM inline model helpers

Layer / File(s) Summary
Shared model traversal and reconstruction
packages/stack/src/encryption/helpers/model-traversal.ts, packages/stack/src/encryption/helpers/model-helpers.ts
Centralizes schema-aware traversal, safe nested assignment, validation, passthrough handling, bulk index mapping, and model reconstruction.
WASM model operation engines
packages/stack/src/wasm-inline.ts
Adds typed single and bulk model APIs, one-call WASM FFI batching, table validation, date serialization/reconstruction, and per-field failure reporting.
Model helper validation and round trips
packages/stack/__tests__/wasm-inline-models.test.ts, e2e/wasm/roundtrip.test.ts
Tests schema mapping, nested paths, null and passthrough values, dates, empty batches, hardening, failures, and aligned round trips.
Release and API documentation
.changeset/wasm-inline-model-helpers.md, skills/stash-encryption/SKILL.md
Documents the WASM model helper surface, behavior, result semantics, and release increments.

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

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant WasmEncryptionClient
  participant ZeroKMS
  Caller->>WasmEncryptionClient: encryptModel or bulkEncryptModels
  WasmEncryptionClient->>ZeroKMS: one bulk encryption request
  ZeroKMS-->>WasmEncryptionClient: encrypted field payloads
  WasmEncryptionClient-->>Caller: rebuilt model result
  Caller->>WasmEncryptionClient: decryptModel or bulkDecryptModels
  WasmEncryptionClient->>ZeroKMS: one fallible bulk decryption request
  ZeroKMS-->>WasmEncryptionClient: decrypted values or field failures
  WasmEncryptionClient-->>Caller: rebuilt models or failure result
Loading

Possibly related PRs

Suggested reviewers: tobyhede

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR adds all four model helpers, shared schema traversal, single-round-trip bulk handling, and nested/null/date support requested by #742.
Out of Scope Changes check ✅ Passed The changes stay focused on wasm-inline model helpers, shared traversal, tests, docs, and related hardening; no unrelated feature work stands out.
Docstring Coverage ✅ Passed Docstring coverage is 94.44% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding model helper APIs to wasm-inline, including single and bulk encrypt/decrypt forms.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wasm-inline-model-helpers

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: 1

🧹 Nitpick comments (1)
packages/stack/src/encryption/helpers/model-traversal.ts (1)

162-174: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Optional: anchor the nested-recursion prefix match to a path boundary. The gate columnPaths.some((path) => path.startsWith(fullKey)) matches sibling keys that merely share a textual prefix (e.g. 'ab.c'.startsWith('a')), so the walk recurses into objects that have no schema column beneath them. This is benign today — it only over-recurses and never skips a real nested column, so there is no plaintext-leak path — but tightening it to a path boundary keeps this sensitive traversal precise as the schema shapes grow.

  • packages/stack/src/encryption/helpers/model-traversal.ts#L162-L174: change the recurse guard to columnPaths.some((path) => path.startsWith(\${fullKey}.`))`.
  • packages/stack/src/encryption/helpers/model-traversal.ts#L244-L256: apply the same \${fullKey}.`` boundary in the bulk walker.
🤖 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/encryption/helpers/model-traversal.ts` around lines 162 -
174, Anchor both nested-recursion guards to a path boundary by changing the
prefix checks in the model traversal logic at
packages/stack/src/encryption/helpers/model-traversal.ts lines 162-174 and
244-256 to match `${fullKey}.`; update the guards in both the single-object
walker and bulk walker, preserving recursion only when a schema column exists
beneath the current key.
🤖 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__/wasm-inline-models.test.ts`:
- Around line 124-130: Update the assertion in the test around expectData(out)
to use strict equality, ensuring the createdOn property is explicitly preserved
as undefined rather than omitted. Keep the expected object and surrounding
encryption assertions unchanged.

---

Nitpick comments:
In `@packages/stack/src/encryption/helpers/model-traversal.ts`:
- Around line 162-174: Anchor both nested-recursion guards to a path boundary by
changing the prefix checks in the model traversal logic at
packages/stack/src/encryption/helpers/model-traversal.ts lines 162-174 and
244-256 to match `${fullKey}.`; update the guards in both the single-object
walker and bulk walker, preserving recursion only when a schema column exists
beneath the current key.
🪄 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: 2797e1e6-3876-4d2a-a54e-003fc15f5cde

📥 Commits

Reviewing files that changed from the base of the PR and between 2aeba94 and 6fdb7f6.

📒 Files selected for processing (7)
  • .changeset/wasm-inline-model-helpers.md
  • e2e/wasm/roundtrip.test.ts
  • packages/stack/__tests__/wasm-inline-models.test.ts
  • packages/stack/src/encryption/helpers/model-helpers.ts
  • packages/stack/src/encryption/helpers/model-traversal.ts
  • packages/stack/src/wasm-inline.ts
  • skills/stash-encryption/SKILL.md

Comment on lines +124 to +130
expect(ffi.encryptBulk).not.toHaveBeenCalled()
expect(expectData(out)).toEqual({
email: null,
createdOn: undefined,
id: 1,
})
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Vitest toEqual ignore undefined object properties while toStrictEqual checks them?

💡 Result:

Yes, that is correct. In Vitest,.toEqual ignores properties that are set to undefined, whereas.toStrictEqual includes them in its check [1][2]. When you use.toEqual, Vitest asserts that the objects have the same structure and values, but it treats missing keys and keys explicitly set to undefined as equivalent [1][2]. Conversely,.toStrictEqual is more rigorous: it requires that the objects not only have the same structure and values but also the same types and that all properties—including those explicitly set to undefined—are present [1][2]. For example: expect({ a: 1 }).toEqual({ a: 1, b: undefined }) // Passes expect({ a: 1 }).not.toStrictEqual({ a: 1, b: undefined }) // Passes [2]

Citations:


Use toStrictEqual for this undefined-preservation assertion

toEqual treats undefined properties as absent, so this still passes if createdOn is dropped instead of preserved.

💚 Assert undefined preservation strictly
-    expect(ffi.encryptBulk).not.toHaveBeenCalled()
-    expect(expectData(out)).toEqual({
-      email: null,
-      createdOn: undefined,
-      id: 1,
-    })
+    expect(ffi.encryptBulk).not.toHaveBeenCalled()
+    expect(expectData(out)).toStrictEqual({
+      email: null,
+      createdOn: undefined,
+      id: 1,
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(ffi.encryptBulk).not.toHaveBeenCalled()
expect(expectData(out)).toEqual({
email: null,
createdOn: undefined,
id: 1,
})
})
expect(ffi.encryptBulk).not.toHaveBeenCalled()
expect(expectData(out)).toStrictEqual({
email: null,
createdOn: undefined,
id: 1,
})
})
🤖 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__/wasm-inline-models.test.ts` around lines 124 - 130,
Update the assertion in the test around expectData(out) to use strict equality,
ensuring the createdOn property is explicitly preserved as undefined rather than
omitted. Keep the expected object and surrounding encryption assertions
unchanged.

@coderdan

Copy link
Copy Markdown
Contributor Author

Code review — feat(stack): port model helpers to wasm-inline (recall pass, xhigh)

10 finder angles × verification × gap sweep. The extracted traversal is moved verbatim from the native path, so a cluster of latent bugs in that shared walk now also ship on the new public wasm surface — whose docstrings promise "passes through untouched" and "one implementation, cannot drift." The most serious cluster is around caller-object mutation, prototype pollution, and silent plaintext/corruption on model shapes the v3 types actually admit.

The cleanest deep fix for the whole first cluster: make the traversal not mutate the caller (build fresh / deep-clone instead of shallow-copy-and-delete) and guard the delete descent with the same FORBIDDEN_KEYS check setNestedValue already uses. Findings 1–5, 8, 10–12 are inherited verbatim from the native traversal (pre-existing on main), but each now also ships on the new wasm surface whose docs promise pass-through and no drift — so they're in scope.

Findings (most-severe first)

1. Caller input model is mutated; decrypted plaintext written back into the "encrypted" inputpackages/stack/src/wasm-inline.ts:1269
prepareBulkModelsForOperation only shallow-copies ({ ...model }, model-traversal.ts:227), so nested objects are shared. Confirmed with the repo's mocks: after await client.decryptModel({ id: 7, profile: { ssn: <envelope> } }, patients) the caller's own input becomes { id: 7, profile: { ssn: 'plain-0' } }; on encrypt the caller's plaintext becomes an envelope, and the returned model aliases the input's nested objects. Edge code that then logs / caches / re-persists the input row it believes is still ciphertext leaks plaintext — against the do-not-log-plaintext rule — despite the encryptModel docblock (line 1011) promising other fields pass through untouched. On the decrypt failure path the fields are already deleted from the caller's row, so a retry persists rows missing those attributes.

2. Prototype pollution via unguarded delete-walkpackages/stack/src/encryption/helpers/model-traversal.ts:322 (and :122)
The decrypt walk's remove-from-otherFields descent has no FORBIDDEN_KEYS guard (unlike setNestedValue). decryptModel/bulkDecryptModels are schema-blind and process DB-influenced models. Verified: ({...JSON.parse('{"__proto__.x":1}')})['__proto__'] === Object.prototype. For a value passing isEncryptedPayload (v/i/c), the walk splits '__proto__.toString'current = otherFields['__proto__'] (Object.prototype) → delete current['toString'], which succeeds (built-ins are configurable), removing toString process-wide. The guarded setNestedValue throws only afterward — the mutation already committed.

3. Non-null primitive array element silently corruptedpackages/stack/src/encryption/helpers/model-traversal.ts:227
{ ...'x@y.com' } explodes a string into a char-indexed object; { ...42 } yields {}. Verified by probe: bulkEncryptModels(['x@y.com', 42], users) resolves { data: [{'0':'x','1':'@',…}, {}] } with no failure — the plaintext is dropped, nothing reaches ZeroKMS, and edge code persisting result.data writes garbage with zero error signal. Strictly worse than the null-element case (which at least throws).

4. Literal flat dotted key → crash or silent plaintext persistencepackages/stack/src/encryption/helpers/model-traversal.ts:276
A model carrying a v3 dotted column as a literal flat key ({ 'profile.ssn': '…' } — the exact shape V3ModelInput types as valid) crashes the delete-walk or persists plaintext. Verified: encryptModel({ 'profile.ssn': '123-45-6789' }, patients) type-checks but returns { failure: 'Cannot convert undefined or null to object' } (current = otherFields['profile'] is undefined before the delete). With a nested sibling present, the call succeeds and the output still contains "profile.ssn": "FLAT" in plaintext — the exact bug class the changeset claims is now impossible by construction.

5. Already-encrypted schema field re-encrypted (silent double-encryption for types.Json)packages/stack/src/wasm-inline.ts:1176
The recurse guard requires !columnPaths.includes(fullKey), so a schema-path envelope object falls to else if (columnPaths.includes(fullKey)) (model-traversal.ts:175) and is collected as plaintext. Read-modify-write (encryptModel(wholeFetchedRow, table)) double-encrypts untouched columns. Confirmed against protect-ffi's js_plaintext.rs: scalar casts error loudly, but (JsonB, ColumnType::Json) succeeds — so types.Json columns corrupt silently and a later decryptModel returns the stale inner envelope as the "plaintext."

6. Invalid Date fails the whole batch with no coordinatepackages/stack/src/wasm-inline.ts:1176
new Date(NaN) instanceof Date is true and .toISOString() throws RangeError('Invalid time value'); the throw escapes encryptModelsBatchwasmResult → one { failure, message: 'Invalid time value' } for the whole bulkEncryptModels batch, with no model/field coordinate. Numbers get a per-field assertValidNumericValue guard (model-traversal.ts:181); Dates get none. (Lower-probability sibling: a cross-realm Date fails instanceof, falls through, and the serde encodes it as {} — the corruption the adjacent comment itself warns about.)

7. Accepts uninitialized tables; table.build() per callpackages/stack/src/wasm-inline.ts:1215
The native typedClient fails fast for a foreign table (encryption/v3.ts:209-215) and precomputes reconstructors once; the wasm engine silently proceeds (decrypt is schema-blind), applying the wrong table's date-column set — a text field colliding with the foreign table's date column becomes an Invalid Date, no error. A table with two properties resolving to one DB name makes build() throw per call (even for [] input, which the docblock says returns { data: [] } without contacting ZeroKMS). Not in the class docblock's list of "deliberate and local" differences.

8. new Date(item.data) unguarded → silent Invalid Datepackages/stack/src/wasm-inline.ts:1267
An unparseable plaintext at a date-column path yields an Invalid Date returned inside { data }, whose later .toISOString() throws far from the decrypt site. No Number.isNaN(getTime()) guard. (Parity with native v3.ts:168, newly shipped on the wasm model surface.)

9. null/undefined models argument crashes vs native []packages/stack/src/wasm-inline.ts:1059
Native bulkEncryptModels/bulkDecryptModels guard if (!models || models.length === 0) return [] (model-helpers.ts:371-373, 431-433); the wasm methods pass straight into the traversal's models.length loop → TypeError → { failure: "Cannot read properties of null (reading 'length')" }. A plain-JS caller doing bulkEncryptModels(maybeRows ?? undefined, users) gets divergent control flow across the two entries.

10. Dotted null key → phantom nested objectpackages/stack/src/wasm-inline.ts:1188
Null fields are never removed from otherFields and are re-applied via key.split('.'). encryptModel({ 'audit.note': null, id: 1 }, users) returns { 'audit.note': null, id: 1, audit: { note: null } } — the row gains an attribute the caller never had, breaking strict-shape writes. Same on the decrypt rebuild (line 1260).

11. V3EncryptedModel/V3DecryptedModel type dotted columns as passthrough (type lie)packages/stack/src/eql/v3/table.ts:223
The model key is 'profile', never 'profile.ssn', so K extends keyof InferPlaintext<Table> never matches and the nested column falls to the passthrough arm. encryptModel({ profile: { ssn } }, patients).data is typed { profile: { ssn: string } } while data.profile.ssn holds an EQL envelope (decrypt is the inverse lie). The new unit test itself needed as { profile: { ssn: unknown } } (wasm-inline-models.test.ts:143) to assert honestly. This diff newly advertises dotted-path model support, so row.data.profile.ssn.toUpperCase() compiles clean and throws at runtime.

12. Prefix recursion lacks a . boundarypackages/stack/src/encryption/helpers/model-traversal.ts:168
columnPaths.some(path => path.startsWith(fullKey)) matches column 'profileX' against passthrough field 'profile', descending into the passthrough object and injecting its nested nulls. Intended test: path === fullKey || path.startsWith(fullKey + '.').

13. The bulk-model e2e is vacuous against the plaintext-persistence failure modee2e/wasm/roundtrip.test.ts:260
The only live coverage of the bulk model methods asserts round-trip identity but never asserts the payloads are encrypted (no isEncrypted check, unlike the single-model path at line 240). A passthrough no-op regression — the exact "schema column silently persisted in plaintext" failure this PR exists to prevent — would still pass every assertion.

14. Date normalization is special-cased on the model path onlypackages/stack/src/wasm-inline.ts:906 (and encrypt / toFfiQueryTerm)
The Date→ISO fix lives inside encryptModelsBatch; encrypt, bulkEncrypt, and toFfiQueryTerm still pass a raw Date, so a plain-JS caller passing a Date to bulkEncrypt hits the {} serde corruption the model-path comment itself documents. The repo's own doctrine (assertValidNumericValue as a runtime guard "for a plain-JS caller") says type exclusion isn't enough at this boundary. Right depth: a shared toWasmFfiPlaintext() normalizer at all serde crossings, after which the model engine's inline ternary disappears.

15. Changeset missing the stash: patch bump for the skill edit.changeset/wasm-inline-model-helpers.md:2
AGENTS.md: "A skills-only change is not internal: skills/ ships inside the stash tarball, so it needs a stash patch changeset." Sibling PR #741 (commit 508f1d5a) bumped both @cipherstash/stack: minor and stash: patch for the identical stack+skill shape. Without it, the updated skill text ("The model helpers are available on the WASM entry too") never triggers a stash release, so stash init keeps copying the stale skill into customer repos until an unrelated stash change ships.

Refuted / verified clean

  • Date wire format (types.Date sent as full ISO datetime): refuted — protect-ffi 0.30's parse_naive_date accepts RFC3339 and derives the identical canonical NaiveDate as the native Date-object path, so cross-entry equality holds.
  • Also confirmed clean: no barrel leak of the newly-exported traversal helpers, no unused imports after the surgery, the short-FFI-response test genuinely exercises assertBatchLength, and the e2e rowA reuse is safe (flat fields, protected by the shallow copy).

Lower-value cleanup (didn't make the cut, same root cause as #14)

The extraction stopped at the disassembly half. Sharing the reassembly + a single FFI-plaintext normalizer would remove most duplication and several bugs at once: O(models × fields) rebuild loops in both engines, datePropertyPaths rebuilding table.build() per call (native precomputes once), the discarded keyMap re-derived via flatMap, and the duplicated failure-aggregation loop (bulkDecrypt vs decryptModelsBatch).

🤖 Generated with Claude Code — recall-mode review, findings verified against the code (several by executing the paths through the repo's mocks). Treat as advisory.

…pers

Recall-mode review (#767) surfaced a cluster of defects in the SHARED model
traversal that this PR newly ships on the wasm surface, plus wasm-specific gaps.
Fixed at the right depth — mostly by rewriting the traversal to be
non-mutating and unambiguous, so the native entry benefits too.

Shared traversal (packages/stack/src/encryption/helpers/model-traversal.ts):
The walk used to shallow-copy the model and `delete` matched fields out of the
copy, which mutated the caller's nested objects (a nested-column decrypt wrote
decrypted PLAINTEXT back into the caller's "encrypted" input), and re-`split`
the dotted key to find the delete target (crash / plaintext leak on a literal
flat dotted key, and `Object.prototype` reach via `__proto__.x`). Rewritten to
build a FRESH passthrough tree that omits operation fields and keeps nulls in
place:
  - caller model is never mutated (encrypt and decrypt);
  - a literal flat dotted key is normalized to its column path — no crash, no
    surviving plaintext;
  - a `__proto__`-shaped key is refused by the setNestedValue guard (no
    prototype pollution) instead of an unguarded delete-walk;
  - a dotted null key no longer materializes a phantom nested object (nulls
    ride in the tree; the rebuild stopped re-applying a separate null map);
  - an already-encrypted field is passed through, not re-encrypted (was silent
    double-encryption for a types.Json column);
  - a non-object / null model element is rejected loudly (was silent
    char-explosion / {});
  - prefix recursion is `.`-boundary correct;
  - Date/class instances pass through by reference (a plain-object clone would
    rebuild a Date as {}), preserving the exact behaviour the 108 live native
    model tests assert.

WASM entry (packages/stack/src/wasm-inline.ts):
  - model ops validate the table against the client's schemas and precompute
    per-table date paths once at construction (was table.build() per call, and
    a foreign table silently accepted) — mirrors the native typed client;
  - a null / empty models batch returns { data: [] } like the native helpers;
  - Date is normalized to RFC 3339 at EVERY encrypt/query crossing via one
    shared toWasmFfiPlaintext (was only the model path; a Date to bulkEncrypt
    crossed the serde as {}), and an invalid Date is rejected per field;
  - decrypt guards new Date(): an unparseable date value is returned as-is
    rather than a silent Invalid Date;
  - docblocks corrected (native-parity overclaims, the fabricated
    "no { id } envelopes" difference).

Tests / docs:
  - 11 new hardening unit tests (no mutation, literal dotted key, double-encrypt
    passthrough, non-object element, null argument, unknown table, invalid Date,
    __proto__ refusal, value-level Date normalization);
  - the bulk-model e2e now asserts the payloads are actually encrypted (a
    passthrough no-op would have passed before);
  - skill: DB-named-row Date caveat + accurate per-field failure labels;
  - changeset: adds the `stash` patch for the skill change.

Deferred: the V3EncryptedModel/V3DecryptedModel type-level lie for dotted-path
columns (they type the nested column as passthrough) — a recursive mapped-type
change to the shared v3 types affecting the native client too; runtime is
correct. Tracked for a follow-up.

Full suite: 1045 pass; test:types clean; code:check clean.
@coderdan

Copy link
Copy Markdown
Contributor Author

Review feedback addressed — e6e2e38c

The bulk of the review pointed at the shared model traversal, so I fixed it at the root rather than per-symptom: the walk no longer shallow-copies-and-deletes (which mutated the caller and re-split dotted keys), it now builds a fresh passthrough tree that omits operation fields. One change closes findings 1, 2, 4, 5, 10, 12 for both entries. Full suite green (1045), test:types clean, code:check clean; the 108 live native model round-trip tests still pass, so the rewrite is behaviour-preserving for well-formed input.

# Finding Resolution
1 Caller model mutated; plaintext written back Fixed — traversal builds a fresh tree; caller is never written to. New tests assert the input object and returned object are independent on encrypt and decrypt.
2 __proto__ delete-walk → prototype pollution Fixed — no delete-walk exists anymore; a __proto__-shaped key is refused by the existing setNestedValue FORBIDDEN_KEYS guard. Test asserts {}.toString survives and the call fails cleanly.
3 Non-object array element silently corrupted FixedassertModelObject rejects a non-object/null element with a clear failure before any FFI call.
4 Literal flat dotted key → crash / plaintext leak Fixed — the field is omitted (not re-split), so it's normalized to its column path: no crash, no surviving plaintext.
5 Already-encrypted field re-encrypted (silent for types.Json) Fixed — an isEncryptedPayload value at a schema path is passed through, not collected.
6 Invalid Date fails whole batch, no coordinate FixedassertEncryptableValue rejects new Date(NaN) per field, naming the column.
7 Accepts uninitialized tables; build() per call Fixed — the client now stores its schemas, precomputes per-table date paths once at construction, and requireTable() returns the cached set or fails with the "not initialized with" error (matching the native typed client). The empty-batch short-circuit runs before the table check.
8 new Date(item.data) → silent Invalid Date FixedreconstructDate returns the raw value when the parse is NaN instead of an Invalid Date object.
9 null models argument crashes vs native [] Fixed — both batch engines short-circuit `if (!models
10 Dotted null key → phantom nested object Fixed — nulls ride in the tree in place; the rebuild no longer re-applies a separate null map (which is what split the dotted key into a phantom).
11 V3EncryptedModel/V3DecryptedModel type dotted columns as passthrough Deferred (see below).
12 Prefix recursion lacks . boundary Fixed — `path === fullKey
13 Vacuous bulk-model e2e Fixed — the e2e now asserts isEncrypted(row.email) and passthrough integrity, so a passthrough no-op regression fails.
14 Date normalization only on the model path Fixed — one shared toWasmFfiPlaintext() (Date → RFC 3339 + invalid-Date reject) is applied at encrypt, bulkEncrypt, encryptModelsBatch, and toFfiQueryTerm.
15 Changeset missing stash: patch Fixed — added.
Docblock: fabricated "no { id } envelopes" parity; datePropertyPaths native-parity overclaim; skill [model N] on all failures Fixed — docblocks corrected; skill now distinguishes bulkDecryptModels ([model 1] …) from decryptModel (bare field) and adds the DB-named-row Date caveat.

One regression caught during the fix

The first cut of the non-mutating builder recursed into any typeof === 'object', which rebuilt a passthrough Date as {} (a Date has no enumerable keys) — 24 native model tests failed. Fixed by only recursing into plain objects/arrays (isPlainContainer) and passing Dates / class instances through by reference. Good catch by the existing live suite; it's now also covered by the "keeps a valid Date" assertions.

Deferred: finding 11 (type-level dotted-column lie)

V3EncryptedModel/V3DecryptedModel type a dotted-path column's field as untouched passthrough (data.profile.ssn is typed as its plaintext type though it holds an envelope). Fixing this correctly needs a recursive mapped type that maps dotted column paths onto the nested model shape, and it lives in the shared v3 types consumed by the native typed client too — a larger, riskier type-system change than this review's runtime scope. The runtime is correct; I've left it for a focused follow-up rather than rush a mapped-type change here. Happy to spin that out as its own issue/PR.

🤖 Generated with Claude Code

@coderdan
coderdan requested a review from freshtonic July 23, 2026 01:28

@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: 2

🤖 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 @.changeset/wasm-inline-model-helpers.md:
- Around line 6-8: The changeset’s ZeroKMS round-trip description must
distinguish normal work from short-circuit cases. Update the wording around the
model helper operations to state that non-empty batches containing encrypted
fields use one round trip, while null/empty batches and batches with no
operation fields return without contacting ZeroKMS.

In `@skills/stash-encryption/SKILL.md`:
- Line 463: Update the WASM model-helper documentation’s round-trip statement to
specify that non-empty batches make one ZeroKMS round trip, while empty batches
are short-circuited and make zero calls; keep the behavior description for
non-empty calls unchanged.
🪄 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 Plus

Run ID: 951ee1af-f5e5-4d1a-8cf1-147252d1bd30

📥 Commits

Reviewing files that changed from the base of the PR and between 6fdb7f6 and e6e2e38.

📒 Files selected for processing (6)
  • .changeset/wasm-inline-model-helpers.md
  • e2e/wasm/roundtrip.test.ts
  • packages/stack/__tests__/wasm-inline-models.test.ts
  • packages/stack/src/encryption/helpers/model-traversal.ts
  • packages/stack/src/wasm-inline.ts
  • skills/stash-encryption/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • e2e/wasm/roundtrip.test.ts

Comment on lines +6 to +8
`@cipherstash/stack/wasm-inline` now has the model helpers: `encryptModel` / `decryptModel` and `bulkEncryptModels` / `bulkDecryptModels` (#742). They run the same schema traversal as the native entry (shared code, so the two entries cannot drift on which fields get encrypted): declared columns are encrypted — matched by JS property name, nested fields via the column's dotted path — everything else passes through, and `null`/`undefined` fields are preserved without reaching ZeroKMS. Each call is one ZeroKMS round trip regardless of how many fields or models it covers, `types.Date`/`types.Timestamp` columns round-trip `Date` → `Date` (ISO strings on the wire), and failures follow this entry's `{ data } | { failure }` Result contract, with decrypt failures naming every failing field by its model path. Edge code no longer needs the hand-written `bulkEncrypt` field mapping whose failure mode was a schema column silently persisted in plaintext.

The shared model traversal is also hardened: it no longer mutates the caller's model (previously a nested-column decrypt wrote decrypted plaintext back into the caller's input, and encrypt overwrote it with ciphertext); a literal flat dotted key, a `__proto__`-shaped key, or a non-object model element is handled safely instead of crashing, leaking plaintext, or reaching `Object.prototype`; an already-encrypted field is passed through rather than re-encrypted; and an invalid `Date` is rejected per field. On the WASM entry, model ops now validate the table against the client's schemas, `Date` values are normalized at every encrypt/query crossing (not just the model path), and a `null`/empty model batch returns `{ data: [] }`. The skills update ships in the `stash` tarball, hence the `stash` patch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clarify the ZeroKMS round-trip claim.

Line [6] says every call performs one ZeroKMS round trip, but Line [8] documents that null/empty batches return without contacting ZeroKMS. The implementation also skips FFI when no operation fields are present. Document that non-empty batches containing encrypted fields use one round trip, while empty/no-op batches short-circuit.

🤖 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/wasm-inline-model-helpers.md around lines 6 - 8, The changeset’s
ZeroKMS round-trip description must distinguish normal work from short-circuit
cases. Update the wording around the model helper operations to state that
non-empty batches containing encrypted fields use one round trip, while
null/empty batches and batches with no operation fields return without
contacting ZeroKMS.

`null` / `undefined` entries yield `null` at the same index without reaching ZeroKMS. Because each entry names its own column, one call can cover several columns across many rows. When items fail to decrypt, `failure.message` names every failing index. The model helpers (`encryptModel` / `bulkEncryptModels` / …) are **not** available on the WASM entry.
`null` / `undefined` entries yield `null` at the same index without reaching ZeroKMS. Because each entry names its own column, one call can cover several columns across many rows. When items fail to decrypt, `failure.message` names every failing index.

**The model helpers are available on the WASM entry too**: `encryptModel(model, table)`, `decryptModel(model, table)`, `bulkEncryptModels(models, table)`, `bulkDecryptModels(models, table)`. They run the same schema walk as the native client — declared columns are encrypted (matched by **JS property name**; nested fields via the column's dotted path), everything else passes through, `null`/`undefined` fields are preserved without reaching ZeroKMS, and the caller's model is never mutated — and each call is **one ZeroKMS round trip** no matter how many fields or models it covers. `table` must be one the client was built with (`Encryption({ schemas })`), else the call fails, as on the native client. `types.Date` / `types.Timestamp` columns round-trip `Date` → `Date` (on the wire they travel as ISO strings); because matching is by JS property name, a row keyed by raw DB column names (e.g. a raw `SELECT` returning `created_on`) still decrypts, but its date fields come back as ISO strings — key your models by the schema's property names. Differences from the native typed client: every method returns a plain `Promise` of the `{ data } | { failure }` Result (no `.audit()` chaining), and there is **no lock-context argument** — identity-bound encryption on the edge is configured at client construction via `config.authStrategy`. Decrypt failures name every failing field: `bulkDecryptModels` prefixes the model index (`[model 1] profile.ssn`), `decryptModel` names the field alone (`profile.ssn`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the empty-batch exception in the round-trip claim.

This says every model-helper call makes one ZeroKMS round trip, but empty batches are short-circuited and make zero calls. Please qualify this as “one round trip for non-empty batches.”

🧰 Tools
🪛 SkillSpector (2.3.11)

[warning] 53: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 103: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 105: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 108: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 110: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 114: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 132: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 620: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 620: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))

🤖 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-encryption/SKILL.md` at line 463, Update the WASM model-helper
documentation’s round-trip statement to specify that non-empty batches make one
ZeroKMS round trip, while empty batches are short-circuited and make zero calls;
keep the behavior description for non-empty calls unchanged.

@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. This is a careful, well-tested port. The central safety property — schema-declared columns cannot silently slip through unencrypted — is preserved by sharing the traversal (model-traversal.ts) between the native and WASM entries rather than re-implementing it, backed by the wasm-inline-bundle-isolation test to keep protect-ffi out of the WASM bundle. I traced the correctness-critical paths and found no blocking defects.

What I verified

  • Batch alignment / ordering. In encryptModelsBatch / decryptModelsBatch, fields is a global flatMap over operationFields in visit order, the FFI payload is fields.map(...), results come back positionally aligned, and assertBatchLength gates the count before any rebuild. The rebuild uses the same global index (fields.forEach((field, i) => … results[i])), so field↔result pairing is internally consistent. Object.entries insertion order is preserved on both the build and read side, so no drift. Not routing through runBatch is justified — model batches are never sparse (nulls stay in the passthrough tree, never become operation fields).
  • No plaintext leakage. Matching is columnSet.has(fullKey) keyed by JS property name via resolveEncryptColumnMap, addressed to the DB name via toColumnName — same mechanism as native. A schema column holding a non-envelope object (e.g. types.Json) is still collected as an operation field. null/undefined schema fields are preserved in place and never reach ZeroKMS.
  • Non-mutation. buildPassthroughTree returns a fresh tree; nested containers with a schema column beneath them are recursed and cloned, so the caller's input is untouched on both encrypt and decrypt. The .not.toBe(input.profile) and "does not write decrypted plaintext back" tests pin this. This fixes a genuine prior bug where a decrypt wrote plaintext back into the caller's object.
  • Date handling. toWasmFfiPlaintext is now applied at every encrypt/query crossing (single, bulk, query, model), fixing silent {} corruption of Date across the wasm serde; an invalid Date is rejected pre-FFI per field; reconstructDate returns the raw value on an unparseable stored date rather than handing back an Invalid Date. Round-trip is Date → ISO → Date, keyed by datePropertyPaths (JS property names).
  • Failure reporting. decryptBulkFallible means one bad field doesn't mask the rest; the aggregate { failure } names every failing field by model path with per-item code. Message formats match the tests.
  • Table validation & prototype safety. requireTable rejects an unregistered table on both model paths; setNestedValue blocks __proto__/prototype/constructor in operation-field placement (the __proto__.toString test confirms it surfaces as a failure, and global Object.prototype is intact).

Non-blocking notes (optional)

  1. buildPassthroughTree passthrough assignment vs a literal __proto__ own key. Passthrough (non-schema, non-envelope) fields are copied with out[key] = value. For a field literally named __proto__ (e.g. from JSON.parse), bracket assignment invokes the prototype setter rather than storing data, unlike the old { ...model } spread (which used define semantics). This does not pollute global Object.prototype (it only sets the fresh out's own prototype) and only affects a non-schema passthrough field, so it is not a security or plaintext issue — but it silently drops that field and is a fidelity gap given the changeset advertises __proto__-key safety. Consider Object.defineProperty / a forbidden-key guard in the passthrough copy for symmetry with setNestedValue. Low priority.
  2. Array-index columns in dotted paths. setNestedValue materialises {} intermediates, not arrays, so a hypothetical array-indexed column path would rebuild as an object. Pre-existing limitation, unlikely in practice; the array-preservation arm in buildPassthroughTree covers passthrough arrays. Worth a comment at most.
  3. Test gap. requireTable is exercised on the decrypt path against a foreign table but there is no equivalent test for encryptModel / bulkEncryptModels against an unregistered table (both call requireTable). Cheap to add.

None of these block. Nice work on the shared-traversal approach and the hardening pass.

@tobyhede

Copy link
Copy Markdown
Contributor

Sharing the traversal instead of porting it is the right call, and the batching/assertBatchLength/per-field failure labelling are solid. Four things before merge.

1. types.Json plaintext shaped like an EQL envelope is now silently stored in plaintextmodel-traversal.ts:283,322

The new isOperationField skips any schema field where isEncryptedPayload(value) is true, and that predicate is purely structural (v number, i object, c or sv present). Verified against the real walk:

table:  encryptedTable('docs', { payload: types.Json('payload') })
model:  { payload: { v: 1, i: { some: 'meta' }, c: 'customer code' } }
→ operationFields: {}
→ otherFields:     { payload: { v: 1, i: {...}, c: 'customer code' } }   // PLAINTEXT

Encrypted before this PR. Same failure class the PR exists to close, and it now applies to the native entry too. Require the envelope to be this column's: isEncryptedPayload(value) && value.i?.t === table.tableName && value.i?.c === toColumnName(fullKey) — a foreign envelope then fails at the FFI instead of passing through.

2. A literal __proto__ own key drops the field and reassigns the output's prototypemodel-traversal.ts:175,200

out[key] = value hits the Object.prototype setter for __proto__, so no own property is created:

model (JSON.parse):  {"__proto__": {"a": 1}, "b": 2}
→ own keys: ['b']                        // field gone
→ Object.getPrototypeOf(otherFields): { a: 1 }

setNestedValue guards this; the passthrough arm doesn't. The __proto__.toString test only covers the throwing path. Use Object.defineProperty, or reject the key with the same "Forbidden key" error.

3. Date → ISO-8601 → Date has no live coverage. Every test mocks the FFI, so they prove the string is produced, not that parse_naive_date accepts a full datetime for a date cast. The Deno e2e table is TextSearch only — add a types.Date column there.

4. Rebuild is O(models × total fields)wasm-inline.ts:1249,1322. Each model scans the whole flat field list; 1000 rows × 3 columns = 3M no-op iterations on the bulk-read path. Group into a Map<number, Field[]> once.

Smaller:

  • nullFields is now always {} (model-traversal.ts:256,293,350) and model-helpers.ts still runs eight dead loops over it (172, 226, 284, 344, 402, 459, 521, 585). Delete the field and the loops — an always-empty map invites someone to repopulate it.
  • Decrypt reshapes dotted keys: {'profile.ssn': <envelope>} from a raw SELECT returns nested. Safe, but the skill documents the date-string caveat for DB-keyed rows and not this one.
  • bulkEncryptModels(nonArray) falls past the guard and fails at models.map is not a function. Array.isArray in the guard would name it.

PR body needs correcting: "pure move — no behaviour change" and "native model behaviour out of scope" are no longer true — the changeset is honest about it, the body isn't. Native encryptModel/decryptModel change in at least four ways (non-mutating, already-encrypted skip, invalid-Date rejection, nulls not re-applied).

Conventions all fine: changeset present with the stash patch, skill updated in-PR, Biome clean apart from pre-existing warnings. Note packages/stack build fails locally on a pre-existing src/types.ts:483 ste_vec_value_selector error that's also on main — unrelated, but it means wasm-inline-bundle-isolation.test.ts (the test cited as enforcing the no-native-import invariant) can't run locally; worth confirming it ran in CI on this branch.

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.

wasm-inline has no model helpers: encryptModel/decryptModel (and their bulk forms) are Node-only

3 participants