Skip to content

refactor(stack): inject the FFI binding into the encryption operations (#798, stages 1-3) - #800

Draft
coderdan wants to merge 119 commits into
remove-v2from
feat/inject-ffi-backend
Draft

refactor(stack): inject the FFI binding into the encryption operations (#798, stages 1-3)#800
coderdan wants to merge 119 commits into
remove-v2from
feat/inject-ffi-backend

Conversation

@coderdan

@coderdan coderdan commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Injects the FFI binding into the encryption operations, so one operation layer can serve both entries. Stages 1–3 of #798.

Important

Stage 4 was attempted and reverted (669d65c). A review found four confirmed defects, none caught by the suite. Stage 4 is now blocked on an upstream protect-ffi change — see below. What remains here is internal plumbing plus one additive API change.

Why

The native and wasm-inline entries had drifted into different client surfaces. Not because WASM is different, but because the operation classes reached their FFI binding by module-level import. A value import of @cipherstash/protect-ffi is a runtime load of a native binary, which cannot happen in a V8 isolate — so nothing importing an operation was reusable on the edge, and wasm-inline.ts reimplemented the whole surface separately. It then drifted on call shape (#792), and never gained .audit() or .withLockContext() (#793, #797).

What landed

Stage Change
1 CryptoBackend — a 1:1 restatement of the six FFI functions, with type-only imports. backend-native.ts implements it.
2 EncryptionOperation gains catch, finally, [Symbol.toStringTag], so it satisfies Promise<Result<…>> structurally.
3 All ten operations take an injected backend.
4 Reverted.
5 Already existed (wasm-inline-bundle-isolation.test.ts, from #741) and is stricter than planned.

Three transitive blockers were removed on the way: noClientError and resolveLockContext were split out of modules that drag in the NAPI binding and fs/path respectively.

The false dichotomy in #798 — Result or thenable — turned out not to be one. @byteslice/result is a union type plus a wrapper; it imposes nothing. Native already had both. So the question was only whether the WASM methods could return the operation object, and adding the three missing Promise members makes that additive rather than breaking.

Why stage 4 came back out

Four defects, verified against the built artifact:

  1. The WASM entry crashed on import. EncryptOperation@/utils/logger, whose initStackLogger() runs at module scope and read process.env unguarded. ReferenceError: process is not defined in a Worker without nodejs_compat, or a browser — the runtimes the entry exists for.
  2. Silent Date corruption. The shared path dropped toWasmFfiPlaintext. Per its own doc, a JS Date crosses wasm-bindgen as {}. Bulk and query kept the call; encrypt became the only path without it.
  3. Result-contract break. log.set({ column: this.column.getName() }) runs outside withResult, so a malformed column rejects instead of returning { failure }.
  4. The published types stopped being self-contained. dist/wasm-inline.d.ts gained @byteslice/result and the native protect-ffi root.

Plus one unconfirmed: unverifiedContext: undefined reached the serde boundary for the first time. toFfiQueryTerm documents exactly this failure for queryOp — "the native NAPI layer tolerates undefined; the WASM one does not".

All of this passed CI. 1073 unit tests, 103 type tests, and the bundle-isolation test were green throughout. That is the more useful finding than any individual defect.

The blocker

dist/wasm/protect_ffi.d.ts types every binding function as (client: WasmClient, opts: any) and exports none of the option types. So CryptoBackend must import them from the native specifier, which then lands in the WASM entry's published types. Same root cause forces the six as never casts in backend-wasm.ts, meaning the WASM call sites get no type-checking from the interface meant to keep both bindings honest.

The fix is upstream in protect-ffi (filed as cipherstash/protectjs-ffi#142): have the WASM .d.ts use the same named option types rather than any. That makes CryptoBackend a genuinely shared contract instead of the native types being borrowed to describe both bindings — which is what #798 is actually after. (A runtime-free ./types subpath would also work, but is strictly weaker.)

Worth noting the four protect-ffi type names still in the reverted .d.ts predate this work, arriving via @/types re-exports. The upstream change is worth making regardless.

Also fixed here

  • Logger process.env guard. Latent regardless of stage 4, and the trap that catches the next attempt.
  • Unified LockContextInput. Two exported types with the same name disagreed: identity/index.ts had LockContext | Context, the operations had { identityContext } | Context. v3.ts typed the v3 client's parameter narrower than the untyped client's.
  • A near-vacuous test. The int64-boundary case asserted only that the failure message differed from the rejection message — which would also hold if the guard broke with different wording. It now records the FFI call and asserts the value arrived intact.
  • JSDoc on CryptoBackend and the operations, stating the invariants neither the types nor any test enforce: bulk calls preserve order (callers re-associate positionally); lockContext is top-level on single calls but per-item on bulk ones; unverifiedContext is audit metadata and never affects decryptability.

Behaviour

.catch() and .finally() now exist on operations — the only user-visible change, and additive.

One caveat now documented in the changeset: an operation is lazy and executes per settlement. then, catch, and finally each call execute(). op.catch(h) followed by await op is two ZeroKMS round trips. Pre-existing for then; stage 2 widened the surface without memoising. Memoising would change existing native double-await semantics, so it deserves its own decision.

Still outstanding

  • model-helpers.ts value-imports the native binding, so the four model operations cannot join the shared path. Needs a decision on widening CryptoBackend with the non-fallible decryptBulk, which is currently excluded on purpose.
  • wasm-inline.ts still carries its own toError / readErrorCode / safeString.
  • Implement .withLockContext() on the wasm-inline entry #797 is not closeable, and wasm-inline has no lock context — and explains its absence incorrectly #793's capability half is not either. Whether the WASM binding honours lockContext across serde needs a live call. Shipping .withLockContext() there while that is open risks a successful-looking payload whose key is not bound to the claim the caller asked for.

Verification

1073 runtime + 103 type tests pass; code:check clean; dist/wasm-inline.js externals unchanged from baseline; the logger and @byteslice/result confirmed absent from the WASM bundle and its .d.ts after the revert.

tobyhede added 30 commits July 24, 2026 10:50
…t-dynamodb)

PR 1 of the EQL v2 final removal (#707).

Delete the closed v2-only dependency chain — @cipherstash/protect-dynamodb →
@cipherstash/protect → @cipherstash/schema — and every reference to it. Nothing
outside the three imported them (@cipherstash/stack depends only on the separate
@cipherstash/protect-ffi). They are superseded by @cipherstash/stack:

- @cipherstash/protect        -> @cipherstash/stack
- @cipherstash/schema         -> @cipherstash/stack/schema
- @cipherstash/protect-dynamodb -> @cipherstash/stack/dynamodb (encryptedDynamoDB)

Existing EQL v2 ciphertext stays decryptable through @cipherstash/stack; this
removes the v2 authoring/emission surface, not the read path.

Reference cleanup (dangling refs that would break build/CI):
- e2e/package.json @cipherstash/protect dep edge
- root package.json build:js turbo filter
- tests.yml protect/protect-dynamodb .env steps (would fail `touch` on gone dirs)
  and the bun-job test loop
- rebuild-docs.yml trigger tag (@cipherstash/protect@* -> @cipherstash/stack@*)
- integration-{drizzle,prisma-next,supabase}.yml packages/schema/** path filters
- lint-no-hardcoded-runners allowlist entry
- e2e package-managers BIN fixture (dead) + two stale source comments

Changeset / RC housekeeping:
- delete schema-stevec-standard-pin.md (only target was the deleted schema)
- prune the three from pre.json initialVersions
- add deletion-notice changeset on @cipherstash/stack + @cipherstash/nextjs

Meta honesty: SECURITY.md package list, AGENTS.md Repository Layout, nextjs
package description.
- Note the intentional @cipherstash/nextjs patch (package.json description edit)
- Use PCRE negative lookahead so the stale-reference check excludes protect-ffi
Remove the `eql_v2_encrypted → 2` branch from `classifyEqlDomain`, so the
migrate domain-type resolution (`detectColumnEqlVersion`,
`listEncryptedColumns`, `resolveEncryptedColumn`) recognises only the
self-describing `eql_v3_*` domains. v3 is the sole generation this workspace
authors and backfills; a legacy v2 column's version is carried by the
manifest's recorded `eqlVersion` (the CLI status renderers already fall back
to it), so v2 status output is unchanged.

This drops v2 *classification*, not the v2 read path — existing v2 ciphertext
stays decryptable via `@cipherstash/stack`. `EqlVersion` keeps its `2` member
for manifest-sourced legacy values and the exported function signatures are
unchanged. Tests in `version.test.ts` are updated to assert v2 domains are no
longer classified (excluded from `listEncryptedColumns`, `null` from the
detectors).

Decision 6 guard: `classifyEqlDomain` is a source-column classifier for
backfill planning and read-only CLI status display — no decrypt/round-trip
consumes its `2` result — so dropping v2 here is safe.

NOTE: `packages/migrate/src/eql.ts` (the `eql_v2.*` config-lifecycle wrappers)
is NOT deleted in this PR. Although it carries no decrypt path, it is hard-
imported by the CLI's v2 cut-over/config commands (`encrypt cutover`,
`db activate`, `db push`); deleting it would break the CLI build, which is
out of this migrate-scoped PR. Its removal must be sequenced with the CLI
v2-command removal.

PR 2 of the EQL v2 final removal (#707).
…cryption

PR 3 of the EQL v2 removal (#707). Makes EQL v3 the sole generation the client authors and writes, while preserving the minimal v2 READ path on the core client and through the DynamoDB adapter.

- Add MappedDecryptOperation: the typed client's decryptModel/bulkDecryptModels are now audit-chainable (.audit()/.withLockContext()) instead of a bare Promise, restoring audited decrypt including through encryptedDynamoDB.

- Collapse EncryptionV3 into an overloaded Encryption; EncryptionV3 is now a deprecated, type-identical alias. An explicit config.eqlVersion is honored (the migration escape hatch is retained).

- BREAKING: remove the DynamoDB v2 WRITE overloads (encryptModel/bulkEncryptModels); decrypt still reads existing v2 items.

- Deprecate (retain) ClientConfig.eqlVersion and the ./schema v2 builders for legacy v2 read/migrate; siblings still consume them, so full removal is deferred to a later PR.

- Tests: flip the audit-surface type assertions, add runtime audit-forwarding tests (both chaining orders), and v2-read acceptance integration tests (core + DynamoDB).

- Update stash-dynamodb and stash-encryption skills and AGENTS.md.

Changesets: @cipherstash/stack minor (audit-on-decrypt), @cipherstash/stack major (DynamoDB v2-write removal), stash patch (skills).
Verification of the EncryptionV3->Encryption collapse surfaced a regression: because Encryption now returns the TYPED client for a v3 schema set, stack-supabase's encryptedSupabaseV3 (which casts to the nominal overload and calls decryptModel(row)/bulkDecryptModels(rows) one-arg) hit the typed methods with no table argument and threw on undefined.tableName. The sibling build passed because it is a type/runtime mismatch with no v3 runtime coverage.

Fix: the typed client's decryptModel/bulkDecryptModels now tolerate a missing table, degrading to nominal behaviour (decrypt without date reconstruction) instead of dereferencing undefined — restoring exactly what stack-supabase received before the collapse. table stays required for genuinely-typed callers.

- Add regression tests: typed client one-arg decryptModel/bulkDecryptModels decrypt without throwing (no reconstruction).

- Correct now-stale customer-visible source docs flagged in review: the resolveDecryptResult docblock, the encryptedDynamoDB version-mismatch error string and @example, and the EncryptedDynamoDBConfig JSDoc (all pointed at EncryptionV3 / needless eqlVersion:3). Update construct-guard.test.ts to the new error wording.

Follows 686004f.
Re-parameterize EncryptedQueryBuilderImpl over AnyV3Table and inline every
EncryptedQueryBuilderV3Impl dialect override, then delete query-builder-v3.ts.
Pure refactor: no runtime behaviour or wire encoding change. The decrypt path
stays generation-agnostic (decryptModel/bulkDecryptModels), so stored EQL v2
payloads still decrypt (Decision 6).
encryptedSupabase is now the introspecting EQL v3 factory (was encryptedSupabaseV3);
encryptedSupabaseV3 kept as a @deprecated type-identical alias. The legacy v2
encryptedSupabase({ encryptionClient, supabaseClient }).from(table, schema) wrapper
and EncryptedSupabaseConfig are removed. All *V3 type exports de-suffixed to
canonical names with @deprecated *V3 aliases retained. v2 decrypt is unaffected.
Point tests at the folded EncryptedQueryBuilderImpl, drop the removed v2
authoring tests (v2 live suite, v2 wire-encoding block, v2 builder type test),
convert the shared execute() error-threading tests to a v3 table, and add
canonical-name type assertions.
v2 read via this adapter is intentionally removed; v2 ciphertext still
decrypts through the core @cipherstash/stack client. Mixed-generation
handling is customer-side (install both), per #707 out-of-scope stance.
…ap 91 -> 71

The EQL v2 removal folded `query-builder-v3.ts` into `query-builder.ts`
(2b4e2e9). FTA penalises size superlinearly, so two files that each passed
the complexity gate became one that did not: 90.88 + 73.95 -> 105.77, against
a cap of 91. That failed the "Analyze v3 complexity" check on #769.

`fta-v3.yml` is explicit that the cap is "a ratchet, not an aspiration", so
decompose rather than raise it. The 2331-line class becomes a pipeline:

  column-map.ts     ColumnMap - name + capability resolution, the concern
                    every stage needed (was six correlated fields)
  query-encrypt.ts  filter-operand terms: collect, validate, batch-encrypt
  query-mutation.ts row-data encryption for insert/update/upsert
  query-dbspace.ts  property-space -> DB-space, once
  query-filters.ts  operand substitution onto the PostgREST query
  query-results.ts  decryption + Date reconstruction
  query-builder.ts  the class: recorded state, fluent surface, execute()

Worst file 105.77 -> 70.12 (query-encrypt.ts); cap lowered 91 -> 71.

Also removed, all no-ops from the pre-fold v2/v3 inheritance:

- `notFilterOperator` - an identity function with an unused parameter
- `applyPatternFilter`'s dead `_wasEncrypted` parameter
- `protected` throughout, now `private`; nothing extends the class

and collapsed the six-times-repeated withLockContext/audit/await dance into
one `withOpContext` helper - a skipped lock context would encrypt under the
wrong data key, so the three steps must stay identical.

`EncryptedQueryBuilderImpl` keeps its name, export site and constructor;
nothing here is part of the package's public surface (index.ts does not
re-export it). 466 tests pass unchanged. One test reached the unsupported-
queryType backstop by subclassing to override `queryTypeForRawOp`, which is
now a module function; `assertTermQueryable` is exported instead and the test
calls it directly, dropping a subclass its own comment called "breaking the
internal contract".

Docs: correct comments asserting adapter-side EQL v2 reads. The adapter is v3
only and does not auto-read `eql_v2_encrypted` columns - introspection matches
`public.eql_v3_*` domains exclusively, so such a column never enters the
encrypt config. No ciphertext is stranded: core decryption stays
generation-agnostic. Also retires the two-dialect scaffolding in types.ts,
whose header claimed v3 does free-text via `contains` - contradicting the
typed surface forty lines above it and re-introducing the exact confusion
#617 removed. `EncryptedQueryBuilderCore`'s OK/BK defaults are kept: they are
live for the untyped surface, only their v2-era rationale was wrong.
Delete the EQL v2 authoring surface (`src/index.ts` `encryptedType` +
config extraction, `src/operators.ts`, `src/schema-extraction.ts`) and
promote the EQL v3 implementation from `./v3` to the package root as a
hard break with no alias.

- Drop the `./v3` subpath from the `exports` map and remove `typesVersions`;
  the v3 impl now lives at `src/*` and is the sole `.` export.
- De-suffix the public API: `createEncryptionOperatorsV3` ->
  `createEncryptionOperators`, `extractEncryptionSchemaV3` ->
  `extractEncryptionSchema`. No `*V3` aliases (they would type-check v2 call
  sites against v3 semantics).
- Delete the two v2 operator test files; move the v3 test suite to
  `__tests__/*` and repoint imports. ESM+CJS both preserved.
- Fix the `@cipherstash/bench` importer: rewrite the Drizzle table to v3
  `types.*` domains + `EncryptionV3`, switch operator usages off the removed
  v2-only ops (`like`/`ilike`, jsonb-path) to `matches`/`contains`/`selector`,
  and update the bench fixture schema.sql to the eql_v3 domains/index terms.
- Update the README and the bundled `stash-drizzle` skill to the collapsed
  root imports; remove v2 authoring guidance.

Existing v2 ciphertext still decrypts via `@cipherstash/stack`; only the
Drizzle-side v2 authoring/query-building is removed (Decision 6).

BREAKING CHANGE: `@cipherstash/stack-drizzle` no longer exports an EQL v2
surface and the `./v3` subpath is removed. Import the v3 API from the package
root with the de-suffixed names.
…ollapse

PR 3 made EncryptionV3 an overloaded alias; ReturnType<typeof EncryptionV3>
now resolves to the nominal overload (EncryptionClient) not the typed client.
Infer through a single-signature helper so the bench handle keeps the typed
client type.
…the image

The bench fixture schema moved to concrete EQL v3 domains
(`public.eql_v3_text_search`, `eql_v3.eq_term(...)`), but tests-bench.yml
started `postgres-eql:17-2.3.1` — which ships EQL v2 — and ran the suite
immediately. `applySchema` failed with `type "public.eql_v3_text_search" does
not exist`. Nothing tied the image tag to the EQL version the fixtures need,
so the coupling broke silently.

Install v3 the way every integration suite already does: a vitest
`globalSetup` calling test-kit's `installEqlV3`, which shells out to the real
`stash eql install --eql-version 3`. That needs only a database URL — no
CipherStash credentials — so `db-only.test.ts` stays the credential-free smoke
test it is meant to be, and CI and `pnpm test:local` become the same path
rather than CI depending on a step the local flow never ran.

The globalSetup imports `@cipherstash/test-kit/install` (a new narrow subpath
export) rather than the barrel: the barrel reaches `needle-for.ts`, which
consumes stack source through the `@/` alias, and bench has no
`stackSourceAlias` in its vitest config. `install.ts` depends on node builtins
only.

Workflow changes:
- Use the `integration-setup` composite, which already builds the `stash` CLI
  the install needs (pinned to this job's existing Node 22).
- Start only the `postgres` service; docker-compose.yml also defines a
  PostgREST that bench never talks to.
- Widen the trigger paths to follow the package graph. bench depends on
  `@cipherstash/stack` and `@cipherstash/stack-drizzle`, and now on the CLI's
  EQL installer, but the filter covered only `packages/bench/**` and
  `local/**` — a break upstream could never trigger this job.

No changeset: `@cipherstash/bench` and `@cipherstash/test-kit` are both
private, and workflow files are repo tooling.
… scaffold, stale shipped docs

Six review findings, each with the regression test that would have caught it.

- `packages/bench/sql/schema.sql` indexed `eql_v3.ste_vec(enc_jsonb)`, a
  mechanical rename of the v2 expression. It builds, and `db-only.test.ts`'s
  `pg_indexes` check passes, but nothing the adapter emits ever mentions it:
  `@>` on `eql_v3_json_search` inlines to `eql_v3.to_ste_vec_query(a)::jsonb`
  (the bundle's own comment says so). A permanently-dead index in the one
  fixture whose job is to prove index engagement. Now indexed on the inlined
  expression, with `scripts/__tests__/bench-index-expressions.test.mjs` pinning
  each bench index against the operator body in the vendored EQL bundle — no
  database, no credentials, which matters because the bench's own EXPLAIN
  assertions need credentials and never run in CI.

- `stash init --drizzle` scaffolded `extractEncryptionSchemaV3` from
  `@cipherstash/stack-drizzle/v3` into the user's repo — both removed here, so
  a freshly-initialised project would not resolve. Flipped the drizzle half of
  the generated strings (the `@cipherstash/stack/v3` half stays for PR 7) and
  pinned it in `utils-codegen-drizzle.test.ts`.

- `skills/stash-encryption`, `packages/stack/README.md` and `AGENTS.md` still
  documented the `./v3` subpath and the `*V3` names. Both skills and the stack
  README are shipped artifacts. `no-removed-drizzle-surface.test.mjs` scans the
  shipped file set (deliberately not CHANGELOGs or specs, which should still
  name the old surface).

- `vitest.shared.ts` aliased `@cipherstash/stack-drizzle/v3` to the deleted
  `src/v3/index.ts`; `vitest-shared-alias.test.mjs` asserts every alias target
  exists on disk.

- The bench `matches` operand was the shared `value` prefix, which every seeded
  row contains — the bloom index had nothing to narrow, so the number measured
  a full scan. Uses a full seeded value now.

- The bench importer's only CI gate was the Bun job's `turbo build`, which
  swallows its own test failures. Added an explicit typecheck step to the main
  test job.

The eq/matches index assertions are sound and now say why: the operator
wrappers are `LANGUAGE sql IMMUTABLE STRICT` single-SELECT bodies, so the
planner inlines them and applies the same inlining to the stored index
expression. The old comment claimed the adapter emitted
`eql_v3.eq_term(col) = eql_v3.hmac_256(value)` — it emits `eql_v3.eq(col,
term)`, and `eql_v3.hmac_256` does not exist in the bundle at all.
Addresses PR #760 review feedback.

- `docs/query-api-walkthrough.md` pointed at `packages/protect/src/ffi/*`,
  deleted by this PR. Replaced with a note rooting the doc's relative paths,
  and corrected the two other stale facts in the same block: the protect-ffi
  pin (0.24.0/0.23.0 → the actual 0.30.0) and row 1a's query-builder paths,
  which moved to stack-drizzle/stack-supabase in the #627 split.
- `.github/dependabot.yml`'s ignore-rule comment narrated the #673 incident
  in the present tense against a package that no longer exists. Kept the
  lesson, marked the package as removed, and stated why the rule still holds
  for the surviving consumers.
- `.github/workflows/tests.yml` looped over `packages/stack-forge`, which has
  never existed. The `[ -f ]` guard made it a no-op; dropped it.

`scripts/lint-no-dead-package-paths.mjs` fails CI on any `packages/<name>`
reference that doesn't resolve to a directory, across docs, .github, skills and
the root meta files. Design archives (docs/plans, docs/superpowers) and
CHANGELOGs are exempt — they record history, not the current tree. It catches
all three of the above; self-tests follow the existing scripts/__tests__
pattern.
…change

`classifyEqlDomain` now returns `3 | null`, so `listEncryptedColumns` can
never emit `version: 2` and the `c.version === 2` exemption in
`explainUnresolved` is dead. Removing it is provably behaviour-preserving:
a post-cutover v2 table (`<col>` carrying the v2 domain) now reaches
`explainUnresolved` with an EMPTY candidate list, which the
`candidates.length === 0` guard above already falls through on.

- Remove the branch; rewrite the doc comment and the stale v2
  parentheticals in `drop.ts` / `cutover.ts` to say why the post-cutover
  state now arrives as "no EQL columns".
- Rewrite the drop test that hand-built a `version: 2` candidate — a state
  resolution can no longer produce, so it only exercised the dead branch —
  to the state that actually occurs.
- Add unit tests pinning `explainUnresolved`'s contract, including that a
  candidate sharing the plaintext column's name still fails closed (the
  removed branch's only behaviour, and wrong at v3, which has no cut-over
  rename).
- Correct the backfill manifest comments: `null` now also means a legacy
  `eql_v2_encrypted` domain, so a v2 column backfilled from here on records
  no `eqlVersion` and reports no version in `encrypt status`. The live-domain
  fallback yields null for that case too. Existing manifests are unaffected.
  Noted in the changeset.
- Drop the PR-1 plan doc that landed in this PR's diff.
- MappedDecryptOperation.execute returns a fresh Result on the unknown-table path, so ops can't alias/mutate a shared failure object.

- Document the AnyV3Table cast in Encryption with a biome-ignore explaining the runtime isV3Only guard the compiler can't see.

- Restore CS_WORKSPACE_CRN in decrypt-audit-forwarding.test.ts afterEach so it doesn't leak env state across suites.

Verified the v2-read acceptance tests (integration/shared/v2-decrypt-compat) match the CI CS_IT_SUITE glob, so #1a/#1b run under test:integration.
…ent lock-context re-bind

- resolveDecryptResult: the inner comment and `logger.debug` message still
  said the typed client has no decrypt audit surface and told the reader to
  use `Encryption({ config: { eqlVersion: 3 } })`. Both shipped clients now
  carry `.audit()` on decrypt, so the branch only fires for a non-conforming
  custom client. Message rewritten to describe that, with a regression test
  asserting it names neither `eqlVersion` nor `EncryptionV3`. The same false
  claim had propagated to the resolve-decrypt test header, `dynamodb/types.ts`,
  a test literally named "though decrypt cannot carry it", and a comment in
  `stack-supabase` — all corrected.

- MappedDecryptOperation.withLockContext: chaining a second lock context onto
  an already-bound op silently dropped it. The wrapper always exposes the
  method, unlike the nominal path where it is absent after binding, so the
  re-bind type-checks. It now throws. Verified no internal or sibling call
  site chains after a positional bind (stack-supabase calls `decryptModel(row)`
  one-arg then chains, so its underlying op is unbound). Tests cover both
  `decryptModel` and `bulkDecryptModels`.

- Changeset bumped minor -> major. Making `Encryption({ schemas: [<v3>] })`
  return the typed client changes its return type AND adds `Date`
  reconstruction on the two-arg `decryptModel` for existing plain-`Encryption`
  v3 callers. The package already carried a major, so the released bump is
  unchanged — the changelog is just accurate now.

- skills/stash-encryption documented `decryptModel`/`bulkDecryptModels` as
  returning `Promise<Result<…>>` and said only encrypt-side ops are chainable.
  It ships in the `stash` tarball, so that was wrong guidance in customer
  repos. Updated, including the one-or-the-other lock-context rule.
…array

CodeRabbit review of the query-builder split. Six findings, all pre-dating
the refactor; these are the two worth acting on.

`single()`/`maybeSingle()` have always returned ONE object at runtime, but
returned `Self`, so the builder kept advertising the array shape it was created
with — `data` was typed `T[] | null` while holding a single row. Callers had to
launder it, and the test suite documented the lie in a comment while casting
through `unknown` to reach the row's fields.

Both now return `EncryptedSingleQueryBuilder<T>`, awaiting
`EncryptedSupabaseResponse<T>` (`data: T | null`) — which already covers the
zero-row case for `maybeSingle()` and the error case for both, so no separate
null modelling was needed. The impl class carries the awaited shape as a
`TData` parameter so the promise cannot keep advertising `T[]` after the runtime
has been switched to single-row mode; `returns<U>()` preserves that shape.

Filters and transforms are deliberately absent from the single-row builder,
matching supabase-js: applying one after `single()` would change the query the
single-row promise was made about.

Also drops two unnecessary `as unknown as T[]` bridges in query-results.ts
(`[] as T[]` and the bulk-decrypt map both compile directly).

Not acted on, with reasons:

- The missing `assertPostgrestCanQueryEncryptedOperator` in the not-filter
  branch is a false positive. The guard fires upstream in `assertTermQueryable`
  (`contains`/`matches` map to `freeTextSearch`), before encryption, and
  `supabase-v3-json.test.ts` already covers `.not(col,'contains')` and
  `.not(col,'matches')` under EQL 3.0.2. The suggested guard keys on
  `wasEncrypted`, which that path never reaches.
- Routing plaintext `in` arrays through `formatInListOperand` would change
  behaviour: `.filter()` is the raw escape hatch and forwards verbatim, as
  supabase-js does. The encrypted path only intervenes because it must encrypt
  element-wise.
- The `as never` on `bulkEncrypt` args and the `term.column` double assertion
  need `ScalarQueryTerm['column']` widened in `@cipherstash/stack` — a different
  package's public type.
CI runs against the PR MERGE commit, so it saw a file this branch never
had: `remove-v2` gained `utils-codegen.test.ts` (from the v3 domain-picker
work) after this branch was cut, and it pins the generated `stash init`
client to `extractEncryptionSchemaV3` / `@cipherstash/stack-drizzle/v3` —
exactly the two names this PR removes. Textually the merge is clean; the
conflict is semantic, which is why it only surfaced in CI.

Points those assertions at the collapsed root and adds the matching
negatives, so a regression to the `./v3` specifier fails here rather than in
a scaffolded project. Narrowed `utils-codegen-drizzle.test.ts` to what the
merged-in suite does not cover — `generatePlaceholderClient`, still
untested — and updated it to the new `ColumnDef` shape (`domain`, not
`dataType`/`searchOps`).
The wizard now scaffolds EQL v3 columns, so `drizzle-kit generate` emits
`ALTER COLUMN ... SET DATA TYPE eql_v3_<name>` — which Postgres rejects (no
cast from text/numeric to an EQL domain). The post-agent rewriter matched only
the single `eql_v2_encrypted` type, so those v3 statements slipped through
unrepaired and failed at migrate time.

Port the rewriter to the whole `eql_v3_*` concrete-domain family alongside
legacy `eql_v2_encrypted`, mirroring the sibling CLI fix (#693): every mangled
form drizzle-kit emits (incl. the 0.31.0+ `"undefined".` prefix and
schema-qualified pgSchema tables), near-miss flagging for `SET DATA TYPE ...
USING ...` it cannot safely repair, statement-breakpoints, and a clearer
data-destroying / empty-table-only warning that points populated tables at the
staged `stash encrypt` flow.

Database introspection (`isEqlEncrypted`) now recognises BOTH `eql_v2_encrypted`
and the `eql_v3_*` family as already-encrypted, matching migrate's
`classifyEqlDomain` v3 convention — so the agent won't scaffold over existing
encrypted data of either generation (v2 ciphertext stays valid and detected).

Add a rewrite-migrations test suite (adapted from the CLI's).
Address code-review feedback on the v3 migration-rewriter port.

**Sweep every candidate migration directory.** `rewriteEncryptedMigrations`
returned after the FIRST candidate that merely existed, even when that
directory contained zero matches — so an empty or already-rewritten
`drizzle/` sitting next to a project's real `migrations/` left those
migrations unrepaired, and they then failed at migrate time with the very
`cannot cast type ...` error the rewriter exists to prevent.

The comment justifying the early return ("running again on a different
candidate would double-transform already-rewritten SQL") was wrong on both
counts: distinct directories hold distinct files, and the rewrite is
idempotent anyway — a rewritten statement no longer contains `SET DATA
TYPE`, so neither the strict matcher nor the near-miss scan can match it a
second time.

Extract the sweep into an exported, log-free `sweepMigrationDirs(cwd, dirs)`
so it is directly testable without mocking @clack/prompts. It sweeps every
existing candidate and reports a directory that throws via `error` rather
than stranding the ones after it; post-agent keeps the reporting.

**Trim the near-miss statement preamble.** `NEAR_MISS_RE` opens with a lazy
`[^;]*?` whose only left boundary is the previous `;` — or the start of file
when there is none. The reported statement therefore dragged in every
comment and blank line since then, so a near-miss in a file opening with a
comment block was quoted back to the user with the whole header glued to its
front. Strip leading blank/comment lines (incl. `--> statement-breakpoint`)
so the statement reads as the offending statement alone. Detection is
unchanged; only the text shown to the user differs.

Applied to the `stash` CLI sibling too — it carried the identical defect and
its header mandates keeping the two in sync. The directory sweep does not
apply there: the CLI takes a single explicit `outDir`.

**Document the hardcoded `"public"."<domain>"`.** Behaviour unchanged and
not a regression, but worth stating: the domain qualifier is an assumption
(EQL installs into `public`), not something read back from the matched SQL —
the `schema` capture is the TABLE's schema and says nothing about where the
domain lives. Non-public domain installs would need it threaded in here and
in the CLI sibling. Already pinned by the existing pgSchema() test.

9 new tests across the two packages, each watched failing first.
…ypecheck

Closes the last open item from the #771 review — the 3 pre-existing
`AutoStrategy` tsc errors flagged as "would bite a future strict type gate".

**Root cause.** `@cipherstash/auth` uses conditional exports: `node` resolves
to `index.d.ts` (the full surface, including `AutoStrategy`), `default`
resolves to `wasm-types.d.ts` (which has no `AutoStrategy`). The wizard's
tsconfig sets `moduleResolution: "bundler"`, which does NOT include the `node`
condition, so tsc took the `default` branch and reported `AutoStrategy` as
missing on all three call sites — `agent/fetch-prompt.ts`, `agent/interface.ts`
and `lib/prerequisites.ts`.

Nothing was actually broken at runtime: the wizard is a Node CLI and always
loads the `node` branch. The types were simply resolved against the wrong
entry point.

**Fix.** Add `"customConditions": ["node"]`, matching what `packages/stack`,
`packages/prisma-next`, `packages/test-kit`, `packages/stack-drizzle` and
`packages/stack-supabase` already carry for the identical `protect-ffi`
conditional-export problem. `tsc --noEmit` now exits 0.

**Regression guard.** A type-level defect needs a type-level gate, or it comes
back silently — the wizard is built by tsup with `dts: false`, so the build
transpiles without ever typechecking, which is exactly why these three errors
sat in `main` unnoticed. Add a `typecheck` script and wire it into `tests.yml`
alongside the prisma-next gate (#684). Verified the gate fires: with
`customConditions` removed the step exits 2 on all three errors; restored, it
exits 0.

No changeset — `dts: false` means the published tarball is byte-identical.
Tooling only, no observable behaviour change.

Green: wizard 265 pass / 5 env-skipped, typecheck 0 errors; stash 770 pass /
53 files; `code:check` 0 errors.
Rebase fallout: main's `stash-indexing` skill (#773) and the indexing
section it added to `stash-drizzle` both import `encryptedIndexes` from
`@cipherstash/stack-drizzle/v3`, a subpath this branch removes when it
collapses `./v3` into the package root. Both files ship to customer repos,
so the stale specifier would not resolve in a freshly-initialised project.

Caught by `scripts/__tests__/no-removed-drizzle-surface.test.mjs`, the
guard this branch added for exactly this drift.
…sic to v3

PRs 3-5 of the EQL v2 removal froze the target names, but nothing
type-checks the artefacts that describe them, so two were left wrong.

`stash init` scaffolded `EncryptionV3` into customer source. That name is
a deprecated alias now, and the scaffold is a template literal, so only an
assertion can catch it — the codegen tests pinned the old form, so they
are flipped with negatives added, mirroring the drizzle precedent from
7b783ee.

`@cipherstash/stack/v3` exported `EncryptionV3` but not `Encryption`, so
the scaffold could not emit a single clean import. Re-export it — the
deprecation example in v3.ts already told users to import it from there.

`examples/basic` had not compiled since the removal deleted
`encryptedType` and the v2 `encryptedSupabase`. Ported to the v3 types.*
factories. Its Supabase branch is deleted rather than ported: it imported
a `contactsTable` that was never exported, so it was already dead before
this work, and examples/supabase-worker carries the Supabase story.

Root `build`/`test` filter to ./packages/*, so CI never compiled any
example and the breakage sat on a green board. Gate examples/basic
through a new turbo `typecheck` task so `^build` builds its deps first.
Verified the gate fails on the exact regression that shipped.
PRs 3-5 froze the API names but the prose describing them was not swept,
so the shipped guidance told users the opposite of what the code does.

The worst of it is in skills/, which ship inside the stash tarball and get
copied into customer repos: stash-encryption and stash-cli both described
`encryptedSupabase` as the legacy EQL v2 wrapper. It is the EQL v3 factory
— the v2 wrapper was deleted in #769. stash-drizzle taught `EncryptionV3`
as the canonical client, contradicting stash-encryption, which correctly
calls it deprecated.

Also corrects the @cipherstash/stack README, the npm landing page, which
claimed DynamoDB "still requires v2" and pointed at #657#768 removed the
v2 write overloads and #657 is closed. The root README quickstart still
taught the v2 chainable builders as the primary example.

Reframes the "Legacy: EQL v2" sections to say what is actually true now: v2
is a read path, not an authoring surface. Left alone deliberately: the v2
CLI/database sections in supabase-sdk.md and the v2 read path in
stash-dynamodb — both accurate today, and the CLI text moves with the SQL
teardown.

`export { Encryption }` in v3.ts is ordered after the `export *` so Biome's
organizeImports is satisfied without detaching the comment from its subject.
…old-examples-meta

feat(cli,examples,docs)!: de-suffix the init scaffold, rewrite examples/basic to v3, sweep the meta (EQL v2 removal PR 7)
…LTER

The ALTER-to-encrypted matcher was comment-blind and its replacement is
multi-line, so a commented-out statement kept the `-- ` on line 1 only and
emitted a live DROP COLUMN. It also captured only the target type, so changing
an already-encrypted column's domain produced the same ADD+DROP+RENAME over
ciphertext. Both copies of the rewriter are fixed; skipped statements now carry
a reason, EACCES on a migration dir is reported, and the wizard's run-migration
prompt defaults to No after a rewrite.
tobyhede added 12 commits July 27, 2026 14:31
…exposed

The reviewer's two findings were both already fixed at bbade2c — this adds
the guards that would have caught them, plus two accuracy fixes.

`typecheck:scaffold` declares `dependsOn: ["^build"]`, so turbo has to be the
thing invoking it. #787 first added it as a bare `pnpm --filter stash run
typecheck:scaffold` and CI went green anyway: an earlier step in the same job
happened to build the workspace via its own `^build`. Those earlier steps read
as independent guards for other packages, so they look freely removable —
delete one and the scaffold step fails `TS2307`, which reads as "the scaffold
is broken" rather than "you skipped the build". Nothing caught that.

scripts/__tests__/workflow-turbo-build-deps.test.mjs derives the protected set
from turbo.json (every task declaring `^build`) rather than hardcoding names,
and asserts tests.yml routes them through turbo. Mutation-tested against three
regressions: reverting the step to the bare form, deleting the step outright,
and adding a new bare build-dependent step — each fails with a message naming
the step and the fix. Two pre-existing bare `typecheck` invocations
(prisma-next, wizard) carry the same latent trap; they are recorded in
KNOWN_BARE rather than silently skipped, and a fourth test fails if an entry
goes stale so the list gets worked down instead of accumulating.

turbo.json is JSONC, so reading it needs the string-aware comment stripper
#782 wrote for `turbo-skills-inputs.test.mjs` — a naive regex eats the `//` in
`"$schema": "https://turbo.build/schema.json"`. Rather than hand-copy 30 lines
of parser into an adjacent file, that function moves to
`scripts/__tests__/lib/read-jsonc.mjs` and both guards import it.

Two accuracy fixes:

- vitest.config.ts claimed the suite's residual `@cipherstash/stack` coupling
  runs through `migrate/src/backfill.ts`. That is only one of two routes:
  `init/lib/__tests__/introspect.test.ts:1` imports `@cipherstash/stack/eql/v3`
  directly, never touching migrate. As written, someone could decouple
  backfill.ts and expect the suite to go standalone. It would not.

- cli-vitest-alias.test.mjs called `.endsWith()` on every alias value, so
  Vite's array form (`[{find, replacement}]`) would throw an opaque TypeError
  from a helper — reading as "the guard is broken" rather than "the config
  changed shape". It now fails cleanly, naming the offender, and says to update
  the guard rather than delete it.

No changeset: repo tooling and a comment, no published surface touched.
Follow-up to the #787 review. Five gaps, none raised by the reviewer, all
found by auditing what the PR's own tests actually pin.

A guard clause that no test held. `context-placeholder.test.ts` named a case
it did not construct: its fixture returned `getEncryptConfig: () => ({})`, so
`configuredTables` was empty and the guard short-circuited on the arity check
before the sentinel-name comparison ever ran. Deleting
`configuredTables.length === 1` left all 891 tests green — measured, not
argued. The fixture now declares the sentinel FIRST alongside a real table,
because the guard indexes `[0]`, and both tests spy `process.exit` so a
regression fails as an assertion instead of killing the worker.

One guard, not two. The same refusal was hand-copied into `loadEncryptConfig`
and `loadEncryptionContext`, sharing only the constant — and it had already
drifted: on a client whose `getEncryptConfig()` returns nothing, `db push`
named the cause while `encrypt backfill` fell through to `Table "..." was not
found`, the symptom-not-cause message the guard exists to replace. Both now
call `requireUsableEncryptConfig`, pinned by a parity test asserting the two
seams emit byte-identical text.

The resolver/command seam, tested. `encrypt-v3.test.ts` stubs
`resolveColumnLifecycle` and hand-writes its return value; `resolve-eql.test.ts`
proves the pure-v2 shape produces that value. Nothing ran the real producer
into the real consumer. The new composition test mocks no resolution at all —
only pg, fs, prompts and the effectful migrate helpers — so the v2 verdict is
derived from a real manifest hint and a real catalog read. Mutation-checked:
the two #787 defences are independent, so removing either alone is masked by
the other; removing both fails this test and nothing else. Its sibling types
are now imported rather than re-declared, so the shape cannot drift silently.

The workflow guard, applied to all workflows. It scanned only `tests.yml`
while enforcing a rule its own docblock generalises. Widened to all 13, which
surfaced six real bare invocations of `^build`-dependent tasks — five
`test:integration`, plus a `test:e2e` that `rootScriptDelegatesToTurbo` was
wrongly exempting (a `--filter` invocation runs the PACKAGE's script, so the
root script's delegation says nothing about it). All six routed through turbo
with `--env-mode=loose`: turbo defaults to strict and would otherwise withhold
the step env these suites need, which `passThroughEnv` could only fix by
enumerating ten variables across workflows this change cannot execute.

Also corrects `AGENTS.md`'s path to `introspect.test.ts` and states the
`stackSourceAlias` collision symmetrically — spreading it either way breaks
one package's `'@/'`. Its "fails 10 files" figure was re-measured by deleting
`packages/stack/dist`: exactly 10.

`stash` unit suite 896/896, scripts 112/112, typecheck:scaffold clean, Biome
0 errors.
fix(cli): mixed-table encrypt lifecycle, and a stash init scaffold that compiles (#772 review findings 6, 7)
…v3] prefix

Both assertions on the unrecognised-builder path matched `/\[supabase v3\]/`,
which 32 messages across this package satisfy — two of them thrown by
`ColumnMap` itself. Neither test could tell which error it caught.

Demonstrated rather than assumed: making `assertNoPropertyDbNameCollision`
throw unconditionally, so the fail-closed probe is bypassed entirely, left both
tests GREEN. (Deleting the probe outright does turn them red — construction
then succeeds and nothing throws. It is the bypass, not the deletion, the loose
matcher was blind to.) `supabase-v3-wire.test.ts` is the more serious of the
two: it guards the harm — no query string emitted — not just the mechanism.

Both now pin the identity clause and both interpolations. Stops short of the
advisory tail, which carries six unescaped `/` and would make the matcher a
syntax error rather than a stricter test. Re-running the same bypass mutant now
fails both.

Also corrects a misattributed citation the assertion's comment repeats: the v2
`build()`/`getName()` at `schema/index.ts:257,264` are `EncryptedField`'s.
`EncryptedColumn` opens at :275, so its own are at :442,449.
…t a time

`isV3ColumnLike` had no test that could distinguish a four-probe gate from a
two-probe one. Every builder reaching it either satisfied all four probes (the
40 catalog domains, the wasm-authored double) or missed two at once (the v2
stub) — so no fixture was ever one member away from passing.

Measured with a mutation harness: deleting any single conjunct, weakening any
single `typeof` to `true`, or dropping the object/null guard entirely left the
whole 471-test suite green. Nine surviving mutants of those enumerated. (The
`'x' in builder` half of each conjunct is an equivalent mutant — for any
non-exotic object the `typeof` half subsumes it — so those four are correctly
left alive.)

The new cases are each one member apart from a conforming builder, so each
fails if and only if its own probe goes. All nine die, and precisely: deleting
a conjunct fails exactly its two tests, weakening a `typeof` exactly its one,
and the two halves of the object/null guard are split so a mutant that drops
one half names which half it dropped (1 test vs 4).

Also covered: prototype-borne members (the real `Encrypted*Column` classes
carry all four on the prototype, so `in` must not become `Object.hasOwn`), and
a real `encryptedColumn().equality()` v2 builder rather than a hand-rolled
stub — the case the four-probe design exists for. Neither kills a mutant the
others miss; both are kept because they turn a 335-test avalanche and an
opaque `true !== false` into one precise, self-describing failure.

`isV3ColumnLike` is exported to allow this. `column-map.ts` is not re-exported
from `src/index.ts` and the package publishes only `.`, so the published
surface is unchanged: the built `dist/index.d.ts` and `.d.cts` contain no
mention of it, and both bundles' runtime exports remain exactly
`encryptedSupabase, encryptedSupabaseV3`.
…fect

The process-free realm harness rejects every bare specifier, which is stricter
than the resolution any real runtime performs. It passes today only because
everything the adapter-kit graph reaches is bundled via tsup `noExternal` —
load-bearing, not incidental: the chunk adapter-kit imports carries inlined
`evlog`, so without that entry it would emit a bare import. `@cipherstash/auth`
and `@cipherstash/protect-ffi` stay external and are simply unreachable from
that graph; if either became reachable, the gate would fail with a message
reading like a self-containment defect.

The header and the throw now say what the constraint rests on and what to do
about it. No behaviour change — verified by pointing the harness at
`dist/index.js`, whose bare `@cipherstash/auth` import produces the reworded
error.

`turbo.json`'s override also replaced the root `dependsOn` rather than
extending it, resolving `test` to `["build"]` and dropping the inherited
`["^build"]`. This is defence-in-depth rather than a live bug: ordering
survives transitively via `build`'s own `^build`, as `packages/prisma-next`
demonstrates with the identical override and real workspace deps. Made
explicit anyway, matching how the root spells `test:e2e`; prisma-next is left
alone deliberately, since the same reasoning says it is not broken either.

Lastly, `logger-edge-safety.test.ts` described a `bundling-isolation.test.ts`
that spawns this harness against the WASM entry as though it existed. It does
not yet — reworded to future tense, and the turbo/bare-invocation skip
asymmetry noted where a reader will hit it.
A v2 `EncryptedTable` is structurally identical to a v3 one — same `tableName`,
same `columnBuilders` — and only `buildColumnKeyMap()` tells them apart. So
nothing that inspected shape caught the swap, and TypeScript only helps callers
who are actually type-checking.

Two paths, two raw TypeErrors, both naming an internal method rather than the
version mismatch behind it:

- `encryptedSupabase({ schemas })` sailed past the record-key check and died in
  `verifyDeclaredSchemas` as `builder.getEqlType is not a function`. This is the
  reachable one — the realistic mistake is migrating from v2 and passing the old
  `schemas` through.
- Constructing the query builder directly died one layer down in `ColumnMap`, on
  the constructor's very first statement, as `table.buildColumnKeyMap is not a
  function`. Not reachable from the factory today (`mergeDeclaredTables` rebuilds
  a fresh v3 table), so this half is defence-in-depth at the seam — symmetric
  with the column-level fail-closed guard already there, which cannot cover it
  because it runs after the constructor has already crashed.

Both now name the table and state the fix. Each guard is load-bearing: removing
either puts its original TypeError back.

The check routes through `hasBuildColumnKeyMap` rather than a second
hand-written spelling, per that function's own doctrine — "a second hand-written
spelling of the check is how a v2 envelope eventually gets built for a v3 table
once the marker drifts". It is re-exported from `@cipherstash/stack/adapter-kit`,
the first-party adapter seam, and deliberately NOT promoted to `./types`:
deciding which wire version a table targets is adapter plumbing, not end-user
API. Verified edge-safe — `dist/adapter-kit.js` still evaluates in a
process-free realm with no bare specifiers (`OK 14 exports`).

`skills/stash-supabase` gains the error under "Legacy: EQL v2", where a
migrating reader will hit it.
… the package-path linter sound

Finding 14 — decryptModel/bulkDecryptModels return an
AuditableDecryptModelOperation: thenable, with .withLockContext() and .audit().
Three sites in skills/stash-encryption and four in packages/stack/README.md
said the opposite ("a plain Promise<Result<...>>", "no .withLockContext() to
chain"), steering agents away from the very .audit() chain the audit-on-decrypt
changeset advertises. The skill contradicted its own reference table, which was
already right — 8b74430 fixed that table and nothing else, despite a commit
message claiming otherwise.

Both files ship: the skill inside the stash tarball and thence into customer
repos via installSkills(), the README inside the @cipherstash/stack tarball.
The equivalent statement about the WASM entry is CORRECT and deliberately
untouched — that client really does return a bare promise.

Also `protectOps.eq` in the setup prompt stash init writes for coding agents.
One occurrence repo-wide, and no such export exists; the real API is
createEncryptionOperators(client), conventionally `ops`.

Finding 15 — the package-path linter had a false positive and a false negative,
both fixed with the fixture-driven self-tests the suite already uses:

- The name capture had no right anchor, so a sentence-final `packages/stack.`
  swallowed the period and reported a LIVE package as dead. Uppercase was also
  excluded from the class, so a capitalised name was never checked at all.
- livePackages came from readdirSync, i.e. the working tree. Deleting a package
  leaves dist/ and node_modules/ behind, so every reference to it kept passing —
  the exact case the linter was commissioned to catch, silently unenforced on
  any checkout that had built the package. Now derived from `git ls-files`.
  Deliberately not "has a package.json": packages/utils has none and is live.
- scripts/ was not scanned, so the linters never checked themselves. Adding it
  immediately surfaced a dead allowlist entry in lint-no-hardcoded-runners for
  a path that never existed in git history. Self-test fixtures stay exempt —
  they must name dead packages.

Severity note for the record: CI was never affected (fresh checkout, caching
disabled), and main carries no required status checks — this was a local
developer papercut plus a real soundness hole, not a broken build.

Finally, resolves two changesets that contradicted each other in the same
release: dynamodb-eql-v3 claimed "EQL v2 tables continue to work unchanged" and
"no existing caller needs to change" while stack-dynamodb-v2-write-removal
announced the v2 encrypt overloads as removed. The code sides with removal;
the claims are now scoped to the decrypt path.
…tch their own rot

Follow-up to the #772 review findings, addressing what the review of that
review turned up.

`stash init`'s setup prompt named `createEncryptionOperators` unconditionally.
That symbol is exported by `@cipherstash/stack-drizzle` alone, so a Supabase or
Prisma Next project was sent after a package that is not in its dependency
tree — the previous `protectOps.eq` was wrong for everyone, this was wrong for
three integrations out of four. Step 5 now branches through
`queryOperatorGuidance()`, following the `migrationCommands()` pattern already
in the file: `ops.eq` for Drizzle, the `encryptedSupabase` wrapper's own
filters for Supabase, the `eql*` column operators for Prisma Next, and
`client.encryptQuery(...)` for plain Postgres — which is also pointed at
`stash-encryption`, since it is the default integration and installs no
integration skill, making "see the integration skill" a dangling pointer.

The package-path linter reported an untracked-but-present package as "does not
exist" — finding 15's false alarm pointed the other way, at a directory sitting
right there on disk. The live set now unions `git ls-files --others
--exclude-standard`. `--directory` is deliberately not passed: it collapses an
all-ignored directory to one entry and would resurrect exactly the `dist/`-and-
`node_modules/` shells the linter exists to catch (verified both ways). git
failing now exits 2 with an actionable message instead of a raw ENOENT stack
trace and exit 1, which was indistinguishable from a genuine lint failure, and
an empty live set refuses to run rather than flagging every reference at once.

The `scans scripts/ but not its fixtures` test asserted the repo was clean —
byte-for-byte what the suite's first test already asserted, and passing whether
or not `scripts` was in TARGETS. It now plants an offender in the scanned
directory. Verified by mutation: it dies when `scripts` is dropped, as the
untracked-package test dies without the union and the git-failure test dies
when the exit code is flipped.

The runners linter now requires each allowlist entry to exist and to still
contain an unexcused `npx` literal. It immediately found a second stale entry
the sibling linter structurally cannot see, since it only matches the
`packages/<name>` shape: `setup-prompt.ts` has had no `npx` since its switch
moved to `utils.ts`. Removed.

Also corrects two claims: the test comment saying `packages/drizzle` never
existed in git history (it was added speculatively by c671560, became
load-bearing 31 minutes later with 9d259e6, and rotted when 413ca39 deleted
the package), and the `AnyEncryptedTable` doc comment still promising v3 is
"purely additive and no existing caller has to change" — true of decrypt, false
of encrypt since the v2 write overloads were removed.
… repeats

Remediation for the two review rounds on #789, plus what verifying them turned
up in the same code.

**The linter fix the review asked for twice.** `livePackages` moving from
`readdirSync` to `git ls-files` was the highest-value change in the PR and had
no test that pinned it. It still doesn't fail under the revert anyone would
actually write: keeping the git call and unioning the filesystem back in passes
every existing test while fully restoring the false negative. The new fixture
builds the discriminating case — a package deleted from git whose gitignored
`dist/` and `node_modules/` shells survive on disk — and is the only test that
fails under both a naive `readdirSync` revert and that hybrid. The
`git status` guard is scoped to the probe, not to `packages/`, so an unrelated
uncommitted file doesn't turn it into an assertion about nothing.

**A missing target no longer passes in silence.** A linter whose entire job is
catching dead paths in configuration skipped dead paths in its own: rename a
target and it dropped out of coverage forever, green. Its sibling already
exits 2 for a stale allowlist entry. Both now do, and both report a target
outside the repo by name instead of as a `../../../../..` chain climbing out
of the root — the review's cosmetic nit, which turned out to sit in both files.

**Every step that names an integration-specific API now branches.** Step 5 was
fixed for this; steps 1, 2 and the read-path step were not. A Prisma Next
project was sent at `types.*` / `encryptedTable` — the client `stash schema
build` explicitly refuses to scaffold for it — and a plain-Postgres project was
pointed three times at "the integration skill" it never gets installed.
`encryptQuery` is shown taking the schema objects rather than an
object-shorthand that read as three required strings. `queryOperatorGuidance`
is a switch with a neutral default rather than an if-chain ending in Drizzle's
answer: tsup transpiles without type-checking and this package has no
typecheck script, so nothing would have caught a fifth integration inheriting
it.

One test asserted nothing: it scoped to a `#### Encryption cutover` heading
that does not exist, and `substring(-1)` returns the whole document.
…default

`stash plan` writes one of three templates. Two of them — the cutover plan and
the `--complete-rollout` plan — described the EQL v2 rename swap as the only
way to switch reads: "a single transaction renames `<col>` → `<col>_plaintext`
and `<col>_encrypted` → `<col>`", with the steps after it referring to
`<col>_plaintext` as though the rename had certainly happened.

On EQL v3, which is the default, none of that is true. `stash encrypt cutover`
refuses a v3 column outright — "Cut-over is not applicable to EQL v3 columns …
there is no rename step" (`encrypt/cutover.ts:119`) — and refuses entirely on a
v3-only database, where the `eql_v2_configuration` relation it needs does not
exist (`:142`). So on a default install the agent was being asked to draft a
plan around a command that will decline to run, and to name a
`<col>_plaintext` column that will never exist.

The implement prompt in this same file already splits the two correctly. This
copies that split into the templates that were missing it. The EQL version is
per-column — one database can hold both — so the templates tell the agent to
establish it per column (`stash encrypt status`) rather than deciding once for
the whole plan; a context-level flag would have been wrong for a mixed
database.

Also: the "this column is already encrypted" stop-and-ask keyed on the
`eql_v2_encrypted` udt alone. v3 columns carry `eql_v3_*` domains, so on the
default path the check could never fire. The agent reads the schema itself, so
naming both is sufficient here.

`introspect.ts:88` has the same v2-only assumption in code
(`isEqlEncrypted: row.udt_name === 'eql_v2_encrypted'`). Left alone
deliberately: it decides which columns `stash init` pre-selects as
already-managed, so widening it changes setup behaviour and wants its own
tests rather than riding along here.
`introspectDatabase` marked a column as CipherStash-managed only when its udt
was exactly `eql_v2_encrypted`:

    isEqlEncrypted: row.udt_name === 'eql_v2_encrypted'

v3 is the default generation and its columns carry per-domain types —
`eql_v3_text_search`, `eql_v3_integer_ord`, and so on — so on the default path
this was false for every encrypted column. The consequences are all in the
column picker: the table hint never said "N already encrypted", the
pre-selection notice never fired, and each encrypted column was displayed with
its plaintext `dataType` and left unticked. An encrypted column presented as
plaintext invites the user to encrypt it a second time, which is the direction
of wrongness that costs data rather than time.

`packages/wizard` already had the correct predicate, with the reasoning
written down (`isEqlEncryptedDomain`, `wizard-tools.ts:167`). This mirrors it
rather than inventing a second answer, and exports it so it is testable — the
old inline comparison sat inside a function that needs a live pg connection,
which is why nothing covered it.

Deliberately not `classifyEqlDomain` from `@cipherstash/migrate`, despite the
dependency already being present: that answers "which generation authors this"
and returns null for `eql_v2_encrypted`, since v2 is no longer authorable. The
question here is "is this encrypted at all", and v2 answers yes.

The per-column hint was also the hardcoded string `eql_v2_encrypted` for any
encrypted column, which mislabels a v3 column with a domain it does not have.
It now reports the column's actual udt, and the pre-selection notice lists the
domains it found instead of naming one it may not have seen.

Checked the rest of the CLI for the same assumption: every other site either
already handles both generations (`rewrite-migrations.ts` matches
`eql_v2_encrypted|eql_v3_[a-z0-9_]+`, `db-readers.ts`, `backfill.ts`) or is
legitimately v2-only (`cutover.ts`, the installer's CREATE TYPE permission
messages). Introspection was the sole outlier.
tobyhede and others added 13 commits July 28, 2026 08:59
fix(skills,stack,scripts): shipped decrypt guidance, and a sound package-path linter (#772 review findings 14, 15)
fix(stack,stack-supabase): edge-safe shared seam — process-free adapter-kit + structural v3 columns
…dicates and the WASM entry (#777)

* feat(cli): add the stash-sql and stash-edge skills — raw-SQL predicates and the WASM entry

Closes #754.

No shipped skill covered the integrations that don't use an ORM: hand-written
SQL over `pg` / `postgres-js`, and the WASM entry on Deno / Supabase Edge
Functions / Workers. Grepping the skills `stash init` installs for
`postgres-js|::jsonb::eql|sql.json|query_text_search` returned one hit, in an
unrelated code comment — so an integration on that path had to recover the
binding surface from `dist/*.d.ts`, the Postgres catalog, and experiment.

Two skills rather than one, per the scope question in the issue: the raw-SQL
predicate cookbook serves a supported plain-Node path (`hono-pg`) with no edge
or WASM involvement, so scoping it under an edge-named skill would leave that
surface uncovered.

skills/stash-sql — the predicate matrix (which of `=`, `<>`, `<`, `>=`, `@@`,
`@>` each column domain accepts, against which `eql_v3.query_*` operand),
storage-vs-query payload shapes, per-driver parameter binding, and recipes for
equality / free-text / range / ORDER BY / JSON containment / field selectors.

skills/stash-edge — import specifier per runtime, the four mandatory `CS_*`
variables and minting them with `stash env`, how the WASM client surface
differs from the native typed client, and why a schema module can't be shared
across the two entries.

Both carry the credential-identity rule, also folded into stash-cli (under
`env` and `encrypt backfill`, where it bites) and stash-supabase: EQL index
terms derive from the ZeroKMS client key, so rows written under one credential
and queried under another decrypt correctly and never match a query.

Three claims were corrected against a live EQL v3 3.0.2 install rather than
carried over from the issue:

- The binding rule is driver-specific. On postgres-js a bare object fails to
  INSERT and `JSON.stringify(...)::jsonb` fails the domain CHECK; only
  `sql.json()` works in both positions. On `pg` all three forms work.
- A bare-`jsonb` operand does not fall through to native jsonb semantics — EQL
  defines `jsonb` overloads that coerce to the *storage* domain, which requires
  the ciphertext key `c`, so a query term without the domain cast raises a
  CHECK violation rather than silently matching nothing.
- The schema type incompatibility reproduces in both directions, not one.

Also fixes the wasm-inline module JSDoc, which passed
`OidcFederationStrategy.create(...)`'s Result straight to
`config.authStrategy` without unwrapping — the same JSDoc the raw-SQL surface
was being reverse-engineered from.

SKILL_MAP (CLI + wizard) installs both for `postgresql` and `supabase`;
Drizzle and Prisma Next get cross-links from their own skills instead.

* refactor(skills): rename stash-sql to stash-postgres

The skill is Postgres-specific throughout — encrypted columns are Postgres
domains over jsonb, the driver rules cover pg and postgres-js, and the drift
check reads information_schema. Nothing in it generalises to another SQL
dialect, so the name overclaimed.

Renames the directory and frontmatter name, updates the SKILL_MAP entries in
both the CLI and wizard installers, the setup-prompt purpose line, the
cross-links in the five sibling skills, and the AGENTS.md skill list and
change→skill map.

The skill has not shipped yet (no CHANGELOG entry — only the pending
changeset), so no installed .claude/skills/ directory carries the old name
and the existing changeset is amended in place rather than adding a second
one. The new name also matches the CLI's `postgresql` integration key, which
is the no-ORM path that installs it.

* docs(skills): scope stash-postgres against Proxy and cite EQL upstream

The skill covered client-side encryption over a direct connection without
ever saying so. That is precisely the fork `stash init` asks about and stores
as `usesProxy`: a CipherStash Proxy user writes plaintext SQL and Proxy
encrypts on the wire, so every rule in the skill — mint a term with
encryptQuery, cast to eql_v3.query_*, bind with sql.json — is wrong for them,
and nothing said which world they were in. Adds a scope callout up front, and
notes that Proxy's config lifecycle (stash db push into eql_v2_configuration)
is the EQL v2 one, while this skill is v3 where there is nothing to push.

The operator matrix also read as if the client library defined it. It does
not: the domains, operators, CHECKs, and extractors all come from the EQL
bundle developed at cipherstash/encrypt-query-language and shipped as
@cipherstash/eql. A reader hitting an operator the matrix does not list had
nowhere authoritative to check and no idea where to file it. Adds a
provenance section with the version check (SELECT eql_v3.version()), ties the
"operator does not exist" troubleshooting entry to it, and adds upstream
links for EQL and Proxy to the Reference list.

Also fixes the frontmatter description, which now states the direct-connection
assumption so skill selection reflects it.

* docs(skills): drop the usesProxy pointer from the stash-postgres Proxy callout

The callout told readers to check `usesProxy` in `.cipherstash/context.json`
to learn which path they were on. That flag and field are being removed (the
CLI never used the value for anything beyond gating a wizard `stash db push`
step), so the pointer would have shipped stale. The Proxy scoping itself
stands — it is the substance of the callout.

* docs(skills): separate the database domain from its language mappings

"Assume `users.email` is a `types.TextEq` column" named the schema builder as
though it were the column's type. It isn't: `types.TextEq` is a TypeScript
factory in this repo, while the column is `public.eql_v3_text_eq`, a Postgres
domain over jsonb. The distinction is the one the whole section turns on, so
opening by conflating them was the wrong footing.

Now opens with the database type and states that the domain is the authority —
what the CHECK enforces and what decides the operator set — then lists the
three things that map onto it: the `types.*` schema factory, the `TextEq` /
`TextEqQuery` wire types from `@cipherstash/eql`, and the `eql_v3.query_*`
operand domain.

Records where the TypeScript types come from: generated with the JSON Schemas
from the Rust `eql-bindings` crate, with the SQL bundle built from the same
commit, so the wire shape and the domain CHECK cannot drift. Verified against
@cipherstash/eql 3.0.2 — `TextEq` is `{ v, i, c, hm }` and `TextEqQuery` is
`{ v, i, hm }`, so the generated pair is exactly the storage-vs-query split
this section goes on to explain.

* docs(skills): send agents to EQL for the current type surface

The domain and operator tables read as authoritative. They are not — they are
a snapshot of a versioned surface defined in `encrypt-query-language`, and
nothing in CI catches them drifting when the `@cipherstash/eql` pin moves.

Marks them as a snapshot at the point of use, and adds a ranked list of places
to confirm current types: the EQL skill first (it ships beside the bundle it
documents, so it tracks the installed version), then the generated
`@cipherstash/eql` TypeScript types, then the install SQL's CREATE OPERATOR
statements, then `SELECT eql_v3.version()` against the database. The middle
two need only `node_modules` — the `stash` CLI depends on `@cipherstash/eql`
at an exact pin — so the check costs nothing and needs no connection.

The EQL skill does not exist yet (cipherstash/encrypt-query-language#422), so
it is referenced by role rather than by a name that may not survive review,
and every other rung of the ladder works today without it.

* docs(skills): stop teaching native-entry bundling in the edge skill

`stash-edge` named `@cipherstash/protect-ffi` five times. Three of those were
configuration for the runtime this skill exists to steer readers away from:
the entry-choice table's Node row explained how to externalise it in Next, and
the Workers section named it again to say the config was irrelevant. A reader
here is not configuring a Node server, so that is the wrong skill to carry it
— the bundling guide already owns it, and is now linked instead.

Two mentions stay, for different reasons:

- The `When to Use` trigger keeps the name. Deploying the default entry to an
  edge runtime fails with `Cannot find module '@cipherstash/protect-ffi'`, and
  that string in a deploy log is how an agent should route itself here.
- The Deno note keeps `--allow-ffi`, which is Deno's own permission flag
  rather than the package — a diagnostic that the wrong entry resolved.

The intro now says "a Node-API native module" without naming the package; the
contrast it draws does not need it.

* docs(skills): correct the lock-context explanation in stash-edge

DEPENDS ON #797 — do not merge #777 until the wasm-inline
`.withLockContext()` lands. This describes the post-#797 surface.

The skill said "Identity-bound encryption is configured, not chained" and
presented `config.authStrategy` as the replacement for `.withLockContext()`.
That conflates two orthogonal mechanisms. An auth strategy decides who the
client is; a lock context decides which key the value is encrypted under. You
need both, and a strategy alone writes data that is not identity-bound.

What actually changed on the native entry was authentication: per-operation
CTS tokens were removed in protect-ffi 0.25, so `LockContext.identify()` is
deprecated and the strategy handles token acquisition. `.withLockContext()`
never went anywhere. The WASM entry picked up the authentication half and not
the key-binding half, and the skill then described the gap as though the first
subsumed the second.

Rewrites the section as two numbered steps, adds the encrypt-side example, and
states the symmetry rule — the same claim must be supplied on decrypt, and a
mismatch surfaces as a failed decrypt rather than a key error.

The code example assumes the chainable form, matching native. If #797 lands a
per-call option instead, this example must change before #777 merges.

* docs(skills): address CodeRabbit review on #777

Five of six findings applied; the sixth skipped with a reason.

- `stash-drizzle`: `db.execute(sql\`…\`)` used backslash-escaped backticks
  inside an inline code span. Escapes do not work inside code spans, so the
  backslashes rendered literally. Switched to a double-backtick span, which is
  the actual mechanism for embedding a backtick.
- `stash-edge`, `stash-postgres`: language tags on three unlabelled fences —
  a TypeScript error, the domain-name mapping, and a Postgres CHECK-violation
  message. All `text`.
- `stash-postgres` frontmatter: the operator list omitted `<=` and `>`, which
  the predicate matrix includes. The description is the skill-selection
  surface, so an incomplete list there is worse than verbose.
- `stash-postgres` Query Recipes: the recipes dereference `.data` without
  guarding `.failure`, and agents copy from them. Rather than adding the guard
  to six short recipes, the section now states the omission up front and shows
  the guard once — unguarded, the failure surfaces as a domain CHECK violation
  rather than as the encryption error it is, which is the confusing outcome
  this skill exists to prevent.

Skipped: adding a "the docs site needs a corresponding update" note to the
changeset. Changesets become CHANGELOG entries, which are customer-facing;
an internal follow-up action does not belong there. Tracked separately.

Two other unlabelled fences exist in `stash-encryption` and `stash-cli` but
predate this branch and are untouched by it.

* docs(skills): reconcile the WASM entry surface after the rebase

Rebasing onto current `remove-v2` pulled in a `stash-encryption` subpath row
asserting the WASM entry has "no `.audit()` or `.withLockContext()` chaining",
which contradicts `stash-edge` — the two ship in the same tarball, so an agent
reading both gets opposite answers about the same entry.

`.withLockContext()` stays described as chainable (this branch documents the
post-#797 surface, per 9a57509), so the `stash-encryption` row now states only
the `.audit()` difference and defers identity-bound encryption to `stash-edge`.
The `.audit()` claim is the one that survives independently: the WASM
operations return plain Results rather than thenable operations
(`wasm-inline.ts:664`). The changeset carried the same stale "no
`.withLockContext()`" phrasing and is corrected with it.

Also documents a real native/WASM divergence the difference table was missing,
raised in review: the model decrypt helpers. Both entries take the table as the
second argument, but the native typed client *also* carries a one-arg
`decryptModel(model)` overload — the read path for rows whose table isn't in
the schema set, legacy EQL v2 above all (`encryption/v3.ts:121-134`). The WASM
entry has no such overload; `requiresTableForDecrypt` is declared true and the
call throws without a table rather than returning a `{ failure }`
(`wasm-inline.ts:674-685`). A wrapper written against the one-arg form
therefore compiles on one entry and breaks on the other.

Still blocked on #797 — `.withLockContext()` does not exist on the wasm-inline
client at any published `protect-ffi` (0.30.0 is the pin and npm `latest`; its
WASM typings carry no lock-context declarations). protectjs-ffi#143 has since
landed the FFI half, as a per-call `lockContext` option field rather than a
chainable method, so the chainable form here remains a bet on how #797 wraps
it — the review condition 9a57509 set out.

* docs(skills,stack): document the wasm entry as having no lock context

Unblocks this PR from #797 by describing the surface that exists today rather
than the one #797 will create. `.withLockContext()` is absent from the
wasm-inline client on every branch in this repo, and no published protect-ffi
adds it — the FFI exposes `lockContext` as an option field, never a chainable,
so the chainable form was always going to be a stack-side wrapper that has not
been written.

What is kept from 9a57509 is the part that was right and is the reason #793
exists: an auth strategy and a lock context are orthogonal. A strategy decides
who the client is; a lock context decides which key the value is encrypted
under. The earlier text presented the first as a replacement for the second.
That claim is now stated as the mistake it is, in both the skill and the source
comment it came from, instead of being silently deleted.

The two consequences are silent, so both are now written down rather than left
to surface as a failed decrypt:

- Values written from the edge entry are encrypted under the workspace key even
  when the client is authenticated as an end user.
- The edge entry cannot read anything the native entry wrote under a lock
  context, since decrypt requires the same context. A read split between the
  two entries, on top of the schema nominal-typing incompatibility.

`wasm-inline.ts` also records that the binding already accepts a lock context
on both paths, so closing #797 is plumbing rather than a new capability — and
that it wants a live round-trip test first, since a wrong or missing claim
surfaces as a failed decrypt rather than a key error.

The `stash-encryption` subpath row regains the `.withLockContext()` fact the
rebase reconciliation had removed, now that the two skills agree again.
… stage 1)

First stage of #798. Introduces the seam; changes no public behaviour.

`CryptoBackend` (`encryption/backend.ts`) is a 1:1 restatement of the six FFI
functions both entries already call — `encrypt`, `decrypt`, `encryptBulk`,
`decryptBulkFallible`, `encryptQuery`, `encryptQueryBulk` — with type-only
imports, so the interface itself is portable. `backend-native.ts` is the
implementation over the Node-API binding, and is now the only value import of
`@cipherstash/protect-ffi` on the encrypt path.

`EncryptOperation` is migrated as proof the seam works end to end: it takes a
backend in its constructor (alongside the client handle it already took) and
calls `this.backend.encrypt` rather than a module-level `ffiEncrypt`. Its
import of the NAPI entry is now `import type`. The client injects
`nativeBackend`. The remaining five operations still import directly and are
unchanged.

Two details worth keeping:

- The backend delegates through a namespace (`ffi.encrypt(...)`) rather than
  closing over destructured imports, so bindings resolve at call time. Binding
  all six eagerly broke three suites at module load: a partial
  `vi.mock('@cipherstash/protect-ffi', …)` that stubs only the function under
  test fails on the five it never calls.
- `null-guards.test.ts` now injects a Proxy backend that throws on any method,
  so "short-circuits without an FFI call" asserts a violation would fail
  loudly. Previously it could only assert absence. That is the testability
  argument for DI, demonstrated rather than claimed.

Verified: `@cipherstash/stack` 1073 passed; `code:check` clean; and the built
`dist/wasm-inline.js` still references only `@cipherstash/protect-ffi/wasm-inline`,
with no NAPI reference. That last check is currently manual — #798 calls for it
to become a build test before shared operations reach the WASM entry.
…stage 2)

Stage 2 of the plan in `docs/plans/798-shared-operation-layer.md` (added here).

An operation was awaitable but not assignable to `Promise<…>`: TypeScript's
`Promise<T>` is structural and wants `then`, `catch`, `finally`, and
`[Symbol.toStringTag]`, and only `then` was implemented. Adds the other three,
each delegating to `execute()` exactly as `then` already does.

This is what makes stage 4 non-breaking. `wasm-inline` currently returns bare
`Promise<WasmResult<T>>`; having it return shared operations instead — the
point of #798 — would otherwise break anyone who annotated that return type.
With the class assignable wherever a `Promise` was, that adoption is additive,
and `.audit()` / `.withLockContext()` reach the WASM entry without a major.

Two corrections found while implementing, both worth recording:

- `implements Promise<T>` is wrong; operations resolve to
  `Result<T, EncryptionError>`, so the declaration is
  `implements Promise<Result<T, EncryptionError>>`. Declaring it (rather than
  relying on structural assignability) is what caught this.
- Open question 1 in the plan answered itself immediately: `Client` from
  `@/types` is `… | undefined`, which the FFI does not accept. `CryptoBackend`
  now takes `NonNullable<Client>`; operations already guard with
  `noClientError()` before reaching the backend, so the narrowed handle is
  what actually crosses the boundary.

`catch` is documented as NOT catching expected failures — those resolve to
`{ failure }` per the Result contract rather than rejecting. It fires only on
a thrown exception, the same cases `await` throws on.

`[Symbol.toStringTag]` reports the concrete operation name rather than
'Promise', so an inspected operation is not mistaken for one.

Verified: 1073 runtime tests pass; `test:types` 103 pass, including a new
`operation-promise-conformance.test-d.ts` that pins the assignability,
`Awaited<>` shape, the three members, and `Promise.all`. Nothing else in the
suite would catch a regression here, because every runtime test only awaits.
…kend (#798, stage 3)

All five remaining operations now take a `CryptoBackend` instead of importing
one: `decrypt`, `bulk-encrypt`, `bulk-decrypt`, `encrypt-query`, and
`batch-encrypt-query`, along with their `*WithLockContext` twins, which receive
it through `getOperation()` or their constructor. Every NAPI import on the
operation path is now `import type`.

Still no public behaviour change — the client injects `nativeBackend` at each
construction site, and 1073 runtime + 103 type tests pass unchanged.

One non-mechanical detail: `batch-encrypt-query.ts` typed a helper parameter as
`Awaited<ReturnType<typeof ffiEncryptQueryBulk>>`, a *value* reference in a
type position. That is now `CryptoBackend['encryptQueryBulk']`, which says the
same thing without naming a binding.

Test construction sites are updated to inject a Proxy backend that throws on
any method. In `null-guards` and `bulk-encrypt-validation` this is a real
improvement rather than a port: both suites exist to prove work is rejected
BEFORE the FFI is reached, and previously could only infer that from an error
message or an unreached stub client. Now contact with the FFI fails the test.

Two helpers deliberately NOT migrated, both deferred to stage 4:

- `helpers/error-code.ts` narrows with `instanceof FfiProtectError`, a runtime
  value. Converging it means adopting the structural reader `wasm-inline.ts`
  already uses (`:392` explains why it must be structural across the serde
  boundary). That is a behaviour change to native error-code extraction, not a
  mechanical move, and belongs with the entry convergence where it can be
  tested against both bindings.
- `helpers/model-helpers.ts` calls `decryptBulk`, which `CryptoBackend` does
  not yet expose — the WASM entry uses `decryptBulkFallible` exclusively. The
  binding exports both, so adding it is easy, but which one the shared model
  path should use is a convergence decision, not a rename.

Bundle purity re-verified: `dist/wasm-inline.js` still references only
`@cipherstash/protect-ffi/wasm-inline`.
`__tests__/wasm-inline-bundle-isolation.test.ts` already enforces the bundle
boundary, and is stricter than the plan proposed: it parses every module
specifier out of the built `dist/wasm-inline.js`, pins protect-ffi and auth to
their `/wasm-inline` subpaths, keeps an allowlist of every external so
additions must be deliberate, and rebuilds when `dist` is staler than `src`
so it cannot pass against an old artifact. It was added during #741.

The plan listed it as outstanding. That was wrong, and the error mattered:
I recommended doing stage 5 before stage 4 on the grounds that the boundary
was only verified by hand. It is not — it is enforced, and stage 4 will trip
the existing test if it regresses.

The test's header also records that this exact leak already happened once,
via `@/encryption/helpers/error-code` pulling in the native `ProtectError`
for an `instanceof` narrow. That corroborates deferring that helper in stage 3
and settles how it converges: the shared path must use the structural reader
`wasm-inline.ts:392` already has, because the `instanceof` one cannot cross
into the WASM bundle at all.
…4 groundwork)

Everything the WASM entry needs in order to adopt the shared operations,
short of the adoption itself. Three transitive native/Node dependencies were
blocking it; each is removed here, and all of it is inert until stage 4
proper.

`backend-wasm.ts` — the `CryptoBackend` over `@cipherstash/protect-ffi/wasm-inline`,
mirroring `backend-native.ts`. Unused so far; it is the other half of the seam.

`helpers/error-code.ts` now reads the code structurally instead of narrowing
with `instanceof ProtectError`, which was a value import of the Node-API
entry. This is the convergence the bundle-isolation test's header predicted:
the `instanceof` version cannot cross into the WASM bundle at all (that build
ships no error class), so the structural reader is the only one that can be
shared. The behavioural difference is documented in the file — it now matches
any object with a string `code`, which degrades an error message in the
unusual case rather than changing behaviour.

`no-client-error.ts` — every operation imported `noClientError` from
`encryption/index.ts`, the native composition root. That single import would
have dragged the NAPI binding into any bundle touching an operation. Now its
own module, re-exported from the index so the public path is unchanged.

`identity/resolve-lock-context.ts` — operations imported `resolveLockContext`
from `@/identity`, which imports `@/utils/config`, which uses `fs` and `path`.
Those are fatal in a Worker. The check is now structural-only, which costs no
behaviour: `identity/index.ts` already paired `instanceof LockContext` with
`'identityContext' in input`, the structural half existing to catch a context
built in another realm. Anything the `instanceof` matched, the property check
matches too.

The `fs`/`path` leak was found by building and reading the bundle, not by
reasoning — `__tests__/wasm-inline-bundle-isolation.test.ts` names every
external precisely so this is a test failure rather than a runtime surprise in
someone's Worker. It earned its keep here.

## Why stage 4 proper is not in this commit

Wiring `WasmEncryptionClient.encrypt` to the shared `EncryptOperation` works —
typechecks, builds, and the bundle stays clean — but regresses three cases in
`wasm-inline-result-contract.test.ts`. The WASM entry's `wasmResult` coerces a
non-`Error` rejection while preserving its detail (a string stays its own
message, an object is JSON-serialised); the shared operation's error mapper
reads `.message` off whatever it is caught, so a Rust-side string rejection
becomes "Something went wrong".

That is a real semantic difference between the two error paths, not a porting
detail, and the shared operation should adopt the WASM entry's `toError`
behaviour before any method migrates. Left for the next commit rather than
regressed here.
…tage 4)

Ports the WASM entry's rejection handling into the shared operation path, then
migrates the first method onto it.

## The blocker, fixed

`helpers/to-error.ts` is `wasm-inline.ts`'s `toError` lifted out and shared.
Passed as `withResult`'s `onException` hook by all ten operations, which
previously passed none. Without it a non-`Error` rejection reached the failure
mapper as-is, `(error as Error).message` was `undefined`, and the detail was
replaced by a generic string.

That gap existed because the two bindings throw differently: the NAPI entry
throws `Error` subclasses, while wasm-bindgen hands back bare strings and plain
objects. The WASM entry had careful handling and a test for it; the native path
never needed it. Sharing the operations means the shared path has to be the
careful one, so the native operations now preserve rejection detail too —
strings kept verbatim, objects JSON-serialised, `code` carried onto the
synthesized Error so `failure.code` survives (`withResult` runs `onException`
first, so the mapper only ever sees the fresh Error).

## The migration

`WasmEncryptionClient.encrypt` returns the shared `EncryptOperation` instead of
a bare promise. Source-compatible thanks to stage 2: the operation satisfies
`Promise<Result<…>>` structurally, so `await client.encrypt(…)` yields the same
`{ data } | { failure }` and existing `Promise<…>` annotations still accept it.
`.audit()` and `.withLockContext()` now exist on the WASM entry for the first
time — they live on the shared operation rather than being reimplemented.

Marked `!` because the return type changes even though behaviour does not.

## Verification

1073 runtime + 103 type tests pass, including the three
`wasm-inline-result-contract` cases this regressed before `toError` was shared
— they are what proved the error path was not equivalent. Bundle isolation
still passes: `dist/wasm-inline.js` imports exactly
`@cipherstash/auth/wasm-inline` and `@cipherstash/protect-ffi/wasm-inline`.

`.withLockContext()` on this entry is plumbed but UNVERIFIED at runtime:
whether the Rust honours `lockContext` across the WASM serde boundary is
open question 2 in the plan (#793), and confirming it needs live credentials.

The remaining nine WASM methods still have their own implementations.
…tions (#798)

One interface across two bindings only prevents drift if the interface
says what the bindings must agree on. Most of that was unstated and
unenforced by types, so this writes it down where it is checkable.

On `CryptoBackend`, per-method docs plus a contract section covering the
four invariants neither the type system nor a test asserts:

- **The three bulk calls preserve order.** Callers re-associate results
  with inputs positionally, so a backend that reorders or compacts
  mis-assigns ciphertexts to rows — silently, and irreversibly.
- **Failure is a rejection, not a sentinel** — except per-item decrypt
  failure, which `decryptBulkFallible` reports in-band.
- **`lockContext` sits in different places on the single and bulk
  calls**: top-level for the former, per payload item for the latter.
  The Node-API types reject the misplaced version at compile time; the
  WASM binding, typing `opts` as `any`, would accept and drop it. That
  asymmetry is now stated on both `encryptBulk` and
  `decryptBulkFallible`, and on each `*WithLockContext` class that has
  to thread it through a payload builder.
- **`unverifiedContext` is audit metadata only** — it reaches the audit
  log and is not bound into key derivation, so unlike `lockContext` it
  never affects whether a value reads back.

Also notes that `encryptBulk` and `encryptQueryBulk` payloads each name
their own table and column, so a batch may span both. There is no
single-column restriction; the docs should not leave room to infer one.

On the twelve operation classes, a class-level doc each: what the
operation does, that the FFI arrives injected so the class runs
unchanged on both entries, and the null semantics — null short-circuits
without an FFI call, so a NULL column stays NULL rather than becoming an
encrypted JSON null. `BulkDecryptOperation` distinguishes the two kinds
of "no value" it can return: a filtered null input versus a per-item
decrypt failure.

`withLockContext()` gains a doc on each of the six operations that has
one, since that is what shows at the call site: it returns a new
operation rather than mutating, audit metadata already set carries
across but later additions do not, and the claim must match at decrypt
time — a mismatch fails rather than falling back to an unbound key.

Comment-only. 1073 runtime + 103 type tests pass, `code:check` clean.
Reverts 7292b9e. A review of the draft found four confirmed defects in
it, none caught by the suite — 1073 unit tests, the type tests, and the
bundle-isolation test all passed while the WASM entry was broken on
import. Verified against the built artifact, not reasoned about:

1. `EncryptOperation` imports `@/utils/logger`, whose `initStackLogger()`
   runs at module scope and read `process.env` unguarded. Importing
   `@cipherstash/stack/wasm-inline` threw `ReferenceError: process is not
   defined` in any runtime without the Node global — a Worker without
   `nodejs_compat`, or a browser. Exactly the runtimes the entry exists
   for. `dist/wasm-inline.js:676`, with the module-scope call at 703.
2. The shared path dropped `toWasmFfiPlaintext`, whose own doc explains
   that a JS `Date` crosses wasm-bindgen as `{}` — silent corruption of
   every date/timestamp column. Bulk and query kept it; encrypt became
   the only path without.
3. `execute()` calls `log.set({ column: this.column.getName() })` outside
   `withResult`, so a malformed column rejects instead of returning
   `{ failure }`. The old body ran `getColumnName()` inside `wasmResult`
   with a named message. That is the contract
   `wasm-inline-result-contract.test.ts` exists to protect.
4. `dist/wasm-inline.d.ts` gained imports of `@byteslice/result` and the
   native `@cipherstash/protect-ffi` root. `wasm-inline.ts` documents
   this regression class already: `WasmResult` is declared locally
   precisely to keep the published types self-contained.

(4) is the one that blocks the approach rather than the attempt.
protect-ffi's WASM `.d.ts` types every `opts` as `any` and exports none
of the option types, so `CryptoBackend` must import them from the native
specifier — which then lands in the WASM entry's published types. The
fix is upstream: have the WASM `.d.ts` use the same named types. That
also removes the six `as never` casts in `backend-wasm.ts`, which are
why the WASM call sites get no type-checking from the interface meant to
keep both bindings honest.

Also reverts `helpers/error-code.ts` to `instanceof`. The structural read
is needed for WASM reachability, but it widens what reaches
`failure.code` — any object with a string `code` matches, and
`dynamodb/helpers.ts` copies that verbatim onto thrown errors. Acceptable
as a deliberate released change; not as a side effect of an internal
refactor. Restore it with stage 4.

What stays, since stages 1-3 stand on their own: the `CryptoBackend`
seam, the native backend, `Promise` conformance on `EncryptionOperation`,
and the config-free `resolveLockContext` split.

Fixed alongside, all found in the same review:

- Guard `process.env` in the logger. Latent regardless of stage 4, and
  the trap that catches the next attempt.
- Unify the two exported `LockContextInput` types. `identity/index.ts`
  had `LockContext | Context` while the operations used
  `{ identityContext } | Context`, so `v3.ts` typed the v3 client's
  parameter narrower than the untyped client's. It now re-exports the
  canonical one; every `LockContext` satisfies it.
- Fix a near-vacuous test. The int64-boundary case asserted only that
  the failure message differed from the rejection message, which would
  also hold if the guard broke with different wording. It now records
  the FFI call and asserts the value arrived intact.

The plan doc records all five findings, the upstream blocker, and the
three items still outstanding (model-helpers, the duplicated error
coercion, per-settlement re-execution).

Changeset rewritten: this is stages 1-3, whose only user-visible change
is `.catch()` / `.finally()` on operations.
cipherstash/protectjs-ffi#142 — WASM .d.ts types every opts as `any` and
exports no option types, so CryptoBackend must import them from the
native specifier.
@coderdan
coderdan force-pushed the feat/inject-ffi-backend branch from ead9d45 to 989209f Compare July 28, 2026 00:32
coderdan added 2 commits July 28, 2026 10:36
The precondition for retrying #798 stage 4, not a consequence of it.

Stage 4 was reverted because the WASM entry threw `ReferenceError: process is
not defined` on import — it had begun importing `@/utils/logger`, whose
`initStackLogger()` read `process.env` at module scope. The useful part is not
the defect but what missed it: 1073 unit tests, the type tests and the
bundle-isolation test were all green while the entry was unusable in every
runtime it exists for.

Nothing could have caught it. `wasm-inline-bundle-isolation.test.ts` reads
import SPECIFIERS, so a global is invisible to it. The Deno e2e runs under
Deno, which provides `process`. The unit suites run under Node, likewise.

So this points the harness #799 built for `dist/adapter-kit.js` at
`dist/wasm-inline.js` — which that test's own docblock anticipated ("the
portable-entry plan will point the same harness at the WASM entry"). It matters
more here than there: the shared operation layer widens this entry's transitive
graph, and anything that graph reaches ships to the edge.

The harness gains an allowed-externals argument. `adapter-kit` has no bare
specifiers; the WASM entry has two, the `/wasm-inline` subpaths of protect-ffi
and auth, which Deno and Workers resolve through an import map (and
`e2e/wasm/deno.json` maps exactly those). They are STUBBED rather than loaded:
the question is whether the graph evaluates without `process`, and loading the
real binding adds a megabyte of unrelated behaviour and its own globals for no
extra signal. Stub exports are `undefined` on purpose — module-scope code that
calls into an external at import time is itself a portability defect and should
fail loudly. Anything not named is still rejected, so a new external on this
entry fails here too, which is the same native-leak class the isolation test
guards.

Verified by breaking it: injecting an unguarded module-scope `process.env` read
into `wasm-inline.ts` and rebuilding makes this fail with the ReferenceError
above. Passing today at 56 exports.
protectjs-ffi#143 closed #142 and shipped in protect-ffi 0.31.0, so the WASM
`.d.ts` now carries the named option types stage 4 needed. Records what that
does and does not unblock: the native-specifier leak is fixed, the
`@byteslice/result` half of the same defect is not, stage 4 depends on the
0.31 bump (#809) landing, and problem 1 is now covered by a test rather than
left to review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants