chore(stack)!: upgrade protect-ffi to 0.31.0 - #809
Conversation
…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.
… disproved (#788 review) Follow-up to the #788 review of this branch. The largest item is that the fix landed at runtime but the TYPE still declared the invariant it disproves. `CallableEncryptionClient.encryptModel` / `bulkEncryptModels` returned `ChainableEncryptOperation`, declaring an `.audit()` that the wasm-inline entry does not have — precisely the assumption that let the write path chain it unconditionally and fail every EQL v3 write there. The decrypt members already returned `unknown` with a docblock explaining why; encrypt never got the same treatment. Both are now `unknown`, `ChainableEncryptOperation` is deleted (it had no other reference), and the docblock covers all four members. This is not cosmetic: re-introducing `client.encryptModel(...).audit(...)` now fails to compile with TS2571, verified by reverting the call site under tsc. The regression is statically unreachable rather than merely fixed. The type is internal — absent from every emitted .d.ts — so there is no API change and no changeset. The test I had described as load-bearing was the weakest one. It stubbed `audit()` returning a promise; the native contract is `audit(): this` on a thenable whose `then()` calls `execute()`, so metadata is read back off the operation at execution time rather than passed forward. That stub passes even if `audit()` stops recording entirely. It now subclasses the real `EncryptionOperation`, mirroring what the decrypt half of the file already did, and asserts the metadata reaches `execute()` and that the operation runs exactly once. A native-failure case covers the other arm. Also strengthened, all verified by mutation (each kills exactly 2 tests): - the audit-drop test asserted only that the result succeeded — it now asserts the drop is logged and names the entry to switch to, plus a mirror proving a chainable client does NOT trip the drop path - both malformed-result loops gained `null` and `[]`. `null` makes the `resolved === null` clause load-bearing (without it `'data' in null` throws); `[]` is `typeof 'object'` so it must be rejected on the key checks `DecryptFailure` is renamed `ResultFailure` — it was already the encrypt helper's failure type, structurally identical, wrong name. bench: the `BenchPlaintextRow` docblock claimed it was "derived from the table so the two cannot drift again". It is hand-written. Deriving it was investigated and is actively worse: `InferInsertModel` describes the ENCRYPTED column and degrades to optional `any`, and `extractEncryptionSchema` returns the widened `AnyV3Table` whose index-signature column map admits both `encTxt: 'x'` and `encText: 12345`, which the literal rejects. The comment now says so and names the unit test as the real guard. The credential-free bench step moved ahead of `docker compose up`: leaving it last meant a database failure would skip the one check whose entire rationale is that it cannot be skipped for want of a database. Its `server.deps.inline` for test-kit was inert — nothing on that path imports it — and is gone; verified still passing with DATABASE_URL and all four CS_* vars unset.
fix(stack,bench): wasm-inline DynamoDB v2 reads, and a bench seed that never encrypted (#772 review findings 10, 12)
On a mixed table — a legacy v2 pair the classifier no longer sees, plus one unrelated EQL v3 column — pickEncryptedColumn's sole-EQL-column rule claims the v3 column for the v2 plaintext. Three separate defects turned that guess into wrong outcomes. cutover had no `via` gate at all. Its v3 branch returns from inside try with exitCode untouched, so the finally never fires process.exit: it printed "point your application at email_enc" and exited 0 while the v2 rename never ran. drop.ts:106 already gated on `via === 'sole'` for exactly this reason. The manifest hint was discarded. backfill records the true pairing, so the answer was on disk, but resolveColumnLifecycle dropped a hint that failed to resolve and re-picked without it — reaching the sole rule. Recording the pairing changed nothing. Split the two reasons a hint fails: a column that is GONE is stale and still falls through to convention; a column that EXISTS but is not EQL v3 (the legacy eql_v2_encrypted case) is reported by name. drop's remedy prescribed the guess — `--encrypted-column <guess>`. Recording it makes the next resolution `via: 'hint'`, which walks past the gate, and the coverage check passes vacuously because an unrelated backfilled column is non-NULL on every row. The message was the instruction manual for generating a live DROP COLUMN on the plaintext at exit 0. Tested at the layer that had none: resolve-eql.test.ts covered only explainUnresolved, and encrypt-v3.test.ts stubs resolveColumnLifecycle outright, so neither could see the hint discard. The new tests keep pickEncryptedColumn real and replace only the two I/O boundaries.
Both placeholder templates emitted `await Encryption({ schemas: [] })`. An
empty schema set is a hard TS2769 against both overloads — deliberately, per
S-6 — so every `stash init` left a project failing its first tsc, in the one
file the CLI tells the user not to hand-edit. The old scaffold called
EncryptionV3, whose `readonly AnyV3Table[]` bound accepted `[]`; collapsing it
into an alias of Encryption tightened that away.
Relaxing the constraint is not an option (it exists to catch a real mistake),
and `stash init` has no table names in scope by design — it stopped
introspecting, and `build-schema.ts` sets `schemas: []` on its own state. So
the scaffold declares a sentinel table instead, which keeps the file compiling
and keeps the "you haven't declared anything yet" signal: loadEncryptConfig
exits 1 when `__stash_placeholder__` is the only table left, naming the file.
The gap that let this ship is the more important half. packages/cli has no
typecheck step (21 pre-existing errors), utils-codegen*.test.ts only
`toContain`-matches fragments, and build-schema.test.ts mocks
generatePlaceholderClient to '// placeholder' — so nothing anywhere compiled,
parsed or executed the generated output. Both templates are now committed as
`.generated.ts` fixtures compiled by a scoped tsconfig in CI, and pinned
byte-for-byte to the generator by a unit test. Verified the gate reproduces the
original TS2769 when the fixture is reverted.
The `.generated.ts` suffix is load-bearing: biome.json already excludes it, so
formatting cannot rewrite template output and break the byte comparison.
The `unresolvedHint` fail-closed added for #772 finding 7 had no `candidates.length > 0` guard, so it fired on pure EQL v2 tables too. `encrypt backfill` records `encryptedColumn` in migrations.json unconditionally, v2 included. On a pure-v2 table `listEncryptedColumns` returns [] (the classifier recognises `eql_v3_*` only), so the hint failed to resolve, `columnExists` found the real `eql_v2_encrypted` column, and `cutover` / `drop` exited 1 — telling users to downgrade for a lifecycle this same build still fully implements in cutover.ts / drop.ts. Gate the fail-closed on a non-empty candidate list. Finding 7's protection is unchanged: the mixed table it targets always has candidates. Order `explainUnresolved`'s empty-candidates fall-through ahead of the hint branch so both agree for direct callers. Also drop the "this release no longer manages that lifecycle" claim from the cutover/drop `via: 'sole'` messages — the build does still implement it; the command simply resolves EQL v3 counterparts only. Tests cover the previously untested shape: candidates [] + a recorded hint. Review follow-ups: - Extend the placeholder-table guard to `encrypt backfill` via loadEncryptionContext; correct the changeset/skill wording to name the commands that actually refuse (cutover/drop never read the client file). - Alias `@cipherstash/migrate` to source in the CLI unit vitest config, so `pnpm --filter stash test` no longer needs a prior workspace build (verified: 888 tests pass with packages/migrate/dist removed). - Revert the unrelated em-dash re-encoding in packages/cli/package.json.
Follow-up to 1d14412, from an adversarial cross-check of that commit. resolve-eql's private `columnExists` used a bare `to_regclass($1)`. That form parses and case-folds unquoted identifiers, so on a Prisma-style "User" table the probe reported the column missing, the recorded pairing was treated as stale, and the #772 fail-closed silently did not fire — falling through to the sole/convention rules and resolving the guess it exists to prevent. migrate already documents this exact anti-pattern (REGCLASS_SQL) and its test asserts `not.toMatch(/to_regclass\(\$1\)/)`. Moved to a shared, case-exact `columnExists` export and deleted the CLI copy; the test double is now case-exact too, so it cannot hide a regression. The placeholder guard read the harvested export map while the `db push` / `db validate` guard it mirrors reads `getEncryptConfig().tables`. Those disagree in both directions on one file: `schemas: [placeholderTable]` minus the `export` keyword fell through to "table not found … Available: (none)" — the error the guard replaces — and a stale placeholder export beside real tables wrongly fired it. Now reads the same source. cutover's `via:'sole'` refusal was nested inside `version === 3`; drop's is top-level. Equivalent today, but cutover's v2 ladder does an irreversible rename plus config promotion, so a restored v2 classification or a v4 family would let cutover rename on a guess drop refuses. Hoisted to match. Text that was false: - the scaffold `stash init` writes into every customer project (and both fixtures) claimed `stash encrypt` commands refuse to run; only backfill does - skills/stash-cli listed the client as loaded by `schema build` and `encrypt *`, and omitted `db push` - skills/stash-cli and skills/stash-encryption still said cutover on a backfilled v3 column exits 0. Since `.cipherstash/` is gitignored, a clone or CI runner hits the new `sole` refusal on a pure-v3 table with an unconventional column name — the changeset's "pure-v3 unaffected" was wrong too - both `sole` messages said "the table's only EQL column"; pickEncryptedColumn excludes the plaintext column first, so it fires with two - the remedy said to drive the v2 lifecycle "directly"; there is no CLI route, so it now says to run the eql_v2 SQL yourself - vitest.config.ts and packages/cli/AGENTS.md claimed the unit suite is self-contained. It is not: @cipherstash/stack is still reached via migrate/src/backfill.ts. The alias removed one of two couplings typecheck:scaffold ran `tsc` directly, bypassing turbo, so it needed a prior build and passed only by accident of step ordering behind steps that read as independently droppable. Now a turbo task with dependsOn ^build. Added scripts/__tests__/cli-vitest-alias.test.mjs — the CLI's alias map sits outside the vitest.shared.ts guard and must, since stackSourceAlias's '@/' would clobber the CLI's own. Runs in test:scripts, ahead of the build-dependent suite. Verified: frozen-lockfile install clean; 891 CLI unit, 41 migrate, 93 scripts, 76 pty e2e; code:check 0 errors; scaffold gate green through turbo with packages/stack/dist absent.
…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.
🦋 Changeset detectedLatest commit: 6ab36ce The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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
Two breaking changes upstream, both of which the stack was relying on the old
lenient behaviour for.
**Unknown option keys are rejected, not dropped (protectjs-ffi#147).** The bulk
paths passed a local `id` alongside each payload — bookkeeping this package
uses to label results, which the binding never read. serde discarded it
silently; 0.31 fails the whole call with `unknown field `id``, a serde error
naming a field the caller never wrote, on payloads valid in every prior
release. It broke `bulkEncrypt`, `bulkDecrypt`, all four model helpers, and
every DynamoDB operation built on them.
Every affected caller re-associates results POSITIONALLY against the array it
passed in — `keyMap[index]`, or an index walk over the original `plaintexts` —
so the `id` was never load-bearing at the boundary. It is now stripped there:
once in `model-helpers`' two shared bulk helpers (covering all eight
construction sites), and in `createEncryptPayloads` / `createDecryptPayloads`.
The public `{ id, plaintext }` and `{ id, data }` envelopes are unchanged.
**`ProtectError` is gone (protectjs-ffi#150).** Rust sets `code` on an ordinary
`Error` now, so `getErrorCode` reads it structurally rather than narrowing with
`instanceof` — which was never reliable (`instanceof` is false across duplicate
copies of a package, and the WASM build shipped no class at all, which is why
`wasm-inline.ts` already had its own structural reader).
That also closes a latent bug rather than merely porting one: the DynamoDB
fallback accepted ANY string `code` through an unchecked `as ProtectErrorCode`,
so a Node `ECONNRESET` was recorded as an encryption failure code. Codes are
now checked against protect-ffi's known set with `isProtectErrorCode`, which
upstream documents as the reason to test the value rather than the field.
Test mocks now spread `importActual` instead of enumerating exports. Nine of
them named exactly the symbols the code under test happened to reach, so
`getErrorCode` calling one more function broke all nine with an error naming
vitest rather than the change. `newClient` and the operations stay overridden,
so nothing contacts ZeroKMS.
`error-codes.test.ts` loses its constructor test — the class no longer exists —
and gains coverage of what replaced it, including the ECONNRESET case above.
Not verified locally: `e2e/wasm` (Deno) needs the four CS_* variables exported,
which this machine does not have; the native suites read the device profile
instead. CI runs it.
CI caught two things the local suites structurally could not: the Deno e2e and the wasm integration suite both failed with `unknown field `clientId``, which is the #147 deny-unknown-keys behaviour again, this time on client construction. protectjs-ffi#143 converged both entries onto one `NewClientOptions`, where credentials live under `clientOpts`. This entry passed `clientId` / `clientKey` at the top level — accepted by the wasm build's own looser options struct before, rejected now — so EVERY wasm client construction failed. The `as never` on that call is why nobody saw it. 0.31 types the wasm `newClient` as `(opts: NewClientOptions)`, so the cast is removed and the compiler checks the object. It immediately found the next problem, which is the more interesting one: `normalizeCastAs` is now obsolete AND wrong, so it is deleted. It existed because the wasm build did not normalise `cast_as` and rejected SDK spellings like `'string'`; 0.31 normalises both entries inside Rust at the deserialization boundary, and upstream's types say so outright — "Nothing asks you to build one. Both entries take the public EncryptConfig and normalize into this shape". Worse, its output no longer round-trips: it emitted `'double'` and `'jsonb'`, which are in neither 0.31's public `CastAs` union nor its canonical one (`'float'` and `'json'`). Left in place it would have traded a construction failure for a rejected config. `toEqlCastAs` stays — `db push` and `encrypt backfill` use it for EQL SQL config, which is a different boundary. Tests follow the contract rather than the removed function. `wasm-inline-normalize.test.ts` is dropped and its coverage folded into `wasm-inline-new-client.test.ts`, which already existed to pin the exact argument object and is the right home: it now asserts credentials are under `clientOpts` and absent from the top level, and that `cast_as` crosses untranslated. Its docblock records that this regression was caught only by the Deno job, since that is the reason the file exists.
…entry Found reviewing the 0.31 upgrade: the wasm entry was moved to `authStrategy` while fixing its client construction, but the native entry still passed `strategy`. protect-ffi 0.31 renamed the option and honours the old name, but documents it as "will be removed" — so this was a live deprecation on the default entry, and the two entries disagreed on the spelling of one concept. Now identical on both, and matching this package's own `config.authStrategy`, which is what upstream renamed the field to align with. The stack's own deprecated `config.strategy` input is untouched — that is a separate alias with its own runtime warning, and only the field it forwards INTO changed name.
994248e to
6ab36ce
Compare
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.
Upgrades
@cipherstash/protect-ffi0.30.0 → 0.31.0 across the three packages that pin it, and adapts to the two breaking changes it carries. Both were cases where this repo depended on the old lenient behaviour without anything saying so.unknown field \id`` — the one that would have bitten usersprotectjs-ffi#147 rejects unknown option keys instead of dropping them. Every bulk path here passed a local
idalongside the payload:serde discarded it silently. 0.31 fails the whole call with
unknown field `id`— a serde error naming a field the caller never wrote, on a payload shape valid in every prior release. It brokebulkEncrypt,bulkDecrypt, all four model helpers, and every DynamoDB operation built on them.The
idturned out never to be load-bearing at the boundary: every affected caller re-associates positionally —keyMap[index]in the model helpers, an index walk over the original array inbulk-encrypt/bulk-decrypt. The binding's response is index-aligned; theidwas only ever there to make the call site readable.So it is now stripped at the FFI boundary, in three places rather than eight: the two shared helpers in
model-helpers.ts(which between them cover all eight construction sites), pluscreateEncryptPayloadsandcreateDecryptPayloads. The public{ id, plaintext }/{ id, data }envelopes are unchanged.ProtectErrorremovedprotectjs-ffi#150 derives error codes in Rust and drops the
ProtectErrorclass. Upstream's reasoning is worth repeating, because it is the same conclusion this repo had already reached on the edge entry:getErrorCodenow readscodestructurally, which is exactly whatwasm-inline.ts'sreadErrorCodealready did for want of a class on that build. The two are now the same logic; what still separates them is the import, not the read (noted in the source, and left for the #798 seam).This closes a latent bug rather than porting one. The DynamoDB fallback branch accepted any string
codevia an uncheckedas ProtectErrorCode, so a NodeECONNRESETwas recorded as though it were an encryption failure code. Codes are now validated against protect-ffi's known set — which is precisely why upstream's docs say to check the value and not just the field's presence.Test mocks stopped enumerating exports
Nine test files mocked
@cipherstash/protect-ffiby listing the symbols the code under test happened to reach.getErrorCodecalling one more function broke all nine — with an error naming vitest, not the change:They now spread
importActualand override onlynewClientand the operations, so nothing contacts ZeroKMS and the next upstream addition doesn't repeat this. Two of them were still mockingProtectError, a class that no longer exists.error-codes.test.tsloses its constructor test and gains three covering what replaced it, including theECONNRESETcase.The WASM entry was broken, and only CI could see it
The first push went green on every local suite and failed three CI jobs — the Deno e2e and both Drizzle integration runs — all with the same error:
Same #147 behaviour as above, this time on client construction. #143 converged both entries onto one
NewClientOptions, where credentials nest underclientOpts; this entry passedclientId/clientKeyat the top level, which the wasm build's own looser options struct had accepted. Every@cipherstash/stack/wasm-inlineclient construction failed.It was invisible locally because the call was
as never. 0.31 types the wasmnewClientas(opts: NewClientOptions), so the cast is now gone and the compiler checks the object — and it immediately found the next problem:normalizeCastAsis obsolete and wrong, so it is deleted. It existed because the wasm build did not normalisecast_asand rejected SDK spellings like'string'. 0.31 normalises both entries in Rust, and upstream's own types say so:Worse, its output no longer round-trips: it emitted
'double'and'jsonb', which are in neither 0.31's publicCastAsunion nor its canonical one ('float'and'json'). Left in, it would have traded a construction failure for a rejected config.toEqlCastAsitself stays —db pushandencrypt backfilluse it for EQL SQL config, a different boundary.Coverage follows the contract rather than the deleted function:
wasm-inline-normalize.test.tsis dropped and folded intowasm-inline-new-client.test.ts, which already existed to pin the exact argument object. It now asserts credentials sit underclientOptsand are absent from the top level, and thatcast_ascrosses untranslated.What this does not do
It does not close #797. 0.31 declares
lockContexton the wasm build's option types, but that is the binding;wasm-inline.tsstill exposes no.withLockContext(), and the shape is a per-call option field rather than a chainable. See #777 for the docs side, now written against the surface that exists.It does unblock #800's stage 4, whose stated blocker was protectjs-ffi#142 — "have the WASM
.d.tsuse the same named option types rather thanany". #143 closes that and ships in 0.31:dist/wasm/protect_ffi.d.tsnow imports the named types from../../lib/types.js, soCryptoBackendneed not borrow the native types and the sixas nevercasts inbackend-wasm.tscan go.Verification
pnpm run code:check— exit 0 (191 warnings, all pre-existing).pnpm run build— 9/9 tasks.@cipherstash/stack— 1108 passed (68 files). Baseline onremove-v2at 0.30 was 1106/68; the delta is one constructor test replaced by three.stash919,@cipherstash/wizard332,stack-drizzle371,stack-supabase466,prisma-next344,migrate41 — all passing. CLI e2e 76 passed.e2e/wasm(Deno) could not be run locally — it needs the fourCS_*variables exported, where this machine's native suites read the device profile instead. It was right to flag it: that job, plus the two Drizzle integration runs, are what caught the broken WASM client construction. All three pass on the current head.Measured by running the full suite on
origin/remove-v2at 0.30 first, so the 105 initial failures could be attributed rather than assumed.