Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
109 commits
Select commit Hold shift + click to select a range
0fbccbe
feat!: remove EQL v2-only published packages (protect, schema, protec…
tobyhede Jul 22, 2026
7568465
docs: add EQL v2 removal PR1 plan
tobyhede Jul 22, 2026
73f6610
docs: correct plan changeset targets and stale-ref grep pattern
tobyhede Jul 22, 2026
01d7dab
refactor(migrate): drop EQL v2 from the domain-type classifier
tobyhede Jul 22, 2026
d7ff847
feat(stack)!: EQL v3 audit-on-decrypt + collapse EncryptionV3 into En…
tobyhede Jul 22, 2026
88168e6
fix(stack): typed client tolerates nominal-arity decrypt calls
tobyhede Jul 22, 2026
e48c6ed
refactor(stack-supabase): fold v3 query builder into base
tobyhede Jul 22, 2026
f5088cb
feat(stack-supabase)!: remove EQL v2 authoring surface, de-suffix v3 API
tobyhede Jul 22, 2026
617cd4a
test(stack-supabase): update suites for folded v3 builder + de-suffix
tobyhede Jul 22, 2026
a68fee6
docs(stack-supabase): update stash-supabase skill + changesets for v2…
tobyhede Jul 22, 2026
636f639
docs(stack-supabase): clarify v2 decrypt path in changeset
tobyhede Jul 23, 2026
cf4c105
refactor(stack-supabase): split query-builder monolith, ratchet FTA c…
tobyhede Jul 23, 2026
19e8057
feat(stack-drizzle)!: remove EQL v2 root and collapse ./v3 to root
tobyhede Jul 22, 2026
91bcbdb
fix(bench): infer typed v3 client through a helper after Encryption c…
tobyhede Jul 23, 2026
f8967f6
ci(bench): install EQL v3 through the real CLI instead of relying on …
tobyhede Jul 23, 2026
90c7a23
fix(bench,cli,docs): address PR review — dead JSON index, broken init…
tobyhede Jul 23, 2026
0167ada
docs(repo): repoint dead package paths + guard against recurrence
tobyhede Jul 23, 2026
2e9d902
refactor(cli): drop the unreachable v2 branch left by the classifier …
tobyhede Jul 23, 2026
8eb2b85
chore(stack): address review nits
tobyhede Jul 23, 2026
8b74430
fix(stack): address PR 768 review — stale decrypt-audit guidance, sil…
tobyhede Jul 23, 2026
e960c39
fix(stack-supabase)!: type single()/maybeSingle() as one row, not an …
tobyhede Jul 23, 2026
48f34bb
test(cli): retarget the codegen drizzle assertions at the collapsed root
tobyhede Jul 23, 2026
125095e
feat(wizard): port migration rewriter to the EQL v3 domain family
tobyhede Jul 22, 2026
ae2fedd
fix(wizard,cli): sweep every migration dir; trim near-miss preamble
tobyhede Jul 23, 2026
48564c0
fix(wizard): resolve @cipherstash/auth via the node condition; gate t…
tobyhede Jul 23, 2026
6981a31
docs(skills): repoint drizzle index guidance at the collapsed root
tobyhede Jul 24, 2026
5fab1cf
feat(cli,examples)!: de-suffix the init scaffold, rewrite examples/ba…
tobyhede Jul 24, 2026
5282281
docs(skills,meta): correct the naming the v2 removal inverted
tobyhede Jul 24, 2026
a16302f
Merge pull request #776 from cipherstash/feat/remove-eql-v2-pr7-scaff…
tobyhede Jul 24, 2026
7eb47ed
fix(wizard,cli): never rewrite a commented-out or already-encrypted A…
tobyhede Jul 24, 2026
7e0092f
fix(stack)!: make Encryption's overloads agree with what it returns
tobyhede Jul 24, 2026
ae5d1a4
fix(stack-supabase): export the single-row builder, drop dead v2 remn…
tobyhede Jul 24, 2026
40c5ea0
docs(skills,meta): stop describing EQL v2 as the current generation
tobyhede Jul 24, 2026
c8c1512
chore(ci,changesets): fix bench triggers, test-kit alias, overstated …
tobyhede Jul 24, 2026
2d3bbfb
fix(wizard): default the migrate prompt to No when a sweep could not run
tobyhede Jul 24, 2026
3c33c12
fix(stack-supabase): accept interface row types, not just type aliases
tobyhede Jul 24, 2026
8b3f0b1
docs(skills): scope the v2 rollout lifecycle, and fix the drop column
tobyhede Jul 24, 2026
de48f0a
docs: stop claiming the tooling detects a column's EQL v2 generation
tobyhede Jul 24, 2026
d765ee5
docs(changesets): fix claims that are false in the release they ship in
tobyhede Jul 24, 2026
05b7bf0
fix(wizard,cli): treat quoted identifiers and string literals as inert
tobyhede Jul 24, 2026
383d3c2
fix(wizard,cli): an unterminated quoted identifier must fail safe too
tobyhede Jul 24, 2026
919a9c7
fix(stack): only forward an EQL v3 table to the client on DynamoDB de…
tobyhede Jul 24, 2026
4c17678
docs(stack-supabase): drop a comment about a removed code path
tobyhede Jul 24, 2026
b186fd2
docs: drop three more comments that explain the present via deleted code
tobyhede Jul 24, 2026
972a8a8
Merge pull request #780 from cipherstash/fix/remove-v2-review-remedia…
tobyhede Jul 24, 2026
b68ce48
docs(specs): fail-closed inversion for the encrypted ALTER COLUMN rew…
tobyhede Jul 24, 2026
d20a052
fix(cli): rewrite an encrypted ALTER only for a column the corpus dec…
tobyhede Jul 24, 2026
baa9597
fix(cli): close the keyword-mention gap and comment-check regression …
tobyhede Jul 24, 2026
84e9e67
fix(wizard): rewrite an encrypted ALTER only for a column the corpus …
tobyhede Jul 24, 2026
94bdb45
fix(cli): declare the column the fail-closed sweep now requires in mi…
tobyhede Jul 24, 2026
2632347
fix(wizard): declare the column the fail-closed sweep now requires in…
tobyhede Jul 24, 2026
599c826
docs(skills,changesets): the ALTER sweep repairs only what it can place
tobyhede Jul 24, 2026
5aebbd5
docs(changesets,skills): scope the drop-ciphertext guarantee to what …
tobyhede Jul 24, 2026
83bba45
fix(cli,wizard): collapse duplicate skip reports for block-comment-pr…
tobyhede Jul 24, 2026
56e18af
fix(wizard): stop claiming data destruction when the sweep only flagg…
tobyhede Jul 24, 2026
692b941
fix(cli,wizard): fold the block-comment strip into the preamble loop
tobyhede Jul 24, 2026
f7b8220
Merge pull request #783 from cipherstash/fix/a2-fail-closed-rewrite
tobyhede Jul 24, 2026
f551a72
fix(cli,wizard): detect non-public-schema and array EQL domains as en…
tobyhede Jul 25, 2026
0cfd135
test(cli,wizard): pin describeSkipReason's three reason mappings
tobyhede Jul 25, 2026
301f7e0
test(cli,wizard): cover the sweep's command and multi-directory layers
tobyhede Jul 25, 2026
8ebb174
fix(cli,wizard): index the rewrite's own target, and resolve the impl…
tobyhede Jul 25, 2026
333edf2
fix(cli,wizard): track dollar-quoted bodies and E'' escapes when scan…
tobyhede Jul 25, 2026
03579b2
fix(cli,wizard): find the CREATE TABLE body terminator instead of gue…
tobyhede Jul 25, 2026
c12e355
test(scripts): compare the two rewriter copies so a one-sided fix fai…
tobyhede Jul 25, 2026
9c96ff1
fix(wizard): sweep only drizzle-kit output directories
tobyhede Jul 25, 2026
a3997b7
fix(bench): key seed rows by JS property, and check it without a data…
tobyhede Jul 25, 2026
bcf247a
fix(stack): refuse the wasm-inline client for DynamoDB EQL v2 reads
tobyhede Jul 25, 2026
2eef07e
docs(specs): design for the Encryption signature fixes and typecheck …
tobyhede Jul 24, 2026
3d925be
fix(stack): give the typed client an init passthrough (A-7)
tobyhede Jul 24, 2026
41ce9c5
fix(stack)!: accept schema arrays that are not literals (A-4)
tobyhede Jul 24, 2026
16de142
fix(stack,stack-drizzle,bench): name the client with EncryptionClient…
tobyhede Jul 24, 2026
74bac7f
ci: typecheck the surfaces that compiled nowhere, and cache dist (C-1)
tobyhede Jul 24, 2026
7b6e050
docs(changesets,skills,prisma-next): record the signature changes
tobyhede Jul 24, 2026
d33cece
ci: exempt bench from the dist outputs cache key
tobyhede Jul 24, 2026
58bff6f
fix(cli,skills,stack-supabase): correct v2 cutover read guidance
tobyhede Jul 24, 2026
5ea066f
docs(changesets): drop the redundant lifecycle bullets
tobyhede Jul 24, 2026
1326b73
fix(stack): make typed encryptQuery callable against the built package
tobyhede Jul 24, 2026
74b6755
fix(stack): scope the dist type gate correctly
tobyhede Jul 24, 2026
ded4921
fix(stack): refuse EQL v2 wire over an all-v3 schema set (A-8)
tobyhede Jul 25, 2026
43b955a
fix(stack): make adapter-kit importable without a process global
tobyhede Jul 26, 2026
772281c
fix(stack-supabase): recognise v3 columns structurally, not by class …
tobyhede Jul 26, 2026
b223bad
fix(stack-supabase): fail closed on unrecognised v3 column builders
tobyhede Jul 26, 2026
124048d
fix(stack): close the init wire-version back door, unbreak generic sc…
tobyhede Jul 27, 2026
77c0d84
Merge pull request #782 from cipherstash/fix/encryption-signature-and…
tobyhede Jul 27, 2026
14324aa
Merge pull request #785 from cipherstash/fix/a2-coverage-followup
tobyhede Jul 27, 2026
b953364
fix(stack): stop the wasm-inline client failing every DynamoDB v3 wri…
tobyhede Jul 27, 2026
687173a
fix(stack,bench): stop the client type asserting the .audit() it just…
tobyhede Jul 27, 2026
c3f4292
Merge pull request #788 from cipherstash/fix/stack-runtime-correctness
tobyhede Jul 27, 2026
f84ac7d
fix(cli): stop encrypt cutover/drop acting on a guessed encrypted column
tobyhede Jul 25, 2026
342ec62
fix(cli): make stash init's scaffolded client compile, and gate it
tobyhede Jul 25, 2026
a55788b
fix(cli): keep pure-v2 tables on the v2 ladder (#787 review)
tobyhede Jul 26, 2026
fd5aafe
fix(cli,migrate): close gaps found verifying the #787 review fixes
tobyhede Jul 27, 2026
39060e6
test(ci): pin the turbo routing that the #787 review's minor finding …
tobyhede Jul 27, 2026
937c6f3
test(cli,ci): close the coverage and guard gaps found auditing #787
tobyhede Jul 27, 2026
3dab7c2
Merge pull request #787 from cipherstash/fix/cli-lifecycle-and-scaffold
tobyhede Jul 27, 2026
02b1734
test(stack-supabase): pin the fail-closed message, not the [supabase …
tobyhede Jul 27, 2026
d34a16b
test(stack-supabase): unit-test the structural v3 probe, one member a…
tobyhede Jul 27, 2026
f503d8d
docs(stack): a new bare specifier is a signal, not automatically a de…
tobyhede Jul 27, 2026
5761f6b
fix(stack,stack-supabase): diagnose an EQL v2 table instead of crashing
tobyhede Jul 27, 2026
66942a0
fix(skills,stack,scripts): correct shipped decrypt guidance, and make…
tobyhede Jul 25, 2026
69b754c
fix(cli,scripts): per-integration query guidance, and linters that ca…
tobyhede Jul 25, 2026
57f323e
fix(cli,scripts): close the review nits, and the same defect where it…
tobyhede Jul 27, 2026
24f66c6
fix(cli): stop the plan templates drafting a v2-only cutover on a v3 …
tobyhede Jul 27, 2026
0585f4d
fix(cli): detect already-encrypted columns on EQL v3 during init
tobyhede Jul 27, 2026
7a9dfe5
Merge pull request #789 from cipherstash/fix/shipped-docs-and-lint-gates
tobyhede Jul 27, 2026
a0acce8
Merge pull request #799 from cipherstash/feat/edge-safe-shared-seam
tobyhede Jul 27, 2026
389cbe2
chore(stack)!: upgrade protect-ffi to 0.31.0
coderdan Jul 27, 2026
b80a6ce
fix(stack): repair the wasm entry's client construction under 0.31
coderdan Jul 27, 2026
6ab36ce
chore(stack): pass the auth strategy as `authStrategy` on the native …
coderdan Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
15 changes: 15 additions & 0 deletions .changeset/cli-near-miss-statement-trim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'stash': patch
---

Trim the leading comment block from near-miss statements reported by the Drizzle
migration rewriter (`stash eql migration --drizzle`, `stash eql install`).

The broad near-miss scan is anchored on the previous `;`, so a
`SET DATA TYPE … USING …` it could not safely repair was quoted back to the user
with every preceding comment and blank line glued to its front — in a file
opening with a comment block, that meant the whole header. The reported
statement is now the offending statement alone. Detection is unchanged; only the
text shown to the user is affected.

Keeps this rewriter in sync with its sibling in `@cipherstash/wizard`.
13 changes: 13 additions & 0 deletions .changeset/cli-v2-cutover-prompt-correction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'stash': patch
---

`stash init`'s setup prompt no longer tells agents to declare an EQL v2 cutover
column with a `types.*` domain.

The `types.*` factories are EQL v3 only — a v2 column is an `eql_v2_encrypted`
composite — so an agent following the old step-4 guidance would author a schema
that cannot describe the column it just cut over to. The prompt now says
explicitly not to use `types.*` for a v2 column, and points at the deprecated
`@cipherstash/stack/schema` builders with decryption through
`@cipherstash/stack`, which is the actual read path for legacy v2 rows.
72 changes: 72 additions & 0 deletions .changeset/decrypt-chaining-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
'stash': patch
'@cipherstash/stack': patch
---

Correct the shipped documentation for `decryptModel` / `bulkDecryptModels`.

Three places in `skills/stash-encryption` and four in `packages/stack/README.md`
said these return "a plain `Promise<Result<...>>` (not a chainable operation)"
and that there is therefore "no `.withLockContext()` to chain". They return an
`AuditableDecryptModelOperation`, which is thenable and carries both
`.withLockContext()` and `.audit()` — the same `.audit()` chain the
audit-on-decrypt work advertises. The skill contradicted itself: its own
reference table already listed the correct return type.

The skill ships inside the `stash` tarball and `installSkills()` copies it into
customer repos, so this was steering agents away from an API that exists. The
README ships in the `@cipherstash/stack` tarball.

The equivalent statement about the **WASM entry** is correct and unchanged —
`@cipherstash/stack/wasm-inline` really does return a plain promise from decrypt,
with no lock-context argument.

Also fixes the setup prompt `stash init` writes for coding agents, which
referenced `protectOps.eq` — an API that does not exist anywhere in the repo.
Every step naming an integration-specific API now branches on the project's
actual integration, instead of naming Drizzle's and Supabase's and leaving the
other two to guess:

- **Query paths.** `createEncryptionOperators(client)` (conventionally `ops`)
for Drizzle, the `encryptedSupabase` wrapper's own filters for Supabase, the
`eql*` column operators for Prisma Next, and `client.encryptQuery(...)` for a
plain Postgres project.
- **Schema authoring.** The `types.*` column factories for Drizzle, the
`eql_v3_encrypted` domain in migration SQL for Supabase, the `cipherstash.*`
field constructors in `schema.prisma` for Prisma Next, and `encryptedTable`
for plain Postgres. Prisma Next was previously sent at `types.*` /
`encryptedTable` — the client `stash schema build` explicitly refuses to
scaffold for that integration.
- **Read paths.** `decryptModel(row, usersSchema)` where that applies, and the
wrapper's transparent decryption where it does not.
- **Skill pointers.** A plain Postgres project installs no integration-specific
skill, so each "see the integration skill" was a pointer at a file that was
never written. Those now point at `stash-encryption`, which it does get.

`client.encryptQuery` is also shown taking the schema objects themselves
(`{ table: usersSchema, column: usersSchema.email }`) rather than an
object-shorthand that read as three required strings — `queryType` is inferred
from the column's configured indexes.

The cutover and complete-rollout **plan templates** now split EQL v3 from v2.
Both described the v2 rename swap (`<col>` → `<col>_plaintext`, twin → `<col>`)
as the only cutover path, so on the default v3 install `stash plan` drafted a
plan built around `stash encrypt cutover` — a command that refuses v3 columns
outright ("there is no rename step") and refuses entirely on a v3-only
database, where `eql_v2_configuration` does not exist. The implement prompt
already carried this split; the plan templates did not. The version is
per-column, so the templates tell the agent to establish it per column rather
than deciding once for the whole plan.

The "already encrypted" stop-and-ask now recognises `eql_v3_*` domains
alongside the legacy `eql_v2_encrypted` udt, so it can fire on the default
path at all.

**`stash init` now detects already-encrypted columns on EQL v3.** Database
introspection marked a column as CipherStash-managed only when its udt was
exactly `eql_v2_encrypted`. v3 columns carry per-domain types
(`eql_v3_text_search`, `eql_v3_integer_ord`, …), so on the default path every
encrypted column was reported as plaintext — shown with its `dataType` and left
unticked in the column picker, inviting a re-run to encrypt it a second time.
The picker also labelled any encrypted column with the literal string
`eql_v2_encrypted`; it now shows the column's real domain.
18 changes: 18 additions & 0 deletions .changeset/domain-carrier-not-public.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@cipherstash/stack': patch
---

The phantom domain carrier on encrypted v3 columns is no longer part of the
public surface.

Recovering a column's domain after declaration emit requires the type parameter
to appear bare somewhere in the instance type, which is what makes typed
`encryptQuery` callable against the published package. That carrier was a
plainly named `__domain` property, and this build has no `stripInternal`, so
`@internal` was documentation rather than enforcement: it appeared in
autocomplete on every column and could be read from consumer code, typed
`D | undefined` while being `undefined` at runtime in every case.

It is now keyed by a `unique symbol` that is not exported, so it is unnameable
outside the package while remaining exactly as inferrable. Nothing is emitted at
runtime and no call site changes.
2 changes: 1 addition & 1 deletion .changeset/drizzle-encrypted-indexes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
'@cipherstash/stack-drizzle': minor
---

New `encryptedIndexes` helper on the `/v3` entry: spread
New `encryptedIndexes` helper on the package root: spread
`...encryptedIndexes(t)` in `pgTable`'s third-argument callback and it derives
the recommended functional indexes for every encrypted column in the table —
named `<table>_<column>_<capability>`, tracked by `drizzle-kit generate` like
Expand Down
13 changes: 8 additions & 5 deletions .changeset/dynamodb-eql-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ Pass a table built with `encryptedTable` + the `types.*` domains from
the typed client from `EncryptionV3` and the nominal client from
`Encryption({ config: { eqlVersion: 3 } })` are accepted.

EQL v2 tables continue to work unchanged — this is additive, and no existing
caller needs to change. The table decides which wire format is used, so a
DynamoDB table populated under one version must keep being read with that
version.
EQL v2 tables continue to be **readable** — `decryptModel` /
`bulkDecryptModels` still accept one, so existing items stay accessible. Writing
through a v2 table is a separate matter: `encryptModel` / `bulkEncryptModels`
narrowed to EQL v3 tables in this same release, so a caller that still encrypts
through a v2 table does need to change. The table decides which wire format is
used, so a DynamoDB table populated under one version must keep being read with
that version.

This fixes a latent bug that made v3 unusable: the write path detected an
encrypted value by its `k: 'ct'` tag, but EQL v3 scalars carry no `k`
Expand Down Expand Up @@ -71,4 +74,4 @@ type, where a declared column `email` becomes `email__source` (plus
`email`. `decryptModel` / `bulkDecryptModels` invert it via `DecryptedAttributes`.
`AnyEncryptedTable`, `DynamoDBEncryptionClient` and `AuditConfig` are now
exported from `@cipherstash/stack/dynamodb` so these signatures can be named.
The EQL v2 overloads are unchanged.
The EQL v2 **decrypt** overloads are unchanged; the v2 encrypt overloads are removed in this release.
17 changes: 17 additions & 0 deletions .changeset/dynamodb-skill-wasm-caveat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'stash': patch
---

The `stash-dynamodb` and `stash-encryption` skills documented EQL v2 decrypt as
unconditionally supported, without noting that `@cipherstash/stack/wasm-inline`
is EQL v3 only. Since that is the documented entry for Deno, Bun, Cloudflare
Workers and Supabase Edge Functions — runtimes commonly paired with DynamoDB — a
reader following the skill hit a runtime refusal with no forewarning. Both skills
now state that legacy v2 items are readable on the native `@cipherstash/stack`
entry only.

The `stash-dynamodb` API reference also claimed audit metadata forwards to
ZeroKMS "regardless of client shape". It does not: the wasm-inline client's
operations return a plain promise with no `.audit()`, so its audit metadata is
dropped (logged at debug). The reference now says so, and says the operation
still succeeds.
27 changes: 27 additions & 0 deletions .changeset/dynamodb-v2-read-table-forwarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@cipherstash/stack': patch
---

Fix `encryptedDynamoDB`'s EQL v2 read path, which failed against a v3-configured
client — the case the v2 read support exists for.

`decryptModel` and `bulkDecryptModels` forwarded the table to the encryption
client unconditionally. The second argument is only meaningful to the typed EQL
v3 client, which resolves it against its own reconstructor map; a legacy v2
table is not in that map, so the call failed with:

```
[eql/v3]: decryptModel received a table this client was not initialized with
```

That contradicted the adapter's documented contract — writes are EQL v3 only,
but decrypt keeps accepting a v2 table so previously stored items stay readable.
In practice the failure hit exactly the customers the compatibility promise was
written for: upgraded to a v3 schema, with v2 items still in the table.

The table is now forwarded only when it is an EQL v3 table. A v2 table takes the
table-less form, and the client derives the table from the payloads themselves.

Both paths are covered by a credential-free test asserting what the adapter
forwards, so a regression no longer depends on a live-credential integration run
to surface.
39 changes: 39 additions & 0 deletions .changeset/dynamodb-wasm-v2-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@cipherstash/stack': patch
---

`encryptedDynamoDB` now refuses a `@cipherstash/stack/wasm-inline` client paired
with a legacy EQL v2 table, instead of failing at the first read with a
misleading error.

The adapter's v2 read path deliberately calls `decryptModel(item)` with **no**
table — a v2 table means nothing to a v3 client's reconstructor map, and the
native clients derive the table from the payloads anyway. `WasmEncryptionClient`
cannot do that: its decrypt requires the table and resolves date fields from a
per-table map, so the omitted argument surfaced as
`TypeError: Cannot read properties of undefined (reading 'tableName')` thrown
from deep inside the client — on the documented entry for Deno, Cloudflare
Workers and Supabase Edge Functions, which satisfies the adapter's client type
structurally and so was accepted with no cast.

The pairing is now rejected at the call site, with a message naming both the
combination and the fix. The message is operation-neutral: the guard runs on all
four operations, so a plain-JS caller reaching the write path with a v2 table
gets a message that does not claim a read it never attempted.

`encryptModel` / `bulkEncryptModels` now also tolerate a client whose encrypt
returns a plain promise. They chained `.audit()` onto the result
unconditionally, which the wasm-inline client does not carry — so **every EQL v3
write through this adapter on that entry** failed with
`client.encryptModel(...).audit is not a function`, surfaced as a
`DYNAMODB_ENCRYPTION_ERROR` and so indistinguishable from a real encryption
fault. The read path already handled this; the write path now matches, via the
same fail-closed check that rejects a malformed result rather than passing an
unencrypted item through as a success. Audit metadata still has nowhere to go on
that client, so it is dropped and logged at debug — the write itself succeeds.

Three comments in this package claimed audit metadata was forwarded "regardless
of client shape" and that "every client this package ships carries `.audit()` on
decrypt". Neither was true of the wasm-inline client, whose decrypt returns a
plain promise — the metadata is dropped, observably (it is logged at debug), and
the comments and the debug message now say so.
20 changes: 20 additions & 0 deletions .changeset/encrypt-client-guard-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'stash': patch
---

`stash encrypt backfill` now names the cause when the encryption client has no
initialized encrypt config, instead of reporting a missing table.

The guard that refuses an unusable client file existed twice — once in
`loadEncryptConfig` (`stash db push` / `db validate`) and once, hand-copied, in
`loadEncryptionContext` (`stash encrypt backfill`). The copies had already
drifted: for a client whose `getEncryptConfig()` returns nothing, `db push`
exited 1 with `Encryption client in <file> has no initialized encrypt config`,
while `encrypt backfill` fell through to `Table "users" was not found in the
encryption client exports. Available: (none)` — naming the symptom rather than
the cause, which is precisely the failure the guard was added to eliminate.

Both loaders now call one shared guard, so a single file cannot produce two
different diagnoses, and the refusals are pinned at both public entry points.
The un-replaced `stash init` scaffold is unaffected — it was already refused by
both, with the same message.
59 changes: 59 additions & 0 deletions .changeset/encrypt-lifecycle-mixed-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
'stash': patch
---

`stash encrypt cutover` and `stash encrypt drop` no longer act on — or report
success for — an encrypted column they only guessed at.

On a table holding both a legacy EQL v2 pair (`ssn` / `ssn_encrypted`) and one
unrelated EQL v3 column, the v2 ciphertext column is not classified as an EQL
column at all, so column resolution fell through to the "this is the table's
only EQL column" rule and claimed the unrelated v3 column. Three consequences,
all now fixed:

- **`cutover` reported success for work it never did.** Its EQL v3 branch had no
guard on how the column was resolved, and returned without setting an exit
code — so it printed "point your application at `email_enc`" and exited 0
while the v2 rename never ran. A scripted rollout read that as complete. It
now refuses, and exits 1, exactly as `drop` already did.

- **The recorded pairing was discarded.** `encrypt backfill` writes the true
`encryptedColumn` to `.cipherstash/migrations.json`, so the answer was already
on disk — but a hint that failed to resolve was dropped entirely and the
re-resolution reached the guess. A hint naming a column that still exists but
is not an EQL v3 column is now reported as what it is (most often a legacy
`eql_v2_encrypted` counterpart) instead of being replaced by a guess. A
genuinely stale hint — one naming a column that is gone — still falls back to
the naming convention as before.

- **`drop`'s refusal message prescribed the guess.** It told the user to re-run
`backfill --encrypted-column <the guessed column>`. Following it recorded the
guess as fact, so the next run resolved "by hint", walked past the refusal,
and passed the coverage check vacuously — an unrelated but legitimately
backfilled column is non-NULL on every row — then generated a live
`DROP COLUMN` on the plaintext and exited 0. The message now asks for the
column that actually encrypts the named one, and says explicitly not to record
the guess.

The `unresolvedHint` refusal is scoped to tables that actually hold EQL v3
columns a guess could wrongly claim. A **pure-v2** table has none, so it still
falls through to the EQL v2 lifecycle exactly as before — including when
`encrypt backfill` recorded an `encryptedColumn` for it, which it does for v2
columns too.

Two cases DO newly exit 1, both deliberately:

- Any table with at least one EQL v3 column where the manifest records an
`encryptedColumn` that exists but is not one of them — not only the
v2-pair-plus-one-v3 shape. The recorded pairing is authoritative and
disagrees with every candidate, so guessing past it is the bug.

- A column whose encrypted counterpart could only be identified **by
elimination** — no recorded `encryptedColumn`, no `<col>_encrypted` name
match, one EQL column left once the plaintext column itself is excluded.
`cutover` now refuses this as `drop` already did. Note `.cipherstash/` is
gitignored, so `migrations.json` is machine-local: a fresh clone or CI runner
can hit this on a **pure-v3** table whose encrypted column is named
unconventionally, where `cutover` previously exited 0 with "not applicable".
Re-run `stash encrypt backfill --table T --column C --encrypted-column <name>`
to record the pairing.
52 changes: 52 additions & 0 deletions .changeset/encryption-schema-arrays.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@cipherstash/stack': minor
'@cipherstash/prisma-next': patch
---

`Encryption({ schemas })` now accepts any non-empty array of EQL v3 tables, not
only an array literal.

Previously the v3 signature required a non-empty *tuple*, so every indirect form
was rejected at compile time even though it worked at runtime:

```ts
// all of these used to fail with TS2769
export const schemas: AnyV3Table[] = [users, orders]
await Encryption({ schemas })

const readonlySchemas: ReadonlyArray<AnyV3Table> = [users, orders]
await Encryption({ schemas: readonlySchemas })

const built: AnyV3Table[] = []
built.push(users)
await Encryption({ schemas: built })
```

They all compile now. `Encryption({ schemas: [] })` is still a compile error, and
an array literal still gets full per-column typing — passing the wrong plaintext
for a column's domain, or a table the client was not built with, is still
rejected.

`EncryptionClientFor<S>` is widened to match, so it names the typed client for a
loose `readonly AnyV3Table[]` as well as for a tuple. Code that is generic over
its schemas — an adapter that builds a table per request, say — can now write
`EncryptionClientFor<readonly AnyV3Table[]>` and keep the typed surface.

If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to
satisfy the old signature, that narrowing is no longer needed.

A wrapper that is itself generic over its schemas keeps working too:

```ts
async function makeClient<S extends readonly [AnyV3Table, ...AnyV3Table[]]>(
schemas: S,
) {
return await Encryption({ schemas })
}
```

Note the non-empty tuple constraint. A wrapper generic over a loose
`readonly AnyV3Table[]` is still rejected — that type admits `readonly []`, so
the wrapper cannot promise what `Encryption` requires. Constrain it as above, or
take `EncryptionClientFor<readonly AnyV3Table[]>` as a parameter instead of
building the client inside the generic function.
Loading