Skip to content

fix(stack): Encryption signature fixes and typecheck gates (A-4, A-6, A-7, C-1)#782

Open
tobyhede wants to merge 11 commits into
remove-v2from
fix/encryption-signature-and-typecheck-gates
Open

fix(stack): Encryption signature fixes and typecheck gates (A-4, A-6, A-7, C-1)#782
tobyhede wants to merge 11 commits into
remove-v2from
fix/encryption-signature-and-typecheck-gates

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

Review remediation for #772, addressing A-4, A-6, A-7 and C-1 from the verification pass (#778).

Stacked on #780 (fix/remove-v2-review-remediation), which is itself stacked on remove-v2 (#772). Branched at 8b3f0b15; the 11 commits here are this PR's own. Design doc: docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md.

Scope, and what is deliberately out of it

A-6 and A-7 exist because Encryption is overloaded, and it is overloaded because there are two clients: the typed EQL v3 one and the nominal one that v2 and loose introspection-derived schemas need. Two overloads means two discriminators that can disagree — overload resolution reads config, the runtime reads schemas. Remove EQL v2 and both defects delete themselves.

So this does not attempt the type-level repair the verification doc explores (a single generic signature returning ClientFor<S, C>, a discriminated-union WireConfig). That machinery exists only to let two clients coexist and would be deleted by the v2 removal that motivates it — it is #637, and it belongs with PRs 9–12. What is here is the subset that is either independent of the removal (A-4, C-1) or cheap and non-throwaway (A-6, A-7).

A-4 — schema arrays that are not literals

Encryption({ schemas }) accepted only an array literal. Every indirect form failed TS2769: a shared AnyV3Table[] module export, ReadonlyArray<AnyV3Table> (the type prisma-next exposes publicly), push-built arrays, spreads, unannotated const. Widened to accept any non-empty array. prisma-next drops its destructure-and-respread workaround; the runtime emptiness check and its error message stay.

A-6 — name the client with EncryptionClientFor<S>

ReturnType reads the last overload — the nominal one — so Awaited<ReturnType<typeof Encryption>> yielded EncryptionClient even for an all-v3 schema set, and every assignment of the real client to it failed TS2739. 15 sites carried that error (10 in stack, 5 in stack-drizzle), none visible to CI because the only typecheck step in either package is scoped to __tests__/**/*.test-d.ts.

Overload order cannot fix it — putting the nominal signature first mis-resolves v3 schemas instead, since a v3 table structurally satisfies BuildableTable — so the client gets a name, which is what comparable libraries do (z.infer, arktype's typeof T.infer, hono's Client<T>). The idiom is swept out entirely, not just at the erroring sites.

Typing these clients correctly unmasked call sites the wrong type had been hiding. Most were as never casts that are simply no longer needed, so those calls are now genuinely checked. Two are not: encrypt-query-stevec and encrypt-query-searchable-json exercise encryptQuery query types the typed client mis-models (it derives the plaintext from the column's domain, so every query type on a types.Json() column is typed JsonDocument, but the SteVec types take a JSONPath string, a { path, value } pair, or a bare scalar). Those two hold the client through the nominal surface with the gap named at the cast, rather than casting at every call and hiding it.

A-7 — init passthrough on the typed client

The two discriminators disagreeing has a concrete crash behind it. Hoisting a config into a ClientConfig-typed variable is enough to split them — ClientConfig.eqlVersion is 2 | 3, which the v3 overload's eqlVersion?: 3 rejects — so the call types as the nominal EncryptionClient while the runtime still returns the typed one. init was the only member of EncryptionClient absent from the typed client, so anything holding it through its declared type hit TypeError: client.init is not a function. Now delegated, with the parity asserted so a future member cannot reopen the hole.

The other half — the silent loss of the typed surface, with no diagnostic — is not closable at the type level (the runtime inspects values, the type inspects an erasable static type). It ends with the v2 removal (#637).

C-1 — typecheck the surfaces that compiled nowhere

Four packages had a tsconfig.json covering src and tests that no CI step ever ran, and were already clean — now gated before they drift: migrate, nextjs, examples/prisma, e2e. packages/nextjs needed vi.Mock imported as a type first.

Also declares outputs: ["dist/**"] on turbo's build. It declared none, so a cache hit replayed logs and restored no files — and the examples/basic gate added by 5fab1cf typechecks against packages/stack/dist/*.d.ts. It held only because fresh CI runners have no cache. Verified by deleting packages/stack/dist and re-running: cache hit, dist restored, still green.

Deliberately not gated, with counts recorded in the workflow so the gap is visible rather than silent: @cipherstash/stack (147), stack-drizzle (63), stash (21), stack-supabase (11). Their test:types scripts are scoped to __tests__/**/*.test-d.ts, so src, the runtime suites and integration/** compile nowhere. Most of the drizzle and supabase count is a single root cause in @cipherstash/test-kit's V3_MATRIX.

Found along the way: typed encryptQuery was never against the built package

client.encryptQuery(value, { table, column }) did not typecheck for any column when imported from the published package — every plaintext resolved to never. Reproduced identically against the rc.4 tarball on npm, released main @ b48494c, origin/remove-v2 and this branch, so it predates the v2 removal and shipped in every release carrying the v3 typed client.

PlaintextForColumn / QueryTypesForColumn recover the domain via C extends EncryptedV3Column<infer D>, which needs D bare in the instance type. Its only bare site was private readonly definition: D, and tsc strips private member types on declaration emit — leaving nothing TypeScript can invert, so infer D fell back to the constraint and cascaded to never. encrypt escaped it by resolving through ColumnsOf.

Fixed with a type-only declare readonly __domain?: D — nothing emitted at runtime, no call-site change, but it survives declaration emit. Adds packages/stack/dist-types and a test:types:dist script that typechecks the emitted declarations rather than source, wired into CI. That gap is why this survived the whole rc series: every other type test imports @/….

Review

CodeRabbit reviewed the branch — 6 findings, 0 critical. The two majors were real and are fixed in 35b8858: skills/stash-encryption claimed reads of the promoted column decrypt transparently after stash encrypt cutover (true only through CipherStash Proxy, and contradicted by the very next table row), and stash init's setup prompt told agents to declare an EQL v2 cutover target with a types.* domain (v3-only; a v2 column is an eql_v2_encrypted composite). Three markdownlint findings were skipped — this repo runs no markdownlint, and two of the three suggestions are wrong under CommonMark.

Changesets

encryption-schema-arrays (stack minor, prisma-next patch), typed-client-init-parity (stack patch), typed-encrypt-query-dist (stack patch), skills-encryption-client-naming (stash patch).

@tobyhede
tobyhede requested a review from a team as a code owner July 24, 2026 12:46
@changeset-bot

changeset-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1fbbd71

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack Major
@cipherstash/prisma-next Major
stash Major
@cipherstash/bench Patch
@cipherstash/stack-drizzle Major
@cipherstash/stack-supabase Major
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8219ba76-9b96-409c-94bc-42f85c18f4c2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/encryption-signature-and-typecheck-gates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

tobyhede added 11 commits July 24, 2026 22:49
…gates

Covers A-4, A-6, A-7 and C-1 from the EQL v2 removal verification, and
records what is deliberately excluded: the single generic signature and
ClientFor<S, C> machinery belong with the v2 removal (#637), not here.
`Encryption` picks its return value with two discriminators that can
disagree — overload resolution reads the `config` argument, the runtime
reads the `schemas` argument. Hoisting a config into a `ClientConfig`-typed
variable splits them: `ClientConfig.eqlVersion` is `2 | 3`, which the v3
overload's `eqlVersion?: 3` rejects, so the call resolves to the nominal
client while the runtime still returns the typed one.

`init` was the only member of `EncryptionClient` absent from the typed
client, so anything holding that client through its declared type hit
`TypeError: client.init is not a function`. Delegate it, and assert the
parity so a future member cannot reopen the hole.

The remaining half — the silent loss of the typed surface — is not
closable at the type level (the runtime inspects values, the type inspects
an erasable static type). It ends with the EQL v2 removal (#637).
`Encryption({ schemas })` only accepted an array LITERAL. Every indirect
form failed with TS2769: a shared `export const all: AnyV3Table[]`, the
`ReadonlyArray<AnyV3Table>` prisma-next exposes publicly, anything
push-built or spread, an unannotated `const`.

The cause was how the empty-schema hole was closed — by constraining the
type parameter to a non-empty TUPLE, which also excluded every non-literal.
Move non-emptiness onto the `schemas` property via `NonEmptyV3<S>` and let
the parameter be the array. `Encryption({ schemas: [] })` is still a compile
error, and the literal path keeps its per-column plaintext typing — the
guard has to sit on the property, since wrapping the config in a
conditional alias defeats `const` inference.

`EncryptionClientFor` carried the same tuple guard and is widened in step.
Left alone it fell through to the nominal client for a loose `AnyV3Table[]`
— handing the wrong type to exactly the callers this enables.

Prisma-next's destructure-and-respread workaround can now be removed.
…For (A-6)

`ReturnType` reads the LAST overload, which is the nominal one, so
`Awaited<ReturnType<typeof Encryption>>` yielded `EncryptionClient` even for
an all-v3 schema set and every assignment of the real client to it failed
with TS2739. 15 sites carried that error — 10 in stack, 5 in
stack-drizzle — and none was visible to CI, because the only typecheck step
in either package is scoped to `__tests__/**/*.test-d.ts`.

Overload order cannot fix it (putting the nominal signature first
mis-resolves v3 schemas instead, since a v3 table structurally satisfies
BuildableTable), so name the client instead — which is what every
comparable library does: z.infer, arktype's typeof T.infer, hono's
Client<T>.

Sweeps the idiom out entirely, not just the erroring sites: nominal
callers now say `EncryptionClient`, v3 callers `EncryptionClientFor<S>`.
packages/bench drops the single-signature helper it wrapped Encryption in
for exactly this reason.

Typing these clients correctly unmasked call sites the wrong type had been
hiding. Most were `as never` casts that are simply no longer needed, and
dropping them means the calls are now genuinely checked. Two are not:
encrypt-query-stevec and encrypt-query-searchable-json exercise
`encryptQuery` query types the typed client mis-models — it derives the
plaintext from the column's domain, so every query type on a types.Json()
column is typed JsonDocument, but the SteVec types take a JSONPath string,
a { path, value } pair, or a bare scalar. Those two suites hold the client
through the nominal surface with the gap named at the cast, rather than
casting at every call and hiding it.

packages/stack tsconfig: 168 -> 147 errors, none new.
packages/stack-drizzle tsconfig: 69 -> 63 errors, none new.
Four packages had a `tsconfig.json` covering their src and tests that no CI
step ever ran, and were already clean — gate them before they drift:
migrate, nextjs, examples/prisma and e2e. `packages/nextjs` needed two
fixes first: `vi.Mock` was used as a TYPE, which needs `import type { Mock }`.

Also declare `outputs: ["dist/**"]` on turbo's `build`. It declared none, so
a cache hit replayed logs and restored no files — and the examples/basic
gate added by 5fab1cf typechecks against `packages/stack/dist/*.d.ts`. It
held only because fresh CI runners have no cache. Verified by deleting
packages/stack/dist and re-running the gate: cache hit, dist restored,
still green.

Deliberately NOT gated, with counts recorded in the workflow so the gap is
visible rather than silent: @cipherstash/stack (147), stack-drizzle (63),
stash (21), stack-supabase (11). Their `test:types` scripts are scoped to
`__tests__/**/*.test-d.ts`, so src, the runtime suites and integration/**
compile nowhere. Most of the drizzle and supabase count is a single root
cause in @cipherstash/test-kit's V3_MATRIX.
- Two new changesets: the schema-array widening (stack minor,
  prisma-next patch) and the typed-client init parity (stack patch).
- stack-audit-on-decrypt.md dropped the paragraph telling callers to
  narrow AnyV3Table[] to readonly [AnyV3Table, ...AnyV3Table[]]. That
  advice is obsolete, and it never worked for ReadonlyArray<AnyV3Table>
  — the type prisma-next exposes publicly — since readonly also failed
  the old tuple constraint.
- prisma-next drops its destructure-and-respread workaround. Its comment
  claimed 'Encryption requires a non-empty schema tuple', which is no
  longer true; the runtime emptiness check and its error message stay.
- skills/stash-encryption documents EncryptionClientFor as the way to
  name the client, and that schemas need not be an array literal.
`packages/bench`'s build is `tsc --noEmit` — a typecheck, not a bundle — so
the repo-wide `outputs: ["dist/**"]` added alongside it warned 'no output
files found' on every run.
CodeRabbit review of this branch. Two guidance defects that would have
shipped into customer repos:

- `skills/stash-encryption` claimed reads of the promoted column return
  "decrypted ciphertext transparently" after `stash encrypt cutover`. Only
  CipherStash Proxy decrypts on the wire; SDK/ORM reads still return
  ciphertext, which is what the very next table row says. An agent
  following the table would return raw `eql_v2_encrypted` composites to
  end users.
- `stash init`'s setup prompt told agents to declare an EQL v2 cutover
  target with a `types.*` domain. `types.*` is the v3 domain family; a v2
  column is an `eql_v2_encrypted` composite. v2 is a read path now, so the
  branch points at the deprecated `@cipherstash/stack/schema` builders,
  decrypting through `@cipherstash/stack`.

Also: the `EncryptedSingleQueryBuilder` doc comment said a method omitted
from the interface "is absent at runtime too". `single()` returns `this`
(query-builder.ts:479), so every filter and transform remains a property
of the object — the interface narrows the static API only.

Skipped three markdownlint findings (MD040 aside): this repo runs no
markdownlint, and MD028 on the supabase skill and the code-span whitespace
in the rewriter changeset are both correct as written under CommonMark.
The existing changeset already states that the bundled skills described the
v2 lifecycle as the default and that the guidance ships into customer repos.
Enumerating each individual correction adds nothing for a reader of the
changelog.
`client.encryptQuery(value, { table, column })` did not typecheck for ANY
column when imported from the published package — every plaintext resolved
to `never`. Reproduced identically against the rc.4 tarball on npm,
released main @ b48494c, origin/remove-v2 and this branch, so it predates
the v2 removal and shipped in every release carrying the v3 typed client.

`PlaintextForColumn` / `QueryTypesForColumn` recover the domain via
`C extends EncryptedV3Column<infer D>`, which needs `D` bare in the instance
type. Its only bare site was `private readonly definition: D`, and tsc
strips private member types on declaration emit — leaving only
`D['eqlType']`, `D['capabilities']` and `QueryableFlag<D>`, none of which
TypeScript can invert. So `infer D` fell back to the V3DomainDefinition
constraint: QueryTypesForColumn -> never -> QueryableColumnsOf -> never ->
plaintext never. `encrypt` escaped it by resolving through `ColumnsOf`.

Fix is a type-only `declare readonly __domain?: D`: nothing emitted at
runtime, no constructor or call-site change, but it survives declaration
emit and restores the inference site.

Adds `packages/stack/dist-types` and a `test:types:dist` script that
typechecks the EMITTED declarations rather than source, wired into CI.
That gap is why this survived the whole rc series — every other type test
imports `@/…`. Verified red without the fix (three errors, including the
original `never`) and green with it.

Found while converting the A-6 call sites: the same tests that would have
caught it were holding the client through the broken `ReturnType` idiom.
Two corrections to the gate added in the previous commit:

- Exclude `dist-types` from `packages/stack/tsconfig.json`. That config maps
  `@/*` and the `@cipherstash/stack/*` subpaths to SOURCE, so checking these
  files there resolved their imports to the very thing they exist to avoid and
  reported two failures that said nothing about what ships.

- Move the `@ts-expect-error` directives onto the exact lines the errors are
  reported at. A directive only covers the following line, and the formatter is
  free to split a call across several — which it did, silently turning one
  negative into 'unused directive' and letting the other through as a real
  error.

Re-proved red/green by deleting the phantom field and rebuilding: 4 errors
without it, including the original `never`; clean with it.
@tobyhede
tobyhede force-pushed the fix/encryption-signature-and-typecheck-gates branch from fe7938e to 1fbbd71 Compare July 24, 2026 12:50
Base automatically changed from fix/remove-v2-review-remediation to remove-v2 July 24, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant