Skip to content

Commit b7fa61f

Browse files
authored
Prisma Next 0.16, payload-identifier routing, and digest-verified baked EQL migration artefacts (#763)
* feat(prisma-next): upgrade to Prisma Next 0.16 All @prisma-next/* deps move 0.14.0 -> 0.16.0 in lockstep. prismaContract now requires the target's createNamespace factory (0.15 stopped materialising a placeholder namespace) — added to both configs and the bundled skill. The PSL interpreter test moves to the CST symbol-table input. Contracts re-emitted: postgres-schema namespace kind, StorageColumnTypes maps, scalarList capability; storage hashes unchanged. * feat(prisma-next): route v3 decoding from the EQL payload's own identifier Every EQL v3 payload carries its required i: {t, c} identifier, and ZeroKMS commits the cell key to it — the authoritative routing source. decode now reads it first (ctx.column kept as fallback for non-v3 documents), and decodeJson builds fully-routed envelopes from it instead of throwing, unblocking relation include() and aggregate projections. * feat(prisma-next): bake EQL install SQL into migration artefacts, digest-verified Replaces runtime injection: the framework applies extension migrations from on-disk vendored copies without the descriptor, and the seed phase never refreshes an existing package dir, so injection's environment- dependent hashes orphaned every consumer copy on each EQL bump (PN-MIG-5002) and the recomputed hash laundered tampering. Each migration self-emit now embeds readVerifiedInstallSql() — refused unless sha256 matches @cipherstash/eql's releaseManifest.installSqlSha256. One content-addressed identity flows from git through npm to vendored copies and the DB ledger. CI guards: provenance pin (committed SQL digest == installed manifest), frozen published-migration hashes, and byte-parity between the example's vendored space and the shipped artefacts. The example's stale vendored space is regenerated (stale v2 dir removed). Also fixes the upgrade migration's stale describe() space hashes. Follow-ups tracked in #762. * test(prisma-next): make EQL provenance guard bump-safe; fix stale docs Addresses code-review findings on the baked-EQL work: - migration-v3.test.ts: the provenance pin compared each migration's baked SQL digest against the LIVE @cipherstash/eql manifest, so it self- destructed on the next EQL bump (frozen 3.0.2 bytes can't match a 3.0.3 manifest) — pushing a maintainer toward re-emitting the published baseline. Reworked to a PUBLISHED_MIGRATIONS table with per-migration FROZEN digests (identity + baked-SQL, tied to each migration's own release) plus one bump-safe lockstep check (the installed release must be baked by SOME published migration) and an on-disk completeness check (no unpinned or stale migration dirs). - descriptor.test.ts: the 'injects the runtime EQL install SQL' test still named and described the deleted sentinel-injection design and compared against a live readInstallSql() (also bump-unsafe). Rewritten to assert the op carries the baked bundle verbatim, without the live comparison; dropped the now-unused import. - DEVELOPING.md: refreshed the sections that still documented the sentinel/ runtime-injection design and instructed re-emitting published migrations (the exact action the new doctrine and frozen-hash guard forbid); added the second (upgrade) migration to the layout and invariants. Reviewed in this branch; follow-ups (#4/#6/#8 payload-routing edges, efficiency items) tracked separately. * docs(prisma-next): document why v3 decodeJson needs no NULL handling CodeRabbit flagged decodeJson(null) as throwing. It is unreachable: the SQL runtime null-guards before both codec methods — decodeField before decode, and sql-orm-client's include-decode loop (plus its many-typed element path) before decodeJson — so a NULL cell never reaches the codec. Null-handling is the runtime's contract; returning null here would also break the framework's decodeJson(json): TInput envelope signature. Comment only, no behavior change. * fix(cli,skills): correct 'prisma-next migration apply' -> 'prisma-next migrate' Prisma Next has no 'migration apply' subcommand — the apply verb is the top-level 'prisma-next migrate' ('migration' only groups plan/new/show/ status/log/list/graph/check). Running the prescribed 'migration apply' errors: 'Unknown command: apply. Use prisma-next migrate --to <contract>'. Fixes the stale name in the stash-prisma-next and stash-cli skills, the @cipherstash/prisma-next README, and — user-visibly — stash init --prisma-next's printed next-steps, the init flag help, and the stash eql install Prisma-Next refusal message. Updated the CLI unit + e2e assertions that pinned the old string. Historical CHANGELOG entries left as-is. Surfaced by the rc.4 skilltester run (found at PN 0.14.0, confirmed 0.16.0); tracked with the other skilltester findings in a follow-up issue. * feat(prisma-next): warn on v3 decode routing disagreement (review feedback) Addresses the reviewer's non-blocking note on #763: identifier-first v3 routing decrypts a value by its own `i:{t,c}` identity even when the projected column disagrees, which removes the implicit cross-check that column-first routing gave for free (a misplaced value used to fail to decrypt). This re-surfaces that signal. `decode` now compares the payload identifier against the projected column and, on disagreement, emits a `console.warn` naming both — schema identifiers only, never plaintext/ciphertext. Routing is UNCHANGED: the payload identifier stays authoritative (ZeroKMS commits the cell key to it). It warns rather than throws, and dedupes once per distinct mismatch (a per-instance Set), because the same disagreement is the expected benign shape of an un-re-encrypted column rename — so it can't spam the hot path or cry wolf on a supported workflow. Mirrors the once-per-process console.warn pattern already used in @cipherstash/stack. 3 new unit tests: warn-once-per-distinct-mismatch, a fresh warn for a different mismatch, and no-warn on agreement / no-context (aggregates). Also documents, in the baked-EQL changeset, that this PR's one-time baseline re-emit is a deliberate exception to the append-only invariant it introduces (reviewer note #1). prisma-next unit suite: 344 pass; build/dts clean; code:check clean.
1 parent d25d100 commit b7fa61f

49 files changed

Lines changed: 1529 additions & 1284 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@cipherstash/prisma-next': minor
3+
---
4+
5+
Bake the EQL v3 install SQL into the migration artefacts at emit time,
6+
digest-verified against the `@cipherstash/eql` release manifest — replacing
7+
runtime injection, whose environment-dependent migration hashes broke consumer
8+
repos on every EQL version bump.
9+
10+
**Why.** Prisma Next's migration framework treats migration packages as
11+
immutable, content-addressed artefacts: the CLI seed phase copies an
12+
extension's packages into the app's `migrations/cipherstash/` only when
13+
missing, and `db init` / `db update` apply from those on-disk files without
14+
loading the extension descriptor. The previous design injected the installed
15+
`@cipherstash/eql`'s SQL at descriptor build and recomputed the migration hash
16+
from it, so the migration's identity changed with the installed EQL version —
17+
every bump orphaned the vendored copies (`PN-MIG-5002`), and the hash could
18+
never detect SQL tampering because it was recomputed from whatever content was
19+
present.
20+
21+
**Now.** Each migration's self-emit embeds the SQL read from the pinned
22+
`@cipherstash/eql`, refusing to emit bytes whose sha256 does not match the
23+
release manifest's `installSqlSha256`. One content-addressed identity flows
24+
from this repo's git history through the npm tarball into the consumer's
25+
vendored copy and the database ledger; `verifyMigrationHash` re-checks the
26+
bytes on every read, so editing the vendored SQL fails loudly before anything
27+
executes. A repo test pins the committed artefact's SQL digest to the installed
28+
manifest, so an EQL bump without a matching new upgrade migration fails CI.
29+
EQL upgrades ship as new append-only migration directories carrying fresh
30+
invariants (the existing `upgrade_eql_v3_3_0_2` pattern); published directories
31+
are never rewritten.
32+
33+
**One-time exception.** This PR itself re-emits the baseline migration's bytes
34+
and hash (`35fc9000…``2c873907…`) as it moves off the runtime-injection
35+
scheme. That is a deliberate, one-time transition — the "never rewrite a
36+
published directory" invariant it introduces holds strictly for every release
37+
after this one, enforced by the frozen-hash CI pins.
38+
39+
**Action required (rc consumers only).** If your project's
40+
`migrations/cipherstash/` was generated by an earlier rc, delete that directory
41+
and re-run `prisma-next migration plan` (or `migrate`) — the seed phase
42+
regenerates it byte-identical to the shipped artefacts. Databases keep their
43+
markers; already-applied invariants are not re-run.

.changeset/prisma-next-0-16.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@cipherstash/prisma-next': minor
3+
'stash': patch
4+
---
5+
6+
Upgrade the Prisma Next integration to Prisma Next 0.16.
7+
8+
All `@prisma-next/*` dependencies move from `0.14.0` to `0.16.0`, in lockstep. The
9+
CipherStash encryption surface is unchanged — column types, envelopes, the `eql*`
10+
operators, `cipherstashFromStack`, and every subpath export behave exactly as before.
11+
12+
**Action required in your `prisma-next.config.ts`:** Prisma Next 0.15 stopped
13+
materialising a placeholder namespace, so authoring a SQL contract now requires the
14+
target's namespace factory. Add `createNamespace` to your `prismaContract(...)` call:
15+
16+
```typescript
17+
import { postgresCreateNamespace } from '@prisma-next/target-postgres/types'
18+
19+
contract: prismaContract('./prisma/schema.prisma', {
20+
output: 'src/prisma/contract.json',
21+
target: postgresPack,
22+
createNamespace: postgresCreateNamespace,
23+
}),
24+
```
25+
26+
Without it, `prisma-next contract emit` fails at runtime with `createNamespace is
27+
not a function`. The bundled `stash-prisma-next` skill documents this too.
28+
29+
The bundled EQL v3 baseline migration is re-emitted so its label and hash reflect
30+
the pinned `@cipherstash/eql` 3.0.2 (the committed artifact still said 3.0.0).
31+
32+
Re-run `prisma-next contract emit` after upgrading. The regenerated
33+
`contract.{json,d.ts}` picks up the 0.15/0.16 shape changes — the namespace
34+
discriminator becomes the target-specific `'postgres-schema'` (was
35+
`'sql-namespace'`), emit adds the `StorageColumnTypes` / `StorageColumnInputTypes`
36+
maps and the `scalarList` capability marker, and foreign keys and their backing
37+
indexes become discrete contract entities. Your contract's `storageHash` is
38+
unaffected by the upgrade itself.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'stash': patch
3+
'@cipherstash/prisma-next': patch
4+
---
5+
6+
Fix the wrong `prisma-next migration apply` command name in the Prisma Next
7+
guidance. Prisma Next has no `migration apply` subcommand — the apply verb is the
8+
top-level `prisma-next migrate` (`migration` only has `plan`/`new`/`show`/
9+
`status`/`log`/`list`/`graph`/`check`). The stale name appeared in the
10+
`stash-prisma-next` and `stash-cli` skills, the `@cipherstash/prisma-next`
11+
README, and — user-visibly — in `stash init --prisma-next`'s printed next-steps,
12+
the `stash init` flag help, and the `stash eql install` Prisma-Next refusal
13+
message, all of which now say `prisma-next migrate`. Surfaced by the rc.4
14+
skilltester run (found independently at Prisma Next 0.14.0, confirmed at 0.16.0).

.changeset/v3-payload-routing.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@cipherstash/prisma-next': minor
3+
---
4+
5+
Route v3 decoding from the EQL payload's own identifier, so encrypted columns
6+
decode on paths that carry no column context.
7+
8+
Every EQL v3 payload carries a required `i` identifier (`{"t": "<table>", "c":
9+
"<column>"}`), and ZeroKMS commits the cell's key to it — a payload relocated to
10+
a different column cannot decrypt. That makes the identifier the authoritative
11+
routing source, and it travels with the value rather than with the query.
12+
13+
The v3 cell codec previously took its `(table, column)` routing key only from the
14+
SQL runtime's projected-column context, which meant two paths failed even though
15+
the value knew exactly where it came from:
16+
17+
- **Relation `include()`** — the ORM decodes cells nested in a `json_agg` /
18+
`json_build_object` document through `decodeJson`, whose framework signature
19+
passes only the JSON value, with no column ref. This previously threw
20+
`decodeJson is not supported; envelopes do not round-trip through JSON`.
21+
Included encrypted columns now decode into ordinary envelopes that
22+
`decryptAll` batches alongside top-level ones.
23+
- **Aggregates and computed projections** — the runtime resolves no column ref
24+
and deliberately passes `column: undefined`, so `decode` threw. It now routes
25+
from the payload.
26+
27+
`decode` reads the payload identifier first and falls back to the projected-column
28+
context for a value carrying no usable identifier (a non-v3 or malformed
29+
document), so existing well-routed reads are unaffected. `encodeJson` is
30+
unchanged: it still renders the opaque `$encrypted*` marker, and is deliberately
31+
not the inverse of `decodeJson` — the two serve different planes.

examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/migration.json

Lines changed: 0 additions & 9 deletions
This file was deleted.

examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/ops.json

Lines changed: 0 additions & 28 deletions
This file was deleted.
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
2-
"from": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
3-
"to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
2+
"from": null,
3+
"to": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e",
44
"providedInvariants": [
55
"cipherstash:install-eql-v3-bundle-v1"
66
],
77
"createdAt": "2026-07-14T20:10:24.325Z",
8-
"migrationHash": "sha256:c33efc31bb927fa63361a7587052cdeff63fd133ab6f1af9a76ebdd7b7705e06"
8+
"migrationHash": "sha256:2c8739076699b81bcf515f1f8ff23501ff1f2582b933cfd80c5fb5bcc3de9e12"
99
}

examples/prisma/migrations/cipherstash/20260601T0100_install_eql_v3_bundle/ops.json

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
2-
"from": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
3-
"to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7",
2+
"from": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e",
3+
"to": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e",
44
"providedInvariants": [
55
"cipherstash:upgrade-eql-v3-bundle-3.0.2-v1"
66
],
77
"createdAt": "2026-07-20T11:40:17.876Z",
8-
"migrationHash": "sha256:4a7582a2a6fbd6d83b2809599f34d894b1b79036af22fa8616d775d24591a79b"
8+
"migrationHash": "sha256:7bb960435f9cdb7d7c25e4ff70b02fa050a1b8e695541facc47dd87ec3cc634e"
99
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"_generated":{"message":"This file is automatically generated by \"prisma-next contract emit\".","regenerate":"To regenerate, run: prisma-next contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"defaultInInsert":true,"enums":true,"lateral":true,"returning":true}},"domain":{"namespaces":{"public":{"models":{"EqlV2Configuration":{"fields":{"data":{"nullable":false,"type":{"codecId":"pg/jsonb@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"state":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}}},"relations":{},"storage":{"fields":{"data":{"column":"data"},"id":{"column":"id"},"state":{"column":"state"}},"namespaceId":"public","table":"eql_v2_configuration"}}}}}},"extensionPacks":{},"meta":{},"profileHash":"sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb","roots":{"eql_v2_configuration":{"model":"EqlV2Configuration","namespace":"public"}},"schemaVersion":"1","storage":{"namespaces":{"public":{"entries":{"table":{"eql_v2_configuration":{"columns":{"data":{"codecId":"pg/jsonb@1","nativeType":"jsonb","nullable":false},"id":{"codecId":"pg/text@1","nativeType":"text","nullable":false},"state":{"codecId":"pg/text@1","nativeType":"text","nullable":false}},"foreignKeys":[],"indexes":[],"primaryKey":{"columns":["id"]},"uniques":[]}}},"id":"public","kind":"postgres-schema"}},"storageHash":"sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7"},"target":"postgres","targetFamily":"sql"}
1+
{"_generated":{"message":"This file is automatically generated by \"prisma-next contract emit\".","regenerate":"To regenerate, run: prisma-next contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"defaultInInsert":true,"enums":true,"lateral":true,"returning":true,"scalarList":true}},"domain":{"namespaces":{"public":{"models":{}}}},"extensionPacks":{},"meta":{},"profileHash":"sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb","roots":{},"schemaVersion":"1","storage":{"namespaces":{"public":{"entries":{"table":{}},"id":"public","kind":"postgres-schema"}},"storageHash":"sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e"},"target":"postgres","targetFamily":"sql"}

0 commit comments

Comments
 (0)