Skip to content

feat(cipherstash): framework SPI + authoring surface (Phase 1)#416

Open
wmadden wants to merge 27 commits intomainfrom
tml-2373-project-1-searchable-encryption-mvp
Open

feat(cipherstash): framework SPI + authoring surface (Phase 1)#416
wmadden wants to merge 27 commits intomainfrom
tml-2373-project-1-searchable-encryption-mvp

Conversation

@wmadden
Copy link
Copy Markdown
Contributor

@wmadden wmadden commented May 1, 2026

closes TML-2373

Intent

This is Phase 1 of Project 1 — Searchable-encryption MVP. It lays the groundwork for @prisma-next/extension-cipherstash (a CipherStash/ZeroKMS-backed searchable-encryption extension) by extending the framework with the SPI cipherstash needs (M1) and shipping cipherstash's authoring surface and codec scaffolding against that SPI (M2.a, M2.b). The runtime path that wires bulk encryption against live ZeroKMS + EQL is intentionally deferred to Phase 2.

End-state of this PR: a user can author an EncryptedString({ equality, freeTextSearch }) column in PSL or TypeScript, emit a contract whose column type is eql_v2_encrypted, and observe the contract round-trip byte-identically across PSL and TS. Encryption, decryption, operator lowering, and migration factories all land in Phase 2.

Change map

The story

The cipherstash extension wants to do three things its predecessor (a CipherStash-built first attempt against prisma-next) couldn't cleanly express in the framework:

  1. Embed pre-validated SQL into the lowerer so a addSearchConfig({...}) migration factory can produce a real, type-safe SQL plan rather than a string-templated one. M1 adds the RawSqlExpr AST node, the Postgres lowerer arm that interleaves string/ParamRef segments through the SqlRenderer, and a planFromAst(ast, contract) helper that wraps a single AST node as a SqlExecutionPlan.

  2. Mutate outbound parameter values before encode runs so a bulk-encrypt middleware can rewrite cipherstash envelope plaintexts to ciphertexts in one network round-trip per query. M1 adds the SqlParamRefMutator seam on SqlMiddleware.beforeExecute, the per-execute MiddlewareContext.signal for cancellation forwarding, and the matching Mongo type-seam parity. Encode runs against the post-mutation params; reference-identity short-circuit when no middleware mutates.

  3. Author EncryptedString columns from PSL or the TS contract builder with full parity. M2.b adds the PSL constructor cipherstash.EncryptedString({ equality, freeTextSearch }), the TS factory encryptedString({...}), and a parity test that asserts byte-identical contract.json. The codec registers as cipherstash/string@1 with target type eql_v2_encrypted and a RuntimeParameterizedCodecDescriptor carrying the arktype paramsSchema for { equality?: boolean; freeTextSearch?: boolean }.

The runtime hookup that uses (1) and (2) — the migration factories that emit RawSqlExpr plans, the bulk-encrypt middleware that consumes the SqlParamRefMutator, and the live-EQL integration tests — is not in this PR. It's the next branch of work; the M2.c → M5 task plan lives in projects/cipherstash-integration/project-1/plan.md.

Behavior changes & evidence

Compatibility / migration / risk

  • All framework changes are additive. New AST node, new optional middleware seam, new optional signal field, new kind: 'boolean' arm — no existing API breaks. pnpm typecheck (125/125) and pnpm lint:deps (0 violations) pass at HEAD.
  • The cipherstash package is unconsumed by anything in main. No examples import it, no integration tests run against it outside its own package, no docs point users at it. Half-complete state on main is bounded — the placeholder EQL install constant and export {} middleware stub will be filled in Phase 2 before any external user is asked to adopt the extension.
  • Mongo runtime wiring is type-seam only, tracked in TML-2376. Project 1 is Postgres-only; Mongo parity is a framework-symmetry follow-up that doesn't gate cipherstash.
  • The two-codec split inside cipherstash (cipherstashStringCodecMetadata SDK-free for pack-meta + createCipherstashStringCodec(sdk) SDK-bound for runtime) is a deliberate local workaround for a framework-ergonomics gap. The clean fix threads SDK per-call via CodecCallContext and is tracked in TML-2388. The workaround is ~30 lines, fully isolated inside the cipherstash package, with misuse-pointing error messages on the metadata codec to make the pattern obvious.
  • No new ADR. All framework additions are SPI extensions of existing patterns. ADR-worthy changes (cipherstash threat model, encrypted-column DDL strategy) are documented in the cipherstash package's DEVELOPING.md and revisited if a second KMS extension lands.

Follow-ups / open questions

  • TML-2376 — Mongo middleware param-mutator runtime wiring. Defers resolveValue past beforeExecute in the Mongo adapter; outside Project 1's Postgres-only scope.
  • TML-2388 — Codec-SDK binding refactor. Threads SDK per-call via CodecCallContext instead of capturing it at codec construction; collapses cipherstash's two-codec workaround back into a single codec.
  • Phase 2 of Project 1 continues on a follow-on branch off main. Plan: bulk-encrypt middleware (T2.c.2), real EQL bundle (T2.c.1), live Postgres + EQL integration tests (T2.c.4..6), eq + ilike operator lowering (M3, M4), addSearchConfig + activatePendingSearches migration factories (M3, M4), decryptAll walker (M4). See projects/cipherstash-integration/project-1/plan.md § M2.c → M5.
  • Two design defaults flagged for the CipherStash team (validation, not gating): routing-key derivation (proposed (table, column), no per-column override) and plaintext zeroing (proposed: don't zero in Project 1). Question doc at projects/cipherstash-integration/project-1/cipherstash-team-questions.md.

Non-goals / intentionally out of scope

  • Bulk-encrypt middleware that wires SqlParamRefMutator against sdk.bulkEncrypt(...). Phase 2 (T2.c.2).
  • Real EQL bundle install — this PR ships a placeholder constant; Phase 2 vendors the ~170 KB SQL bundle from the reference repo (T2.c.1).
  • Live Postgres + EQL integration tests proving an end-to-end round-trip. Phase 2 (T2.c.4..6).
  • eq and ilike operator lowering against cipherstash columns. Phase 2 (M3, M4).
  • addSearchConfig and activatePendingSearches migration factories. Phase 2 (M3, M4).
  • decryptAll(rows) bulk-decrypt walker. Phase 2 (M4).
  • Other cipherstash column types (EncryptedNumber, EncryptedDate, EncryptedBoolean, EncryptedJson). Project 2.
  • Other operator families (orderAndRange, searchableJson). Project 2.
  • KMS provider abstraction across CipherStash + Vault + AWS KMS. Future, gated on a second KMS extension landing.

Summary by CodeRabbit

  • New Features

    • Added Cipherstash encrypted string extension with envelope-based encryption support and parameterized codec descriptors
    • Introduced raw SQL expression support in query AST for inline SQL fragments
    • Added parameter mutation system allowing middleware to transform query parameters before execution
    • Added boolean argument type support in authoring system
  • Improvements

    • Enhanced middleware context handling with per-execute abort signal propagation
    • Expanded abort phase tracking with beforeExecute, afterExecute, and onRow phases
  • Tests & Documentation

    • Added comprehensive test coverage for encryption envelopes, parameter mutation, and raw SQL rendering
    • Added extension development and usage documentation

Partial M1 work-in-progress from a stalled implementer round. Adds
RawSqlExpr to relational-core ast/types.ts and a first-pass test file.
Does NOT include:
  - AnyQueryAst / queryAstKinds / isQueryAst integration
  - Postgres lowerer arm
  - planFromAst helper
  - middleware-param-transform work

Workspace likely fails typecheck/test. Committed as a checkpoint per
explicit user request before push; next implementer round either
extends from here or hard-resets.

Refs: TML-2373
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive encryption extension and supporting middleware infrastructure. It adds generic parameter-mutation support to the framework runtime, implements a SQL param-ref mutator system with RawSqlExpr AST support, extends the Mongo family with equivalent mutator infrastructure, and delivers a complete Cipherstash extension for PostgreSQL with SDK-driven encryption codec integration, envelope lifecycle management, and control-plane DDL support.

Changes

Framework Runtime Mutator Infrastructure

Layer / File(s) Summary
Type Additions & Exports
packages/1-framework/1-core/framework-components/src/execution/runtime-error.ts
RuntimeAbortedPhase union expanded to include 'beforeExecute', 'afterExecute', 'onRow' phases alongside existing phases.
Context & Mutator Types
packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts
RuntimeMiddlewareContext gains optional signal?: AbortSignal property; new ParamRefMutator base type introduced; RuntimeMiddleware now generic over TMutator, with beforeExecute accepting optional params?: TMutator.
Core Runtime Changes
packages/1-framework/1-core/framework-components/src/execution/run-with-middleware.ts, runtime-core.ts
runWithMiddleware parameterized with TMutator generic; per-execute middleware context scoped with signal from execute() call; abort checks guarding beforeExecute invocations.
API Relaxation
packages/1-framework/1-core/framework-components/src/execution/race-against-abort.ts
checkAborted relaxed to accept generic context object { readonly signal?: AbortSignal } instead of CodecCallContext.
Public API Export
packages/1-framework/1-core/framework-components/src/exports/runtime.ts
ParamRefMutator added to runtime export surface.
Authoring System Extension
packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts
Boolean kind added to AuthoringArgumentDescriptor union; validator extended to handle boolean arguments.
Tests
packages/1-framework/1-core/framework-components/test/*
run-with-middleware-signal.test.ts validates consistent signal propagation; authoring.test.ts covers boolean argument handling.

SQL & Adapter Param-Ref System

Layer / File(s) Summary
AST Extension
packages/2-sql/4-lanes/relational-core/src/ast/types.ts
New RawSqlExpr class introduced extending QueryAst; AnyQueryAst updated to include RawSqlExpr; queryAstKinds extended with 'raw-sql'.
SQL Param-Ref Mutator
packages/2-sql/4-lanes/relational-core/src/middleware/param-ref-mutator.ts
Full implementation of ParamRefHandle (phantom-branded), ParamRefEntry, and SqlParamRefMutator with overloaded replaceValue/replaceValues; createSqlParamRefMutator factory walks plan.params, yields entries, and mutates in-place on lazy-copy semantics.
Middleware Wiring
packages/2-sql/4-lanes/relational-core/src/exports/middleware.ts, index.ts
Public re-exports for ParamRefHandle, ParamRefEntry, ParamRefEntryUnion, SqlParamRefMutator, createSqlParamRefMutator; tsdown entry added.
Plan Construction
packages/2-sql/4-lanes/relational-core/src/plan.ts
planFromAst utility added to construct SqlQueryPlan from AST + Contract, populating meta from contract and defaulting params/laneId.
SQL Runtime Integration
packages/2-sql/5-runtime/src/middleware/sql-middleware.ts
SqlMiddleware now generic over TCodecMap; extends RuntimeMiddleware with SqlParamRefMutator; beforeExecute expanded to accept optional params?: SqlParamRefMutator<TCodecMap>.
Execution Pipeline
packages/2-sql/5-runtime/src/sql-runtime.ts
Per-execute middleware context with signal; upfront plan lowering; runWithMiddleware drives encoding with param mutation before driver invocation; decoding operates on lowered plan; telemetry updated.
AST Handling
packages/2-sql/5-runtime/src/codecs/decoding.ts, middleware/lints.ts
projectionListFromAst and lints switch both handle 'raw-sql' kind (returning undefined and no-op respectively).
Postgres Adapter
packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts
renderLoweredSql dispatcher extended with 'raw-sql' case; renderRawSql function interpolates fragments with rendered arguments.
Authoring Support
packages/2-sql/2-authoring/contract-psl/src/psl-authoring-arguments.ts, contract-ts/src/authoring-type-utils.ts
parsePslAuthoringArgumentValue and ArgTypeFromDescriptor both gain boolean-kind handling.
Tests
packages/2-sql/4-lanes/relational-core/test/middleware/*, test/plan-from-ast.test.ts, packages/2-sql/5-runtime/test/*, postgres/test/*
Comprehensive param-ref mutator tests (enumeration, mutation, type narrowing), plan construction tests, SQL runtime mutator integration (mutation, writeback, abort handling), RawSqlExpr encoding, Postgres rendering tests.
Package Config
relational-core/package.json, tsdown.config.ts
Export entry added for ./middleware; tsdown entry added for middleware exports.

Mongo Family Param-Ref Mutator

Layer / File(s) Summary
Type Infrastructure
packages/2-mongo-family/7-runtime/src/mongo-param-ref-mutator.ts
MongoParamRefHandle (phantom-branded), MongoParamRefEntry, MongoParamRefEntryUnion, and MongoParamRefMutator defined with codec-map generics; flattenMongoParamRefs walks object/array trees yielding MongoParamRef instances; createMongoParamRefMutator factory collects refs, maintains replacement map, exposes entries and mutation methods.
Middleware Integration
packages/2-mongo-family/7-runtime/src/mongo-middleware.ts
MongoMiddleware now generic over TCodecMap; extends RuntimeMiddleware with MongoParamRefMutator; beforeExecute signature updated with optional params?: MongoParamRefMutator<TCodecMap>.
Public Exports
packages/2-mongo-family/7-runtime/src/exports/index.ts
Type and value exports for MongoParamRef infrastructure re-exported from mongo-param-ref-mutator.
Tests
packages/2-mongo-family/7-runtime/test/mongo-param-ref-mutator.test.ts
Flattenning, entries enumeration, single/bulk mutation, and zero-ref handling validated.

Cipherstash Extension & Integration

Layer / File(s) Summary
Core Infrastructure
packages/3-extensions/cipherstash/src/core/*
envelope.ts defines EncryptedString with WeakMap-backed handle storage, decrypt lifecycle, and plaintext caching; sdk.ts declares framework-native SDK contracts (routing, single/bulk encrypt/decrypt); codecs.ts implements runtime codec factory binding SDK; authoring.ts registers EncryptedString typeConstructor; parameterized.ts wires per-SDK shared codec with params schema; descriptor-meta.ts defines extension pack metadata; eql-bundle.ts provides placeholder SQL bundle path.
Public API Surface
packages/3-extensions/cipherstash/src/exports/*
index.ts exports EncryptedString and SDK types; column-types.ts provides encryptedString factory for SQL column descriptors; control.ts wires SqlControlExtensionDescriptor with database-dependency EQL installation; runtime.ts exposes createCipherstashRuntimeDescriptor factory; middleware.ts placeholder for future bulkEncryptMiddleware; pack.ts re-exports descriptor metadata.
Build & Config
packages/3-extensions/cipherstash/{package.json, tsconfig*.json, vitest.config.ts, tsdown.config.ts, biome.jsonc}
Standard extension package setup with ES module exports, workspace dependencies, test/build configuration.
Documentation
packages/3-extensions/cipherstash/{README.md, DEVELOPING.md}
User-facing README covering envelope, codec, PSL syntax, and database setup; developer-facing DEVELOPING guide on source layout, surface, design choices.
Unit Tests
packages/3-extensions/cipherstash/test/*
authoring.test.ts validates typeConstructor registration; codecs.test.ts covers codec id/traits, decode envelope construction, encode ciphertext extraction, renderOutputType; column-types.test.ts validates factory and defaults; control.test.ts verifies control descriptor and EQL installation shape; envelope.test.ts validates write/read-side lifecycle and SDK integration; envelope.test-d.ts enforces handle privacy; runtime.test.ts validates descriptor traits and SDK-bound factory.
PSL Integration Tests
test/integration/test/authoring/parity/cipherstash-encrypted-string/*
schema.prisma defines EncryptedDoc model with cipherstash.EncryptedString fields; contract.ts builds SQL contract equivalent; expected.contract.json provides snapshot of lowered contract; packs.ts exports extension pack list.
Architecture & Framework Wiring
architecture.config.json, test/integration/package.json
cipherstash entries added to extension domain/layer/plane mappings; @prisma-next/extension-cipherstash dependency added to integration tests.
DDL Snapshot Test
test/integration/test/authoring/cipherstash-dbinit-snapshot.test.ts
Validates CREATE TABLE generation for cipherstash columns with correct native type eql_v2_encrypted and byte-exact snapshot matching.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Runtime
    participant Middleware as BeforeExecute<br/>Middleware
    participant Mutator
    participant Encoder
    participant Driver

    Client->>Runtime: execute(plan, {signal})
    Runtime->>Runtime: Create per-execute<br/>middleware context<br/>(with signal)
    Runtime->>Middleware: runWithMiddleware(ctx)
    Middleware->>Middleware: Check abort signal
    Middleware->>Mutator: Call beforeExecute<br/>(plan, ctx, params)
    Mutator->>Mutator: Enumerate/mutate<br/>param refs
    Mutator-->>Middleware: Mutation complete
    Middleware->>Encoder: Encode params<br/>(with mutations)
    Encoder-->>Middleware: Encoded values
    Middleware->>Driver: Execute query<br/>(with encoded params)
    Driver-->>Middleware: Row stream
    Middleware-->>Runtime: Rows
    Runtime->>Runtime: Decode rows
    Runtime-->>Client: Results
Loading
sequenceDiagram
    participant PSL
    participant Parser
    participant Authoring
    participant Contract
    participant Storage

    PSL->>Parser: model EncryptedDoc {<br/>email cipherstash.EncryptedString(...)
    Parser->>Authoring: Parse type arguments<br/>(equality: bool, freeTextSearch: bool)
    Authoring->>Authoring: Validate boolean args<br/>via schema
    Authoring->>Contract: Emit codec descriptor<br/>(cipherstash/string@1)
    Contract->>Storage: Map field to<br/>eql_v2_encrypted<br/>column type
    Storage-->>Contract: Storage metadata
    Contract-->>Parser: EncryptedDoc model<br/>with encrypted<br/>columns
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

  • prisma/prisma-next#381: Modifies framework-components runtime surfaces (runWithMiddleware, runtime-core, runtime-middleware) that are foundational to this PR's generic mutator infrastructure.
  • prisma/prisma-next#256: Refactors ParamRef model and propagation through AST, adapters, and middleware—infrastructure this PR's mutators depend on.
  • prisma/prisma-next#253: Modifies relational-core AST surface (queryAstKinds, kind discriminants) that this PR extends with RawSqlExpr.

Suggested Reviewers

  • SevInf
  • aqrln

🐰 A mutator hops through params so spry,
RawSql fragments woven, cipherstash nearby,
Signals abort with grace, envelopes encrypt tight,
From framework to Postgres, the schema shines bright!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tml-2373-project-1-searchable-encryption-mvp

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 1, 2026

Open in StackBlitz

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-runtime@416

@prisma-next/family-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-mongo@416

@prisma-next/sql-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-runtime@416

@prisma-next/family-sql

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-sql@416

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-arktype-json@416

@prisma-next/extension-cipherstash

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-cipherstash@416

@prisma-next/middleware-telemetry

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/middleware-telemetry@416

@prisma-next/mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo@416

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-paradedb@416

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-pgvector@416

@prisma-next/postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/postgres@416

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-orm-client@416

@prisma-next/sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sqlite@416

@prisma-next/target-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-mongo@416

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-mongo@416

@prisma-next/driver-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-mongo@416

@prisma-next/contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract@416

@prisma-next/utils

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/utils@416

@prisma-next/config

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/config@416

@prisma-next/errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/errors@416

@prisma-next/framework-components

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/framework-components@416

@prisma-next/operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/operations@416

@prisma-next/ts-render

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ts-render@416

@prisma-next/contract-authoring

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-authoring@416

@prisma-next/ids

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ids@416

@prisma-next/psl-parser

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-parser@416

@prisma-next/psl-printer

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-printer@416

@prisma-next/cli

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/cli@416

@prisma-next/emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/emitter@416

@prisma-next/migration-tools

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/migration-tools@416

prisma-next

npm i https://pkg.pr.new/prisma/prisma-next@416

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/vite-plugin-contract-emit@416

@prisma-next/mongo-codec

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-codec@416

@prisma-next/mongo-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract@416

@prisma-next/mongo-value

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-value@416

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-psl@416

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-ts@416

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-emitter@416

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-schema-ir@416

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-ast@416

@prisma-next/mongo-orm

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-orm@416

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-builder@416

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-lowering@416

@prisma-next/mongo-wire

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-wire@416

@prisma-next/sql-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract@416

@prisma-next/sql-errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-errors@416

@prisma-next/sql-operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-operations@416

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-schema-ir@416

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-psl@416

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-ts@416

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-emitter@416

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-query-builder@416

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-relational-core@416

@prisma-next/sql-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-builder@416

@prisma-next/target-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-postgres@416

@prisma-next/target-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-sqlite@416

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-postgres@416

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-sqlite@416

@prisma-next/driver-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-postgres@416

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-sqlite@416

commit: 8ceebb5

cursoragent and others added 26 commits May 1, 2026 17:38
Adds the `raw-sql` arm to two AnyQueryAst exhaustive switches in
`@prisma-next/sql-runtime`:

- `projectionListFromAst` (codecs/decoding.ts): returns `undefined` for
  raw-sql plans because they have no projection list to align decode
  metadata against. Decoding then falls through to the same no-codec
  path used for ast-less plans (driver row keys flow through unchanged),
  which is the correct behavior for raw-SQL plans whose result shape is
  not statically known to the framework.
- `evaluateAstLints` (middleware/lints.ts): no-op arm. Raw-SQL plans
  are not subject to the AST lints (full-table-scan / etc.) that target
  structured queries — those lints have no semantic anchor on a
  RawSqlExpr.

Both changes keep the existing exhaustiveness check intact: the
`satisfies never` default still triggers when a future AnyQueryAst arm
is added without updating these sites.

Tests-first: covered by the existing raw-sql-expr-encode + sql-runtime
suites (no regression), with the new arms exercised structurally by
the upcoming AC-PLAN3 / AC-E2E1 tests landing in following commits.

M1 -- raw-sql-ast-node
Refs: TML-2373
…OW1..6)

Adds the `raw-sql` arm to `renderLoweredSql` in the Postgres SQL
renderer. Implementation is a single-pass interleave of `fragments`
and `args`:

  fragments[0] + render(args[0]) + fragments[1] + ... + fragments[n]

ParamRefs lower through the existing `renderParamRef` chokepoint, so
positional placeholder numbering, codec-driven cast emission
(`$N::nativeType`), and ParamRef identity dedupe via
`collectOrderedParamRefs` are all reused unchanged. Inlined
`AnyExpression` args (typed-builder sub-expressions) lower through
the standard `renderExpr` dispatch.

Tests cover the AC-LOW family from the raw-sql-ast-node task spec:

- AC-LOW1: single ParamRef -> `$1` substitution + value lifted into
  params.
- AC-LOW2: multiple ParamRefs -> `$1, $2, ...` in source order.
- AC-LOW3: inlined typed-builder expression (BinaryExpr) lowers via
  renderExpr, sub-params append in canonical order.
- AC-LOW5: zero-arg raw lowers to its single fragment with empty
  params.
- Empty leading fragment (template-literal `${value} suffix` shape)
  renders correctly.
- Repeated ParamRef identity dedupes to a single $N (collectOrderedParamRefs
  semantics shared with the rest of the renderer).
- AC-LOW6 (defense in depth, finding-prompted addition): ParamRef
  values are never text-inlined into the rendered SQL. Asserts the
  exact shape cipherstash`s `addSearchConfig` migration factory uses
  -- `users` and `email` appear only in the params array, not in the
  rendered sql string.

AC-LOW4 (async codec resolution over a RawSqlExpr-backed plan) is
covered by the sql-runtime suite and lands in a following commit.

M1 -- raw-sql-ast-node
Refs: TML-2373
…AC-PLAN1..3)

Introduces `planFromAst(ast, contract, laneId?)` in
`@prisma-next/sql-relational-core/plan` -- a small helper that wraps
an `AnyQueryAst` (typically a `RawSqlExpr` constructed package-internally
by an extension`s migration factory) in a fully-populated
`SqlQueryPlan`.

Why centralize this:

- Every consumer that constructs a raw-SQL plan today (cipherstash`s
  upcoming migration factories first; future raw-SQL callers next)
  needs to populate `meta.target`, `meta.targetFamily`,
  `meta.storageHash`, and `meta.lane` exactly the same way.
- Without the helper, each call site hand-rolls the meta object and
  drift on `storageHash` surfaces only at runtime as a subtle
  `assertContractMatches` failure inside `dataTransform`. The helper
  pins the storage-hash source to `contract.storage.storageHash`.
- `params: []` is correct: parameters embedded as `ParamRef`s in the
  AST are resolved at lowering time
  (`encodeParams` walks `plan.ast.collectOrderedParamRefs()`), not
  at plan-construction time.
- `laneId` defaults to `"raw"` (matching SQL raw plans` standard
  lane tag) and is overridable; future `sql-raw-factory` outputs
  may distinguish themselves via a different lane.

Tests cover AC-PLAN1, AC-PLAN2, and AC-PLAN3 (the latter via an
end-to-end test in `adapter-postgres` that hands a `planFromAst`
result to `dataTransform({ run: () => plan }, adapter)` and verifies
`assertContractMatches` does not throw -- the upstream call site that
would otherwise drift). target / targetFamily forwarding and the
ast/params projection are also covered.

M1 -- raw-sql-ast-node
Refs: TML-2373
…-LOW4)

Adds the AC-LOW4 coverage point from the raw-sql-ast-node task spec:
async codec resolution (per ADR 204) works for ParamRefs interpolated
inside a `RawSqlExpr` AST identically to typed-builder-embedded
ParamRefs.

The test stands up an `encodeParams` invocation over a manually-
constructed `SqlExecutionPlan` whose `ast` is a `RawSqlExpr.of(...)`
carrying two `ParamRef`s with codec ids on a registry that resolves
one async-authored and one sync-authored codec. Asserts both encode
through their codecs (`wire:email` and `7 * 10 = 70`).

This pins the structural invariant that `encodeParams` walks the
AST`s `collectOrderedParamRefs()` rather than dispatching on AST kind:
metadata-by-index alignment falls out of the existing per-AST-kind
`collectOrderedParamRefs` implementation, including the new `raw-sql`
arm added in the prior commit.

M1 -- raw-sql-ast-node
Refs: TML-2373
…ntext (AC-ABT1)

Threads the per-query AbortSignal supplied to `runtime.execute(plan, { signal })`
into every middleware phase via `RuntimeMiddlewareContext.signal` -- the same
reference also carried on `CodecCallContext.signal` per ADR 207.

The base `RuntimeMiddlewareContext` gains an optional readonly `signal` field;
`RuntimeCore.execute` allocates a per-execute middleware ctx that merges the
caller-supplied signal onto the persistent ctx for the lifetime of the call.
Identity matches `codecCtx.signal` so middleware authors who compare
`ctx.signal` across the codec/middleware boundary observe the same reference.

Symmetric plumbing across all middleware phases (`beforeExecute`, `onRow`,
`afterExecute`) -- not just `beforeExecute` -- because middleware that wraps
a downstream observability hook or post-processor needs the same cancellation
reach. Existing middleware that ignores `ctx.signal` is unaffected (additive
optional field).

Tests-first: AC-ABT1 covered by `run-with-middleware-signal.test.ts` --
identity equality across the three phases plus an undefined-signal pin.

M1 -- middleware-param-transform
Refs: TML-2373
…ecute

Lands the middleware param-transform seam end-to-end on the SQL family:

- Adds the `ParamRefMutator` marker on `RuntimeMiddleware` and threads a
  family-specific mutator into `beforeExecute(plan, ctx, params)` as an
  additive, optional third argument. Existing `(plan)` / `(plan, ctx)`
  middleware bodies continue to compile and run unchanged
  (TypeScript trailing-parameter bivariance).
- Introduces `SqlParamRefMutator<TCodecMap>` in
  `@prisma-next/sql-relational-core/middleware` with branded
  `ParamRefHandle`, discriminated `ParamRefEntryUnion`, and overloaded
  `replaceValue` that infers `newValue` from the codec is `TInput`
  (AC-MUT1..4).
- `createSqlParamRefMutator(plan)` walks the plan AST for ordered
  `ParamRef`s and lazily allocates a working params buffer only on the
  first mutation. With no mutations, `currentParams()` returns
  `plan.params` by reference identity (AC-MUT5).
- `SqlRuntimeImpl.executeAgainstQueryable` now lowers without encoding,
  builds the mutator from the lowered plan, and runs `encodeParams`
  inside the runDriver thunk so encode sees the post-mutation values.
  The per-execute middleware ctx carries the same `signal` reference
  threaded into `codecCtx.signal` (ADR 207 identity).
- `runWithMiddleware` pre-checks `ctx.signal` before each middleware
  body (AC-ABT2 -> phase: beforeExecute) and races each body against
  the signal via `raceAgainstAbort` so deaf bodies still surface
  `RUNTIME.ABORTED` promptly (AC-ABT3). Non-abort errors pass through
  unchanged (AC-ABT4).
- End-to-end fixture asserts the bulk pattern: a middleware walks
  `entries()`, filters by codec id, makes one async call, and writes
  back via `replaceValues`; the driver receives encoded ciphertexts
  (AC-EX1).
- Allocation-discipline test confirms `plan.params` reaches encode by
  reference identity when no middleware mutates.

Closes AC-MUT1..5, AC-EX1, AC-ABT2..4 of middleware-param-transform.
Brings Mongo to parity with the SQL middleware seam:

- `MongoMiddleware` is now generic over `TCodecMap` and extends
  `RuntimeMiddleware<MongoExecutionPlan, MongoParamRefMutator<TCodecMap>>`,
  sharing the framework-level `MiddlewareContext` shape (AC-FAM1).
  `beforeExecute(plan, ctx, params)` declares the additive third
  argument; existing two-arg bodies still compile.
- `MongoParamRefMutator` mirrors the SQL family: branded
  `MongoParamRefHandle`, discriminated `MongoParamRefEntryUnion`,
  overloaded `replaceValue` that infers from `TCodecMap`.
  `flattenMongoParamRefs(value)` walks an arbitrary Mongo lowered
  tree (objects, arrays, leaves) yielding `MongoParamRef` instances
  in pre-order. `createMongoParamRefMutator(tree)` builds a mutator
  whose `entries()` exposes those refs (AC-FAM2).
- Wiring into `MongoRuntime` is intentionally deferred: Mongo lowering
  resolves `MongoParamRef`s into raw values today, so threading the
  mutator end-to-end requires deferring `resolveValue` past the
  middleware chain. The mutator types and flatten helper land here so
  extension authors can target the seam ahead of that wiring.

Closes AC-FAM1..2 of middleware-param-transform (extension-author seam).
Wiring into MongoRuntime is tracked separately.
…YPE1..2)

Covers the type-level surface of the mutator:

- `replaceValue(ref, newValue)` infers `newValue` from the codec is
  declared `TInput` for codec ids resolvable via `TCodecMap` (AC-TYPE1).
  Discriminating on `entry.codecId` narrows `entry.ref` to a typed
  `ParamRefHandle<K>` that routes through the typed overload.
- Negative type test pinned by `@ts-expect-error`: passing a wrong-shape
  value (number where the codec wants string, etc.) is a compile error
  (AC-TYPE2). `@ts-expect-error` is permitted here per the negative-
  type-test allowance in AGENTS.md.
- Brand check pinned by `@ts-expect-error`: callers cannot fabricate
  a `ParamRefHandle` from an object literal (AC-MUT4 reinforced at
  the type level).

Closes AC-TYPE1..2 of middleware-param-transform.
…wiring deferral

Adds an Open item documenting that `middleware-param-transform` shipped
the Mongo type seam + flatten helper + unit tests in M1, but end-to-end
runtime wiring (threading a MongoParamRefMutator through beforeExecute)
was deferred because it requires an architectural change: defer
resolveValue past the middleware chain in mongo-adapter.lower().

Project 1 scope is Postgres-only, so the deferral does not block
cipherstash. Framework symmetry follow-up is tracked at TML-2376.

Refs: TML-2373, TML-2376
…/PKG2/PKG3)

Scaffold `@prisma-next/extension-cipherstash` mirroring the
`packages/3-extensions/pgvector/` shape: package.json, tsconfig
(base + prod), biome, vitest, tsdown configs, and the README.

Subpath exports match the layout enumerated in
`projects/cipherstash-integration/project-1/specs/envelope-codec-extension.spec.md`
§ Subpath exports: `.`, `./column-types`, `./control`,
`./middleware`, `./runtime`. The `column-types` and `middleware`
subpaths ship as placeholder modules in M2.a so the surface
matches the spec immediately; M2.b populates `column-types` (TS
factory) and M2.c populates `middleware` (bulkEncryptMiddleware).

Architecture mapping wires the package globs into
architecture.config.json so `pnpm lint:deps` validates layering
(`extensions/adapters/{shared,migration,runtime}` per the
pgvector precedent).

Phase: M2.a (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…k (M2.a AC-ENV1/ENV2/ENV4)

The envelope is the user-facing surface for encrypted columns; the
SDK shape is the framework-native contract that the codec, the
bulk-encrypt middleware (M2.c), and `decryptAll` (M4) all consume.

`EncryptedString.from(plaintext)` (AC-ENV1)
- Constructs a write-side envelope. Plaintext lives on a
  module-private handle stored in a `WeakMap` keyed on the
  envelope; no `EncryptedStringHandle` type is exported and no
  public accessor returns the handle.
- `decrypt()` returns the cached plaintext synchronously. The
  bulk-encrypt middleware (M2.c) overwrites the plaintext slot
  post-encrypt so subsequent `decrypt()` calls would route
  through the read-side path described below.

`EncryptedString.fromInternal({ ciphertext, table, column, sdk })`
- Constructs a read-side envelope. Used by the codec`s `decode`
  body in M2.a. The handle carries the SDK reference so
  `decrypt({ signal? })` can issue the SDK`s single-cell
  `decrypt` (AC-ENV2) and forward the caller-provided
  AbortSignal by identity.

Handle storage decision: WeakMap rather than `#`-prefixed fields.
Both options give the same package-internal isolation; the
WeakMap shape keeps `Object.keys(envelope)` and the default
`JSON.stringify` shape trivially clean across every JS host
without per-class `toJSON` workarounds. A `toJSON()` override
ships anyway to produce the documented `{ "$encryptedString":
"<opaque>" }` placeholder per the open-question default in
`envelope-codec-extension.spec.md`.

`CipherstashSdk` interface declares three async methods:
- `decrypt(args)` — single-cell read used by `EncryptedString#decrypt()`.
- `bulkEncrypt(args)` — write-side coalesced, consumed by M2.c.
- `bulkDecrypt(args)` — read-side coalesced, consumed by M4.

Each method accepts an optional AbortSignal forwarded directly to
the SDK per the umbrella cancellation contract. The shape is
deliberately smaller than the first-attempt SDK
(`reference/cipherstash/stack/...`) which is rich and
Prisma-adapter shaped; the framework-native shape lets cipherstash
wrap any SDK that exposes the three operations.

Tests:
- `test/envelope.test.ts` covers AC-ENV1 (write-side construct +
  decrypt), AC-ENV2 (read-side decrypt forwards signal by
  identity, returns SDK output, omits absent signal), AC-ENV4
  (handle has no own enumerable property, JSON.stringify does not
  leak plaintext, prototype exposes only `decrypt` and `toJSON`).
- `test/envelope.test-d.ts` is the negative type test for AC-ENV4:
  `envelope.handle` / `.plaintext` / `.ciphertext` are all
  rejected by the type checker, and the public namespace does not
  export an `EncryptedStringHandle` type. Pattern follows
  `packages/3-extensions/arktype-json/test/arktype-json-codec.test-d.ts`.

Phase: M2.a (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
… descriptor (M2.a AC-CODEC1..5)

`createCipherstashStringCodec(sdk)` builds the SQL codec via the
existing `codec({ ... })` factory in
`@prisma-next/sql-relational-core/ast`:

- `typeId: "cipherstash/string@1"`, `targetTypes:
  ["eql_v2_encrypted"]`, `traits: ["equality"]`, and
  `meta.db.sql.postgres.nativeType: "eql_v2_encrypted"` (AC-CODEC1).
- `decode(wire, ctx)` requires `ctx.column` and constructs an
  `EncryptedString.fromInternal({ciphertext, table, column, sdk})`
  capturing the codec`s SDK reference for read-side single-cell
  decrypt (AC-CODEC2).
- `encode(envelope, ctx)` reads the ciphertext from the
  envelope`s handle and surfaces a clear error if the bulk-encrypt
  middleware did not run before encode (AC-CODEC3).
- `renderOutputType` returns `"EncryptedString"` (AC-CODEC4).

The codec is constructed *per SDK binding*, not as a module
singleton: cipherstash`s codec depends on the SDK at decode time,
so multi-tenant deployments construct one extension descriptor per
tenant, each with its own captured SDK. This differs from
pgvector (whose codec is fully stateless and can be a module
singleton).

`createParameterizedCodecDescriptors(sdk)` exposes the
`RuntimeParameterizedCodecDescriptor<{equality, freeTextSearch}>`
shape via the unified post-#402 model in `@prisma-next/sql-runtime`:

- `paramsSchema` is an arktype `{equality: boolean,
  freeTextSearch: boolean}` schema validated at the JSON boundary
  (AC-CODEC5).
- `factory(params)(ctx)` returns the shared codec — the
  search-mode flags do not affect the per-cell encode body on the
  write side (the middleware did the actual encryption); they
  affect operator lowering and the migration factories (M3/M4).
  Future per-instance state can move into the closure without
  changing the descriptor shape.

`createCipherstashRuntimeDescriptor({ sdk })` assembles a
`SqlRuntimeExtensionDescriptor<"postgres">` mirroring pgvector`s
`runtime.ts`: codec registry, `parameterizedCodecs`,
`types.codecTypes.codecInstances`, `create()` returning the SQL
extension instance.

Tests:
- `test/codecs.test.ts` exercises AC-CODEC1..4 (registration
  shape, decode reads ctx.column, encode reads the handle, error
  when ctx.column is missing or ciphertext is empty,
  renderOutputType).
- `test/runtime.test.ts` exercises AC-CODEC5 (descriptor list
  shape, params-schema acceptance/rejection, factory wiring).

Phase: M2.a (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…trol descriptor (M2.a AC-INSTALL1)

`cipherstashControlDescriptor` is the
`SqlControlExtensionDescriptor<"postgres">` consumed by the
framework`s `dbInit` machinery. Mirrors pgvector`s
`databaseDependencies.init` shape from
`packages/3-extensions/pgvector/src/exports/control.ts`:

- One `init` entry `postgres.extension.eql` with one `install`
  step `eql.install` of class `additive` targeting `postgres`.
- Precheck SELECTs `cs_configuration_v2` to short-circuit when
  EQL is already installed (idempotency hook for AC-INSTALL3,
  exercised in M2.c).
- Postcheck SELECTs `eql_v2` schema to confirm install
  succeeded (AC-INSTALL2 hook, exercised in M2.c).

The `execute` step ships a placeholder SQL string
(`"-- TODO M2.c: vendor EQL_INSTALL_SQL from
reference/cipherstash/.../eql-bundle.ts"`) sourced from
`src/core/eql-bundle.ts`; the real ~170 KB bundle lands in M2.c
when the live-Postgres + live-EQL integration tests come online
and exercise AC-INSTALL2 / AC-INSTALL3 end-to-end.

`test/control.test.ts` pins AC-INSTALL1 at the shape level: init
entry id, install op shape, precheck/execute/postcheck wiring,
the placeholder marker, and the sql-family postgres targeting.

Phase: M2.a (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…tring.decrypt (F4)

Replace the inline conditional spread

  ...(opts?.signal ? { signal: opts.signal } : {})

with the repo`s `ifDefined` helper from `@prisma-next/utils/defined`
per `.cursor/rules/use-if-defined.mdc`. The rule explicitly marks
the inline spread pattern as ❌ WRONG and prescribes the helper
both for readability and for the documented behavior (only
`undefined` is excluded; falsy values are preserved).

Adds `@prisma-next/utils` as a workspace dep so the import
resolves; mirrors the dep wiring in
`packages/3-targets/6-adapters/postgres/package.json`. The single
inline-spread call site in the cipherstash package was the one
the reviewer cited (envelope.ts:140); no other call sites exist.

Phase: M2.a R2 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…LOPING.md (F3)

`README.md` previously linked twice into
`projects/cipherstash-integration/project-1/...`, violating the
always-applied workspace rule `.cursor/rules/doc-maintenance.mdc`
("Docs must not link to transient project artifacts under
`projects/`"). Pgvector`s README is the durable-link precedent —
it links only to ADRs (e.g. ADR 208), the Architecture Overview,
and external docs (pgvector docs, Extension Packs guide).

Resolution mirrors pgvector`s pattern:

- README now points at the durable docs that materially affect
  cipherstash`s shape: ADR 208 (the parameterized-codec
  descriptor model the codec uses), ADR 207 (the per-call codec
  context the codec`s `decode` reads), and the Architecture
  Overview. The transient project pointers are gone.
- A short `DEVELOPING.md` collects the contributor-facing detail
  the README previously inlined: source layout, implemented vs
  forthcoming surface, and the design choices worth knowing
  (handle storage, codec-is-SDK-scoped, framework-native SDK
  interface, EQL placeholder strategy). Mirrors the
  `DEVELOPING.md` precedent under
  `packages/2-sql/2-authoring/contract-ts/`.

The README links to `DEVELOPING.md` for contributor-level
detail per the doc-maintenance rule`s "User-facing packages
keep README focused; contributor details in DEVELOPING.md"
guidance.

Phase: M2.a R2 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
… TSDoc (F3 follow-up)

Two stub-export files (`src/exports/column-types.ts` and
`src/exports/middleware.ts`) carried TSDoc comments that linked
into `projects/cipherstash-integration/project-1/specs/...`. Same
violation as F3 but in source comments rather than the README, so
the same workspace rule applies (`.cursor/rules/doc-maintenance.mdc`:
"Docs must not link to transient project artifacts under
`projects/`").

Both TSDocs now point at `DEVELOPING.md § Forthcoming surface`,
which is the canonical inventory of in-progress milestones (M2.b
column-types factory, M2.c middleware) for this package. A
contributor opening either stub gets the same context the README
reader gets, without any link into the transient project tree.

Pgvector has no equivalent stub-export precedent (its exports are
all implemented), so the `DEVELOPING.md` pointer matches the
README → DEVELOPING.md pattern established in the F3 commit
(`e03acf453`).

Phase: M2.a R2 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
The framework`s `AuthoringArgumentDescriptor` discriminated union supported
four leaf kinds (`string`, `number`, `stringArray`, `object`) but not
`boolean`. Extensions that need boolean authoring args — e.g. cipherstash`s
`cipherstash.EncryptedString({ equality, freeTextSearch })` constructor —
had no way to declare typed boolean properties or get span-attached PSL
diagnostics for wrong-typed boolean inputs.

Adds `kind: "boolean"` end-to-end:

- `framework-authoring.ts` — extends the descriptor union and the
  `validateAuthoringArgument` switch to produce
  "must be a boolean" runtime errors for non-boolean values.
- `psl-authoring-arguments.ts` — extends `parsePslAuthoringArgumentValue`
  to accept top-level `true` / `false` literals (the existing nested-
  object path already handled boolean leaves via `parseJsLikeLiteral`).
- `authoring-type-utils.ts` — extends `ArgTypeFromDescriptor` so the
  generated TS factory types resolve nested boolean properties to
  `boolean` rather than `never`.

Backwards-compatible additive change; no existing extension uses a kind
that would conflict. Pgvector and family-sql/postgres-target authoring
types continue to work unchanged.

Phase: M2.b R1 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…tor + pack meta (M2.b AC-CTOR1..4, AC-LOWER1..3, AC-ALIAS1..2)

Adds the authoring contributions for cipherstash so users can declare
encrypted columns directly in PSL:

  model User {
    id    Int    @id
    email cipherstash.EncryptedString({ equality: true, freeTextSearch: true })
    notes cipherstash.EncryptedString({})?
  }

The single-object-argument shape (per the orchestrator`s standing
M2 decision) carries two optional booleans (`equality`,
`freeTextSearch`), each defaulting to `false`. Storage-only
encryption is the legitimate default.

Mirrors `packages/3-extensions/pgvector` structurally:

- `core/authoring.ts` — `cipherstashAuthoringTypes` registers
  `cipherstash.EncryptedString` as a `kind: "typeConstructor"` whose
  output template lowers the args to a `ColumnTypeDescriptor` with
  `codecId: "cipherstash/string@1"`,
  `nativeType: "eql_v2_encrypted"`, and
  `typeParams: { equality, freeTextSearch }` (defaults applied via
  `AuthoringArgRef.default`).

- `core/descriptor-meta.ts` — `cipherstashPackMeta` carries
  `authoring`, `types.codecTypes.codecInstances`, and
  `types.storage`. The `codecInstances` slot uses a new
  `cipherstashStringCodecMetadata` (SDK-free) so pack-meta consumers
  can read codec metadata at contract emit time without needing to
  bind an SDK; runtime descriptors continue to use the SDK-bound
  codec from `createCipherstashStringCodec(sdk)` for actual
  encode/decode.

- `exports/pack.ts` — re-exports the pack meta as the default
  export, mirroring `pgvector/src/exports/pack.ts`.

- `exports/control.ts` — spreads the pack meta into the control
  descriptor (so `databaseDependencies` ships alongside
  `authoring`, `capabilities`, etc.) instead of redeclaring the
  `id`/`familyId`/`targetId`/`version` triplet inline.

- `exports/runtime.ts` — pulls `id` and `version` from the pack
  meta to keep the two descriptors in sync.

Tests:

- `test/authoring.test.ts` (4 tests) — pack-meta exposes the
  constructor at the canonical path, declares the boolean-property
  options object, and registers the storage type.
- `test/psl-interpretation.test.ts` (8 tests) — full
  PSL→ColumnTypeDescriptor lowering through
  `interpretPslDocumentToSqlContract`:
   - AC-LOWER1: full args → typeParams populated.
   - AC-LOWER2: empty `{}` → `false` defaults applied.
   - AC-LOWER3: `?` → nullable: true.
   - AC-CTOR3: unknown name → `PSL_INVALID_ATTRIBUTE_ARGUMENT`.
   - AC-CTOR4: wrong type → `PSL_INVALID_ATTRIBUTE_ARGUMENT`
     mentioning "boolean".
   - AC-ALIAS1: `types { ... }` alias resolves and is reachable
     from a model field via `typeRef`.
   - AC-ALIAS2: alias-form named-type descriptor matches the
     inline-form column`s codec/nativeType/typeParams.
   - Span: diagnostic span points at line/column of the offending
     argument value.

Adds `@prisma-next/sql-contract-psl` and `@prisma-next/psl-parser`
as devDeps so the cipherstash package can drive the PSL interpreter
in unit tests (mirrors how pgvector tests other extensions through
`contract-psl`). Layering remains clean
(`pnpm lint:deps` passes).

Phase: M2.b R1 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
Populates the M2.a stub at `src/exports/column-types.ts` with the
`encryptedString({ equality?, freeTextSearch? })` factory — the
TS-side counterpart to the PSL constructor
`cipherstash.EncryptedString({...})` registered in
`core/authoring.ts`.

Both factories produce the same `ColumnTypeDescriptor` shape:

  { codecId: "cipherstash/string@1",
    nativeType: "eql_v2_encrypted",
    typeParams: { equality, freeTextSearch } }

so PSL- and TS-authored contracts emit byte-identical
`contract.json` (the parity fixture under
`test/integration/test/authoring/parity/cipherstash-encrypted-string/`
pins this equivalence in the next commit).

Defaults are `false`/`false` per the project`s M2 standing
decision: storage-only encryption is the legitimate default,
mirroring the PSL constructor`s `false` defaults declared via
`AuthoringArgRef.default`.

Tests cover the four argument shapes (storage-only, equality-only,
freeTextSearch-only via both, and full args) plus a structural-
equivalence check that the factory output equals the
PSL-constructor lowering output byte-for-byte.

Phase: M2.b R1 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…(M2.b AC-PARITY1..2)

Adds the authoring-parity fixture under
`test/integration/test/authoring/parity/cipherstash-encrypted-string/`
that pins PSL-emitted ≡ TS-emitted ≡ `expected.contract.json`
byte-for-byte for the cipherstash extension.

The fixture exercises all three argument shapes the M2.b spec
calls out — `{}`, `{ equality: true }`,
`{ equality: true, freeTextSearch: true }` — on both nullable and
non-nullable model fields (six cipherstash-typed columns total)
plus an `Int @id @default(autoincrement())` id column. The
schema uses `@@Map("encrypted_doc")` so the PSL-derived storage
table name lines up with the TS contract`s explicit
`.sql({ table: "encrypted_doc" })`, mirroring the
`pgvector-named-type` fixture`s pattern.

The parity-test runner (`cli.emit-parity-fixtures.test.ts`)
auto-discovers any directory under `parity/` containing the
four required files (`schema.prisma`, `contract.ts`, `packs.ts`,
`expected.contract.json`), so no test runner changes are needed.

Adds `@prisma-next/extension-cipherstash` as a workspace dep of
the integration-tests package so the parity contract.ts can
import `encryptedString` from
`@prisma-next/extension-cipherstash/column-types` and the
parity packs.ts can import the cipherstash control descriptor.

Validation:

  pnpm --filter @prisma-next/integration-tests test \\
    -t "cipherstash-encrypted-string"
  → 1 passed (cipherstash-encrypted-string), 21 skipped.

Phase: M2.b R1 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…ER4)

Pins the SQL DDL the migration planner would emit for a contract
containing `cipherstash.EncryptedString`-typed columns. Pure
SQL-shape snapshot — drives the postgres-adapter`s
`buildCreateTableSql` directly, no live Postgres required (the
live-DB equivalent ships in M2.c with the EQL bundle).

Two checks:

- A coarse "every cipherstash column renders as `eql_v2_encrypted`"
  containment assertion that doubles as documentation for the
  decision that search-mode `typeParams` (`equality`,
  `freeTextSearch`) do *not* affect column DDL — they govern
  *operator* lowering at runtime instead.

- A byte-exact `CREATE TABLE` snapshot covering all three argument
  shapes (`{}`, `{ equality: true }`,
  `{ equality: true, freeTextSearch: true }`) on both nullable and
  non-nullable columns plus an `Int @id @default(autoincrement())`
  pkey.

Drive-by: registers a `cipherstashStringControlPlaneHooks` in
`exports/control.ts` whose `expandNativeType` returns the bare
`eql_v2_encrypted` (ignoring `typeParams`). The framework`s DDL
builder requires every typeParam-carrying column to declare an
`expandNativeType` hook to make the "do parameters affect DDL?"
decision explicit; this hook records the explicit "no" decision
for cipherstash. Without it, `dbInit` against a contract carrying
encrypted columns would error with "no expandNativeType hook is
registered".

Phase: M2.b R1 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…narrowing

The interpreter`s `Result<Contract, ...>` carries
`storage: StorageBase<string>`, an intentionally weak base type
that family adapters specialise. The PSL interpretation tests
need access to the SQL-family-shape `tables` and `types`
projections; before this commit they reached for those properties
directly on the base type, which `tsc` rejects.

Refactors the tests to:

- Define a local `NarrowedStorage` interface describing the
  SQL-family projection.
- Funnel access through a small `narrowStorage()` helper that
  performs the explicit `as unknown as NarrowedStorage` cast in
  one place, with a comment explaining why.
- Add a `userColumns(value, name)` accessor that returns the
  named column from the `User` model, throwing if absent (so a
  missing column fails the test loudly rather than silently
  passing on `undefined`).

Also removes the stray `codecLookup` argument from the
`interpret(...)` helper`s input — `InterpretPslDocumentToSqlContractInput`
does not declare a `codecLookup` slot; vitest accepted the extra
property at runtime but `tsc` correctly rejects it.

No behavior change: all eight PSL interpretation tests continue
to pass; cipherstash`s `pnpm typecheck` is now green.

Phase: M2.b R1 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
The control descriptor`s
`types.codecTypes.controlPlaneHooks` slot is typed
generically (record-of-unknown) at the spread site, so
`Object.entries(...)` returns `[string, unknown][]`. Casting
the record to `Record<string, CodecControlHooks>` *before*
calling `Object.entries` lets the `Map` constructor accept
the entries without the test having to widen the Map`s value
type or add per-entry casts.

Repo `pnpm typecheck` now reports 125/125 tasks green.

Phase: M2.b R1 (cipherstash-integration / project-1).
Signed-off-by: Will Madden <madden@prisma.io>
…rral (M2.b R1)

Adds Open item 8 documenting the M2.b R1 accepted-deferral for the
codec-SDK binding refactor. M2.b landed its authoring surface
cleanly (7 commits, all 12 ACs covered) but the pack-meta codec
needed an SDK-free workaround because the runtime codec captures
CipherstashSdk in its decode closure.

The long-term fix is to thread SDK per-call via CodecCallContext
rather than capturing it at codec construction, which touches every
codec in the repo and is M3+ framework scope.

Linear ticket pending; full decision record + ticket body in
reviews/code-review.md Orchestrator notes.

Refs: TML-2373
Updates the spec and plan to reflect current state (M1, M2.a, M2.b
SATISFIED on the branch; M2.c, M3, M4, M5 remaining) and adds a
HANDOVER.md with five-minute orientation, what-is-done /
what-remains breakdown, operating context (branch, worktree,
validation gates, key files, repo conventions), follow-up tickets
(TML-2376 filed; codec-SDK binding refactor pending manual filing),
unresolved spec open questions, subagent / orchestration context,
and a pre-flight checklist for the next driver.

plan.md changes:

- Each milestone gains an explicit Status: line at the top.
- M2 splits its status into M2.a (SATISFIED), M2.b (SATISFIED),
  M2.c (NOT STARTED) sub-rounds reflecting the actual execution
  cadence.
- M2.c gains a concrete task checklist (T2.c.1..T2.c.8) keyed
  to the AC text from envelope-codec-extension.spec.md and
  psl-encrypted-string-constructor.spec.md.
- Status table updated with current AC totals (52/0/48), commit
  ranges per milestone, and the M2.c entry conditions
  (live Postgres + EQL infra).

spec.md changes:

- Per-task-spec Status table updated: raw-sql-ast-node and
  middleware-param-transform shipped in M1; psl-encrypted-string-
  constructor shipped in M2.b; envelope-codec-extension partially
  shipped (codec/envelope in M2.a, middleware/decryptAll/operator-
  lowering deferred); migration-factories drafted (scope is M3+M4).

Refs: TML-2373
- Cross-reference TML-2388 (codec-SDK binding refactor) in HANDOVER and plan; replace the pending-manual-filing entry with the filed ticket entry.
- Resolve Open items 5 (routing-key derivation) and 6 (plaintext zeroing) with explicit decisions and rationale; remove the now-obsolete pre-flight checklist entries.
- Routing key: { table, column }, derived from the envelope handle; matches the shape already locked into CipherstashSdk in M2.a. No per-column override on encryptedString({...}) in Project 1.
- Plaintext zeroing: not done. Side effect is that a write-side envelope decrypt() returns synchronously without an SDK round-trip. The M2.a-shipped setHandleCiphertext helper currently zeros plaintext; the M2.c implementer removes that line as part of T2.c.2 and flips AC-MW5.
- Update umbrella spec to align Plaintext-exposure-window text with the No-automatic-zeroing non-goal (the previous text contradicted itself).
- Update envelope-codec task spec: flip AC-MW5 to assert plaintext retention via the public surface (envelope.decrypt() returns synchronously without invoking the mock SDK); rephrase the Non-goals entry; mark Open Questions 2 and 5 as RESOLVED.
- Add cipherstash-team-questions.md framing both decisions for the upcoming CipherStash team conversation (validation, not gating), plus adjacent topics on EQL bundle vendoring, integration-test infrastructure, and operator-lowering canonical source.
@wmadden wmadden changed the title project-1 (cipherstash, searchable-encryption MVP): in-flight feat(cipherstash): framework SPI + authoring surface (Phase 1) May 6, 2026
@wmadden wmadden marked this pull request as ready for review May 6, 2026 12:48
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts (1)

173-227: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ignore prototype-chain values during object-argument validation.

This function validates nested object args with input[key], so inherited properties can satisfy required fields. That disagrees with resolveAuthoringTemplateValue(), which later walks arg paths with Object.hasOwn() and treats prototype-sourced values as missing. The result is that a helper arg can validate successfully and then resolve to undefined or a fallback at instantiation time.

🔧 Proposed fix
-    for (const [key, propertyDescriptor] of Object.entries(descriptor.properties)) {
-      validateAuthoringArgument(propertyDescriptor, input[key], `${path}.${key}`);
+    for (const [key, propertyDescriptor] of Object.entries(descriptor.properties)) {
+      const propertyValue = Object.hasOwn(input, key) ? input[key] : undefined;
+      validateAuthoringArgument(propertyDescriptor, propertyValue, `${path}.${key}`);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts`
around lines 173 - 227, validateAuthoringArgument currently reads values via
input[key] which will pick up prototype-inherited properties; change the
object-property validation to ignore prototype-chain values by checking
ownership before using the value: inside the object branch (where input is const
input = value as Record<string, unknown> and you iterate for (const [key,
propertyDescriptor] of Object.entries(descriptor.properties))), compute a
valueToValidate = Object.hasOwn(input, key) ? input[key] : undefined (or use
Object.prototype.hasOwnProperty.call(input, key) for older runtimes) and pass
that into validateAuthoringArgument(propertyDescriptor, valueToValidate,
`${path}.${key}`). Ensure the unknown-property check (for (const key of
Object.keys(input))) can remain as-is.
packages/2-sql/4-lanes/relational-core/src/ast/types.ts (1)

1705-1727: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix isQueryAst() type predicate soundness before extending it to new AST kinds.

isQueryAst() narrows to AnyQueryAst by checking only the kind discriminant, but AnyQueryAst requires both collectParamRefs() and toQueryAst() methods. TypeScript will allow calling these methods on any object that passes the predicate, even if it only has a kind property. For example, isQueryAst({ kind: 'raw-sql' }) returns true, but the object has no collectParamRefs() method.

While RawSqlExpr class properly implements both methods, the type predicate does not validate this structural contract. Downstream code that trusts the predicate can pass malformed objects deeper into processing and fail at runtime.

Either validate the required members in the predicate (not practical for runtime), or use a narrower return type that reflects what the predicate actually checks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/2-sql/4-lanes/relational-core/src/ast/types.ts` around lines 1705 -
1727, isQueryAst currently claims to narrow to AnyQueryAst by only checking the
kind discriminant (queryAstKinds), which is unsound because AnyQueryAst requires
methods like collectParamRefs and toQueryAst; change the predicate to a
narrower, accurate type such as "value is { kind: AnyQueryAst['kind'] }" (or a
dedicated QueryKind type) instead of "value is AnyQueryAst", so callers don't
get false assurance they can call collectParamRefs/toQueryAst; update the
function signature and any imports/usages referencing isQueryAst accordingly
(leave runtime check as-is using queryAstKinds).
packages/2-sql/5-runtime/src/sql-runtime.ts (1)

201-226: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't re-encode caller-supplied SqlExecutionPlans.

lower() still defines SqlExecutionPlan as driver-ready, but this path now treats every incoming SqlExecutionPlan as pre-encode/raw and runs encodeParams() again inside the driver thunk. Any caller that executes a cached or pre-lowered plan will double-encode codec values (wire:wire:...) and break the unchanged execute(plan) contract. Please split raw-vs-encoded execution plans, or preserve the old passthrough behavior for externally supplied execution plans.

Also applies to: 300-355

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/2-sql/5-runtime/src/sql-runtime.ts` around lines 201 - 226, The
lower() implementation currently always re-encodes params causing
double-encoding for caller-supplied SqlExecutionPlan objects; change lower() to
detect and passthrough already-lowered/driver-ready execution plans instead of
always calling encodeParams: if the incoming plan is already a SqlExecutionPlan
(e.g. contains the driver-ready params shape or an explicit marker), return it
frozen as-is; otherwise call lowerSqlPlan(this.adapter, this.contract, plan) and
then await encodeParams(...) as before. Update the lower() signature or add a
narrow runtime guard (union param or runtime-type check) so callers of
execute/executAgainstQueryable retain the old passthrough behavior and only raw
SqlQueryPlan paths trigger encodeParams; ensure references to encodeParams,
lowerSqlPlan, executeAgainstQueryable and execute remain consistent.
🧹 Nitpick comments (1)
packages/3-extensions/cipherstash/test/authoring.test.ts (1)

7-7: ⚡ Quick win

Use direct assertions instead of optional chaining on required pack fields.

These two checks are validating required shape contracts; ?. softens the assertion path and makes failures less direct. Prefer direct property access here.

Proposed diff
-    expect(cipherstashPack.authoring?.type).toMatchObject({
+    expect(cipherstashPack.authoring.type).toMatchObject({
       cipherstash: {
         EncryptedString: {
           kind: 'typeConstructor',
         },
       },
@@
-    expect(cipherstashPack.types?.storage).toContainEqual({
+    expect(cipherstashPack.types.storage).toContainEqual({
       typeId: 'cipherstash/string@1',
       familyId: 'sql',
       targetId: 'postgres',
       nativeType: 'eql_v2_encrypted',
     });

As per coding guidelines, "Prefer assertions over defensive checks when data is guaranteed to be valid ... remove optional chaining ... access required properties directly without ?. operators."

Also applies to: 48-48

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/3-extensions/cipherstash/test/authoring.test.ts` at line 7, The test
uses optional chaining on required fields which hides assertion failures;
replace uses of cipherstashPack.authoring?.type with direct access
cipherstashPack.authoring.type (and similarly remove `?.` for the other
occurrence around line 48) so the assertions directly target required
properties; update any expect(...) calls referencing
cipherstashPack.authoring?.... to use cipherstashPack.authoring.... instead to
ensure failures are explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@architecture.config.json`:
- Around line 460-488: Add a new architecture mapping object for the missing
cipherstash pack export by inserting an entry with "glob":
"packages/3-extensions/cipherstash/src/exports/pack.ts", "domain": "extensions",
"layer": "adapters", and "plane": "runtime" into the same array/block alongside
the other cipherstash export entries so the pack entry is registered for
dependency/plane checks.

In
`@packages/1-framework/1-core/framework-components/src/execution/runtime-core.ts`:
- Around line 121-127: The per-execute middleware context currently reuses
self.ctx when options?.signal is undefined, which can preserve a stale signal;
change the construction of execMiddlewareCtx in execute() so it always starts
from a copy of self.ctx with any existing signal property removed, then only add
the per-execute signal when provided (i.e., create a shallow copy of self.ctx,
delete its signal key, and if signal !== undefined assign that signal into the
new object before assigning to execMiddlewareCtx) to ensure no stale signal is
inherited.

In `@packages/2-sql/2-authoring/contract-psl/src/psl-authoring-arguments.ts`:
- Around line 314-319: Add unit tests that exercise the new boolean parsing
branch introduced at the case 'boolean' in psl-authoring-arguments.ts: assert
that rawValue values "true" and "false" return true/false respectively, that
values with surrounding whitespace (e.g. "  true  ") are accepted, and that
invalid tokens like "truthy" and quoted strings like "\"true\"" return
INVALID_AUTHORING_ARGUMENT; place tests targeting the function that routes to
this switch (the boolean parsing logic using rawValue and
INVALID_AUTHORING_ARGUMENT) so coverage includes valid, whitespace-trimmed, and
invalid cases.

In `@packages/2-sql/4-lanes/relational-core/src/exports/middleware.ts`:
- Around line 1-8: Remove the internal-only symbol from the public exports:
delete SqlParamRefMutatorInternal from the type re-export list in middleware.ts
(leave ParamRefEntry, ParamRefEntryUnion, ParamRefHandle, SqlParamRefMutator and
the createSqlParamRefMutator export intact). If another package truly needs that
shape, either create and export a supported public alias (give it a non-Internal
name) or keep SqlParamRefMutatorInternal only inside
../middleware/param-ref-mutator so it is not part of src/exports public
boundary.

In `@packages/2-sql/4-lanes/relational-core/src/middleware/param-ref-mutator.ts`:
- Around line 56-58: ParamRefEntryUnion currently uses ParamRefEntry<undefined>
as a fallback which misrepresents runtime entries with codec IDs not in
TCodecMap; change the fallback arm to accept arbitrary codec strings (e.g.,
ParamRefEntry<string>) so entries() yields a union that preserves the actual
ref.codecId at the type level, then adjust any consumers that currently expect
codecId === undefined to handle the generic string case or perform an explicit
runtime validation/mapping of ref.codecId before pattern-matching; update the
ParamRefEntryUnion type alias and any code that narrows on entry.codecId
accordingly.

In `@packages/2-sql/5-runtime/src/sql-runtime.ts`:
- Around line 285-289: The code currently reuses self.ctx when signal is
undefined, causing concurrent no-signal executions to share the same context
object; always create a fresh per-execute middleware context instead. Replace
the conditional reuse with a shallow clone of self.ctx for every execute (e.g.,
create execMiddlewareCtx by spreading self.ctx into a new object and add the
signal property only if present) so execMiddlewareCtx is a new object each time;
update references to execMiddlewareCtx (and codecCtx.signal usage) accordingly.

In `@packages/3-extensions/cipherstash/DEVELOPING.md`:
- Around line 23-24: Update DEVELOPING.md to remove the “placeholder” wording
and document the actual exported surface for the implemented factory in
exports/column-types.ts: describe the encryptedString(...) factory signature and
options it accepts, and update the middleware note to reflect that
bulkEncryptMiddleware is implemented (or remove the placeholder comment if not).
Specifically mention the encryptedString function name and column-types.ts,
include a short summary of its parameters/returned type and any important
behavior or defaults, and adjust the lines that reference middleware.ts to
correctly state its current status (implemented or not) so the doc reflects the
true code surface.

In `@packages/3-extensions/cipherstash/README.md`:
- Around line 6-10: Update the "Status" paragraph in README.md to reflect that
the PSL constructor and the encryptedString({...}) TypeScript factory are
included in this PR’s Phase 1 surface instead of being deferred; specifically
edit the sentence that lists "Search operators..., decryptAll, the bulk-encrypt
middleware, the PSL constructor, and the `encryptedString({...})` TS factory
ship in subsequent releases" to state that the PSL constructor and
`encryptedString({...})` factory are now shipped (or included in Phase 1), and
remove or rephrase "subsequent releases" so the Status accurately lists the
current shipped components (e.g., EncryptedString envelope, codec, EQL-bundle,
PSL constructor, encryptedString factory).
- Line 37: The README has a malformed backtick/apostrophe in the phrase "codec`s
`decode` site"; update the wording to use a proper apostrophe and consistent
code spans—for example change it to "codec's `decode` site reads the cell's
`(table, column)`..." so the word codec is plain text with a correct apostrophe
and only `decode` remains in backticks; edit the paragraph containing "decode"
accordingly.

In `@packages/3-extensions/cipherstash/src/core/envelope.ts`:
- Around line 59-64: EncryptedString.fromInternal and the
EncryptedStringFromInternalArgs type leak internal SDK-bound construction into
the public API; change the concrete class to a non-exported (private)
implementation (e.g., rename the class to _EncryptedStringImpl and un-export
it), remove or make fromInternal non-exported, and instead export only a factory
function(s) such as createEncryptedString(...) or fromEncryptedPayload(...) that
accept sanitized public arguments and construct the private implementation using
CipherstashSdk internally; update all internal call sites to use the new private
class or exported factory and ensure the public export surface only exposes the
EncryptedString interface and factory functions, not the concrete class or
fromInternal method.

In `@packages/3-extensions/cipherstash/src/core/eql-bundle.ts`:
- Around line 16-17: The EQL_INSTALL_SQL constant currently contains only a
comment which makes databaseDependencies.init execute as a silent no-op and
causes dbInit to always fail; update the control descriptor so it does not
register an empty install step or replace the placeholder SQL with an explicit
failing stub: either remove or skip wiring an init/execute step for
databaseDependencies.init until the real bundle is vendored, or change
EQL_INSTALL_SQL (referenced by the control descriptor /
databaseDependencies.init execute step) to an SQL string or mechanism that
immediately fails with a clear "not supported yet" error so the system fails
fast and surfaces the missing bundle instead of silently continuing.

In `@packages/3-extensions/cipherstash/src/core/parameterized.ts`:
- Around line 20-22: The file src/core/parameterized.ts contains runtime-only
code (createParameterizedCodecDescriptors) and imports
RuntimeParameterizedCodecDescriptor and arktype, violating the core/runtime
split; move the createParameterizedCodecDescriptors implementation out of
src/core into a runtime-only module (e.g., under the package's runtime entry),
keep only the shared helpers (params/schema/output-type utilities) in
src/core/parameterized.ts, update exports so callers import
createParameterizedCodecDescriptors from the new runtime module, and remove any
runtime imports (RuntimeParameterizedCodecDescriptor, arktype) from src/core;
also apply the same split for the related code block spanning the other affected
lines (40-62).

In `@packages/3-extensions/cipherstash/src/core/sdk.ts`:
- Around line 55-79: The bulkEncrypt/bulkDecrypt contract is ambiguous and can
break callers if implementations reorder/drop entries; update the CipherstashSdk
contract so bulkEncrypt(args: CipherstashBulkEncryptArgs) and bulkDecrypt(args:
CipherstashBulkDecryptArgs) explicitly guarantee results are the same length and
in the same order as the input (i.e., result[i] corresponds to input[i]), or
change the return shape to an indexed result (e.g., include an explicit
index/key per item) so callers can reliably correlate outputs; modify the
CipherstashBulkEncryptArgs/CipherstashBulkDecryptArgs return types and inline
JSDoc on bulkEncrypt, bulkDecrypt, and the CipherstashSdk interface to reflect
the chosen approach.

In `@packages/3-extensions/cipherstash/test/codecs.test.ts`:
- Around line 15-81: Add tests that exercise cipherstashStringCodecMetadata and
the JSON hooks: call cipherstashStringCodecMetadata(makeSdk()) and assert its
id/meta matches CIPHERSTASH_STRING_CODEC_ID and postgres nativeType; for
encodeJson/decodeJson, invoke createCipherstashStringCodec(makeSdk()).encodeJson
and .decodeJson with a sample plaintext and a JSON wire object respectively and
assert round-trip behavior (decodeJson returns EncryptedString-like envelope
with handle.sdk/table/column when given ctx.column, and encodeJson returns the
ciphertext when handle.ciphertext is set via setHandleCiphertext). Use existing
helpers EncryptedString.from, getInternalHandle, setHandleCiphertext and makeSdk
to locate the right codec instances and verify expected errors/outputs for
missing ctx.column or missing ciphertext.

In `@packages/3-extensions/cipherstash/test/envelope.test.ts`:
- Around line 16-25: The test is tautological because the mock `sdk` is never
used by `EncryptedString.from('hello')`, so
`expect(sdk.decrypt).not.toHaveBeenCalled()` is meaningless; either remove this
test or rewrite it to exercise a code path that actually receives the SDK mock.
Concretely: delete this spec, or change it to construct/use the API that accepts
an SDK (e.g., create an envelope via the write-side method that captures the SDK
or call `envelope.decrypt(...)`/the decrypt helper that takes the `sdk`), then
assert `sdk.decrypt` was/was not invoked; reference `EncryptedString.from` and
`decrypt` to locate the current test and replace it with a version that passes
the `sdk` into the code under test so the mock can be observed.

In `@packages/3-extensions/cipherstash/test/runtime.test.ts`:
- Around line 77-87: The test currently only asserts resolved?.id and doesn't
verify the codec actually closed over the supplied sdk; update the test that
calls createParameterizedCodecDescriptors(sdk) and factory to exercise the codec
(resolved) by invoking its decode(...) with a sample { column } (or call
resolved.decrypt(...) if available) and assert that the resulting envelope
includes the same sdk instance or that the mock sdk from makeSdk() received the
expected call; target symbols: makeSdk, createParameterizedCodecDescriptors,
factory, resolved, decode/decrypt, and envelope/CIPHERSTASH_STRING_CODEC_ID so
the test fails if the codec wired a different sdk.

In `@packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts`:
- Around line 774-786: renderRawSql currently tolerates malformed RawSqlExpr
shapes (fragments.length !== args.length + 1) by using "?? ''" and skipping
undefined args, which can produce SQL/params mismatch; update renderRawSql
(signature: renderRawSql(ast: RawSqlExpr, contract: PostgresContract, pim:
ParamIndexMap)) to validate the AST shape up front: assert fragments is an array
and fragments.length === args.length + 1, and throw a clear error if not; also
validate each fragment and arg is present (no null/undefined) before
concatenation and remove the "?? ''" and loose "arg !== undefined" handling so
malformed nodes fail fast rather than producing truncated SQL that will mismatch
collectOrderedParamRefs.

---

Outside diff comments:
In
`@packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts`:
- Around line 173-227: validateAuthoringArgument currently reads values via
input[key] which will pick up prototype-inherited properties; change the
object-property validation to ignore prototype-chain values by checking
ownership before using the value: inside the object branch (where input is const
input = value as Record<string, unknown> and you iterate for (const [key,
propertyDescriptor] of Object.entries(descriptor.properties))), compute a
valueToValidate = Object.hasOwn(input, key) ? input[key] : undefined (or use
Object.prototype.hasOwnProperty.call(input, key) for older runtimes) and pass
that into validateAuthoringArgument(propertyDescriptor, valueToValidate,
`${path}.${key}`). Ensure the unknown-property check (for (const key of
Object.keys(input))) can remain as-is.

In `@packages/2-sql/4-lanes/relational-core/src/ast/types.ts`:
- Around line 1705-1727: isQueryAst currently claims to narrow to AnyQueryAst by
only checking the kind discriminant (queryAstKinds), which is unsound because
AnyQueryAst requires methods like collectParamRefs and toQueryAst; change the
predicate to a narrower, accurate type such as "value is { kind:
AnyQueryAst['kind'] }" (or a dedicated QueryKind type) instead of "value is
AnyQueryAst", so callers don't get false assurance they can call
collectParamRefs/toQueryAst; update the function signature and any
imports/usages referencing isQueryAst accordingly (leave runtime check as-is
using queryAstKinds).

In `@packages/2-sql/5-runtime/src/sql-runtime.ts`:
- Around line 201-226: The lower() implementation currently always re-encodes
params causing double-encoding for caller-supplied SqlExecutionPlan objects;
change lower() to detect and passthrough already-lowered/driver-ready execution
plans instead of always calling encodeParams: if the incoming plan is already a
SqlExecutionPlan (e.g. contains the driver-ready params shape or an explicit
marker), return it frozen as-is; otherwise call lowerSqlPlan(this.adapter,
this.contract, plan) and then await encodeParams(...) as before. Update the
lower() signature or add a narrow runtime guard (union param or runtime-type
check) so callers of execute/executAgainstQueryable retain the old passthrough
behavior and only raw SqlQueryPlan paths trigger encodeParams; ensure references
to encodeParams, lowerSqlPlan, executeAgainstQueryable and execute remain
consistent.

---

Nitpick comments:
In `@packages/3-extensions/cipherstash/test/authoring.test.ts`:
- Line 7: The test uses optional chaining on required fields which hides
assertion failures; replace uses of cipherstashPack.authoring?.type with direct
access cipherstashPack.authoring.type (and similarly remove `?.` for the other
occurrence around line 48) so the assertions directly target required
properties; update any expect(...) calls referencing
cipherstashPack.authoring?.... to use cipherstashPack.authoring.... instead to
ensure failures are explicit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 06e4f8cf-13a1-4924-91f0-95549edc4819

📥 Commits

Reviewing files that changed from the base of the PR and between 1d8b709 and 8ceebb5.

⛔ Files ignored due to path filters (6)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • projects/cipherstash-integration/project-1/HANDOVER.md is excluded by !projects/**
  • projects/cipherstash-integration/project-1/cipherstash-team-questions.md is excluded by !projects/**
  • projects/cipherstash-integration/project-1/plan.md is excluded by !projects/**
  • projects/cipherstash-integration/project-1/spec.md is excluded by !projects/**
  • projects/cipherstash-integration/project-1/specs/envelope-codec-extension.spec.md is excluded by !projects/**
📒 Files selected for processing (71)
  • architecture.config.json
  • packages/1-framework/1-core/framework-components/src/execution/race-against-abort.ts
  • packages/1-framework/1-core/framework-components/src/execution/run-with-middleware.ts
  • packages/1-framework/1-core/framework-components/src/execution/runtime-core.ts
  • packages/1-framework/1-core/framework-components/src/execution/runtime-error.ts
  • packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts
  • packages/1-framework/1-core/framework-components/src/exports/runtime.ts
  • packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts
  • packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts
  • packages/1-framework/1-core/framework-components/test/run-with-middleware-signal.test.ts
  • packages/2-mongo-family/7-runtime/src/exports/index.ts
  • packages/2-mongo-family/7-runtime/src/mongo-middleware.ts
  • packages/2-mongo-family/7-runtime/src/mongo-param-ref-mutator.ts
  • packages/2-mongo-family/7-runtime/test/mongo-param-ref-mutator.test.ts
  • packages/2-sql/2-authoring/contract-psl/src/psl-authoring-arguments.ts
  • packages/2-sql/2-authoring/contract-ts/src/authoring-type-utils.ts
  • packages/2-sql/4-lanes/relational-core/package.json
  • packages/2-sql/4-lanes/relational-core/src/ast/types.ts
  • packages/2-sql/4-lanes/relational-core/src/exports/middleware.ts
  • packages/2-sql/4-lanes/relational-core/src/index.ts
  • packages/2-sql/4-lanes/relational-core/src/middleware/param-ref-mutator.ts
  • packages/2-sql/4-lanes/relational-core/src/plan.ts
  • packages/2-sql/4-lanes/relational-core/test/ast/raw-sql-expr.test.ts
  • packages/2-sql/4-lanes/relational-core/test/middleware/param-ref-mutator.test.ts
  • packages/2-sql/4-lanes/relational-core/test/middleware/param-ref-mutator.types.test-d.ts
  • packages/2-sql/4-lanes/relational-core/test/plan-from-ast.test.ts
  • packages/2-sql/4-lanes/relational-core/tsdown.config.ts
  • packages/2-sql/5-runtime/src/codecs/decoding.ts
  • packages/2-sql/5-runtime/src/middleware/lints.ts
  • packages/2-sql/5-runtime/src/middleware/sql-middleware.ts
  • packages/2-sql/5-runtime/src/sql-runtime.ts
  • packages/2-sql/5-runtime/test/middleware-param-mutator.test.ts
  • packages/2-sql/5-runtime/test/raw-sql-expr-encode.test.ts
  • packages/3-extensions/cipherstash/DEVELOPING.md
  • packages/3-extensions/cipherstash/README.md
  • packages/3-extensions/cipherstash/biome.jsonc
  • packages/3-extensions/cipherstash/package.json
  • packages/3-extensions/cipherstash/src/core/authoring.ts
  • packages/3-extensions/cipherstash/src/core/codecs.ts
  • packages/3-extensions/cipherstash/src/core/descriptor-meta.ts
  • packages/3-extensions/cipherstash/src/core/envelope.ts
  • packages/3-extensions/cipherstash/src/core/eql-bundle.ts
  • packages/3-extensions/cipherstash/src/core/parameterized.ts
  • packages/3-extensions/cipherstash/src/core/sdk.ts
  • packages/3-extensions/cipherstash/src/exports/column-types.ts
  • packages/3-extensions/cipherstash/src/exports/control.ts
  • packages/3-extensions/cipherstash/src/exports/index.ts
  • packages/3-extensions/cipherstash/src/exports/middleware.ts
  • packages/3-extensions/cipherstash/src/exports/pack.ts
  • packages/3-extensions/cipherstash/src/exports/runtime.ts
  • packages/3-extensions/cipherstash/test/authoring.test.ts
  • packages/3-extensions/cipherstash/test/codecs.test.ts
  • packages/3-extensions/cipherstash/test/column-types.test.ts
  • packages/3-extensions/cipherstash/test/control.test.ts
  • packages/3-extensions/cipherstash/test/envelope.test-d.ts
  • packages/3-extensions/cipherstash/test/envelope.test.ts
  • packages/3-extensions/cipherstash/test/psl-interpretation.test.ts
  • packages/3-extensions/cipherstash/test/runtime.test.ts
  • packages/3-extensions/cipherstash/tsconfig.json
  • packages/3-extensions/cipherstash/tsconfig.prod.json
  • packages/3-extensions/cipherstash/tsdown.config.ts
  • packages/3-extensions/cipherstash/vitest.config.ts
  • packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts
  • packages/3-targets/6-adapters/postgres/test/migrations/data-transform.plan-from-ast.test.ts
  • packages/3-targets/6-adapters/postgres/test/sql-renderer.raw-sql.test.ts
  • test/integration/package.json
  • test/integration/test/authoring/cipherstash-dbinit-snapshot.test.ts
  • test/integration/test/authoring/parity/cipherstash-encrypted-string/contract.ts
  • test/integration/test/authoring/parity/cipherstash-encrypted-string/expected.contract.json
  • test/integration/test/authoring/parity/cipherstash-encrypted-string/packs.ts
  • test/integration/test/authoring/parity/cipherstash-encrypted-string/schema.prisma

Comment thread architecture.config.json
Comment on lines +460 to +488
"glob": "packages/3-extensions/cipherstash/src/exports/index.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "shared"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/column-types.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "shared"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/control.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "migration"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/runtime.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "runtime"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/middleware.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "runtime"
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add architecture mapping for packages/3-extensions/cipherstash/src/exports/pack.ts.

The cipherstash pack export is part of the package surface but is not registered in this config block. That leaves dependency/plane checks incomplete for that entrypoint.

Proposed diff
     {
       "glob": "packages/3-extensions/cipherstash/src/exports/column-types.ts",
       "domain": "extensions",
       "layer": "adapters",
       "plane": "shared"
     },
+    {
+      "glob": "packages/3-extensions/cipherstash/src/exports/pack.ts",
+      "domain": "extensions",
+      "layer": "adapters",
+      "plane": "shared"
+    },
     {
       "glob": "packages/3-extensions/cipherstash/src/exports/control.ts",
       "domain": "extensions",
       "layer": "adapters",
       "plane": "migration"

As per coding guidelines, "Package domain/layer/plane configuration must include glob pattern, domain name, layer name, and plane designation for each package entry."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"glob": "packages/3-extensions/cipherstash/src/exports/index.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "shared"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/column-types.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "shared"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/control.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "migration"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/runtime.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "runtime"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/middleware.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "runtime"
},
"glob": "packages/3-extensions/cipherstash/src/exports/index.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "shared"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/column-types.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "shared"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/pack.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "shared"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/control.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "migration"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/runtime.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "runtime"
},
{
"glob": "packages/3-extensions/cipherstash/src/exports/middleware.ts",
"domain": "extensions",
"layer": "adapters",
"plane": "runtime"
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@architecture.config.json` around lines 460 - 488, Add a new architecture
mapping object for the missing cipherstash pack export by inserting an entry
with "glob": "packages/3-extensions/cipherstash/src/exports/pack.ts", "domain":
"extensions", "layer": "adapters", and "plane": "runtime" into the same
array/block alongside the other cipherstash export entries so the pack entry is
registered for dependency/plane checks.

Comment on lines +121 to +127
// Merge the per-execute signal onto the persistent middleware ctx
// for the duration of this execute() call. The ctx object itself is
// freshly allocated per-execute so middleware sees the signal that
// belongs to *its* invocation, not a shared one. Identity matches
// codecCtx.signal so middleware authors who compare `ctx.signal`
// across the codec/middleware boundary observe the same reference.
const execMiddlewareCtx = signal === undefined ? self.ctx : { ...self.ctx, signal };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Strip any base ctx.signal before layering the per-execute one.

When options?.signal is absent, this branch reuses self.ctx verbatim. If the long-lived context already carries a signal, later executions inherit that stale signal and can abort against the wrong request. Build the per-execute middleware context from self.ctx with any existing signal removed first.

🛠️ Proposed fix
-      const execMiddlewareCtx = signal === undefined ? self.ctx : { ...self.ctx, signal };
+      const { signal: _baseSignal, ...baseMiddlewareCtx } = self.ctx;
+      const execMiddlewareCtx: RuntimeMiddlewareContext =
+        signal === undefined ? baseMiddlewareCtx : { ...baseMiddlewareCtx, signal };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/1-framework/1-core/framework-components/src/execution/runtime-core.ts`
around lines 121 - 127, The per-execute middleware context currently reuses
self.ctx when options?.signal is undefined, which can preserve a stale signal;
change the construction of execMiddlewareCtx in execute() so it always starts
from a copy of self.ctx with any existing signal property removed, then only add
the per-execute signal when provided (i.e., create a shallow copy of self.ctx,
delete its signal key, and if signal !== undefined assign that signal into the
new object before assigning to execMiddlewareCtx) to ensure no stale signal is
inherited.

Comment on lines +314 to +319
case 'boolean': {
const trimmed = rawValue.trim();
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
return INVALID_AUTHORING_ARGUMENT;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add targeted tests for the new boolean parsing branch.

Line 314 introduces a new branch (true / false / invalid). Given CI is currently failing coverage thresholds, add focused tests for: valid booleans, surrounding whitespace, and invalid tokens (e.g. truthy, quoted "true").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/2-sql/2-authoring/contract-psl/src/psl-authoring-arguments.ts`
around lines 314 - 319, Add unit tests that exercise the new boolean parsing
branch introduced at the case 'boolean' in psl-authoring-arguments.ts: assert
that rawValue values "true" and "false" return true/false respectively, that
values with surrounding whitespace (e.g. "  true  ") are accepted, and that
invalid tokens like "truthy" and quoted strings like "\"true\"" return
INVALID_AUTHORING_ARGUMENT; place tests targeting the function that routes to
this switch (the boolean parsing logic using rawValue and
INVALID_AUTHORING_ARGUMENT) so coverage includes valid, whitespace-trimmed, and
invalid cases.

Comment on lines +1 to +8
export type {
ParamRefEntry,
ParamRefEntryUnion,
ParamRefHandle,
SqlParamRefMutator,
SqlParamRefMutatorInternal,
} from '../middleware/param-ref-mutator';
export { createSqlParamRefMutator } from '../middleware/param-ref-mutator';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Do not publish SqlParamRefMutatorInternal from src/exports.

Anything re-exported here becomes semver surface. Publishing a symbol explicitly named Internal locks an implementation detail into the public API and makes the Phase 2 cleanup harder. If another package needs this shape, give it a supported public name or keep it off the exported boundary. Based on learnings, files in src/exports/ define the explicit public API boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/2-sql/4-lanes/relational-core/src/exports/middleware.ts` around
lines 1 - 8, Remove the internal-only symbol from the public exports: delete
SqlParamRefMutatorInternal from the type re-export list in middleware.ts (leave
ParamRefEntry, ParamRefEntryUnion, ParamRefHandle, SqlParamRefMutator and the
createSqlParamRefMutator export intact). If another package truly needs that
shape, either create and export a supported public alias (give it a non-Internal
name) or keep SqlParamRefMutatorInternal only inside
../middleware/param-ref-mutator so it is not part of src/exports public
boundary.

Comment on lines +56 to +58
export type ParamRefEntryUnion<TCodecMap extends Record<string, unknown>> =
| { [K in keyof TCodecMap & string]: ParamRefEntry<K> }[keyof TCodecMap & string]
| ParamRefEntry<undefined>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

cat -n packages/2-sql/4-lanes/relational-core/src/middleware/param-ref-mutator.ts

Repository: prisma/prisma-next

Length of output: 1939


🏁 Script executed:

file_path="packages/2-sql/4-lanes/relational-core/src/middleware/param-ref-mutator.ts"
# Try to see if we can access via git metadata or local cache
git ls-files | grep -E "param-ref-mutator"

Repository: prisma/prisma-next

Length of output: 443


🏁 Script executed:

git show HEAD:packages/2-sql/4-lanes/relational-core/src/middleware/param-ref-mutator.ts

Repository: prisma/prisma-next

Length of output: 9251


The codec union fallback does not accurately model out-of-map codec IDs at runtime.

ParamRefEntryUnion<TCodecMap> declares only discriminated arms for codec IDs in the map, plus ParamRefEntry<undefined> as a fallback. However, entries() yields entries with the raw ref.codecId value (which can be any string outside the map) cast to that union. When a codec ID like 'pg/text@1' appears at runtime but is not in TCodecMap, the entry is typed as ParamRefEntry<undefined> even though entry.codecId contains the actual string value—creating a mismatch. Pattern-matching on entry.codecId to distinguish codec-specific handling becomes unreachable in the type system yet reachable at runtime, breaking the discriminated union contract. The implementation should either widen the fallback arm to accept arbitrary codec strings, or validate and map all codec IDs at runtime before yielding entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/2-sql/4-lanes/relational-core/src/middleware/param-ref-mutator.ts`
around lines 56 - 58, ParamRefEntryUnion currently uses ParamRefEntry<undefined>
as a fallback which misrepresents runtime entries with codec IDs not in
TCodecMap; change the fallback arm to accept arbitrary codec strings (e.g.,
ParamRefEntry<string>) so entries() yields a union that preserves the actual
ref.codecId at the type level, then adjust any consumers that currently expect
codecId === undefined to handle the generic string case or perform an explicit
runtime validation/mapping of ref.codecId before pattern-matching; update the
ParamRefEntryUnion type alias and any code that narrows on entry.codecId
accordingly.

Comment on lines +55 to +79
export interface CipherstashBulkEncryptArgs {
readonly routingKey: CipherstashRoutingKey;
readonly values: ReadonlyArray<string>;
readonly signal?: AbortSignal;
}

export interface CipherstashBulkDecryptArgs {
readonly routingKey: CipherstashRoutingKey;
readonly ciphertexts: ReadonlyArray<unknown>;
readonly signal?: AbortSignal;
}

/**
* The framework-native CipherStash SDK contract consumed by the
* envelope, codec, middleware, and `decryptAll` surfaces.
*
* Real implementations wrap the CipherStash `EncryptionClient`
* (currently `@cipherstash/stack`'s `Encryption({ schemas })` factory).
* Tests construct mock SDKs that implement these three methods directly.
*/
export interface CipherstashSdk {
decrypt(args: CipherstashSingleDecryptArgs): Promise<string>;
bulkEncrypt(args: CipherstashBulkEncryptArgs): Promise<ReadonlyArray<unknown>>;
bulkDecrypt(args: CipherstashBulkDecryptArgs): Promise<ReadonlyArray<string>>;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Define the positional contract for bulk results before middleware starts consuming this API.

bulkEncrypt() and bulkDecrypt() return bare arrays, so callers can only correlate outputs by index. If an implementation reorders, drops, or deduplicates entries, the wrong ciphertext/plaintext gets paired back to a cell.

At minimum, lock in “same length, same order as input” on both methods, or switch to a result shape that carries an index/key explicitly.

📌 Minimal contract clarification
 export interface CipherstashSdk {
   decrypt(args: CipherstashSingleDecryptArgs): Promise<string>;
+  /**
+   * Returns exactly one ciphertext for each entry in `args.values`,
+   * preserving input order.
+   */
   bulkEncrypt(args: CipherstashBulkEncryptArgs): Promise<ReadonlyArray<unknown>>;
+  /**
+   * Returns exactly one plaintext for each entry in `args.ciphertexts`,
+   * preserving input order.
+   */
   bulkDecrypt(args: CipherstashBulkDecryptArgs): Promise<ReadonlyArray<string>>;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/3-extensions/cipherstash/src/core/sdk.ts` around lines 55 - 79, The
bulkEncrypt/bulkDecrypt contract is ambiguous and can break callers if
implementations reorder/drop entries; update the CipherstashSdk contract so
bulkEncrypt(args: CipherstashBulkEncryptArgs) and bulkDecrypt(args:
CipherstashBulkDecryptArgs) explicitly guarantee results are the same length and
in the same order as the input (i.e., result[i] corresponds to input[i]), or
change the return shape to an indexed result (e.g., include an explicit
index/key per item) so callers can reliably correlate outputs; modify the
CipherstashBulkEncryptArgs/CipherstashBulkDecryptArgs return types and inline
JSDoc on bulkEncrypt, bulkDecrypt, and the CipherstashSdk interface to reflect
the chosen approach.

Comment on lines +15 to +81
describe('cipherstash codec — AC-CODEC1 (registration shape)', () => {
it('codec id is `cipherstash/string@1` with target type `eql_v2_encrypted` and traits `[equality]`', () => {
const codec = createCipherstashStringCodec(makeSdk());
expect(codec.id).toBe(CIPHERSTASH_STRING_CODEC_ID);
expect(codec.targetTypes).toEqual(['eql_v2_encrypted']);
expect(codec.traits).toEqual(['equality']);
});

it('codec carries postgres-native-type meta `eql_v2_encrypted`', () => {
const codec = createCipherstashStringCodec(makeSdk());
expect(codec.meta).toMatchObject({
db: { sql: { postgres: { nativeType: 'eql_v2_encrypted' } } },
});
});
});

describe('cipherstash codec — AC-CODEC2 (decode constructs envelope from ctx.column)', () => {
it('decode(wire, ctx) builds an envelope whose handle carries {table, column} from ctx.column', async () => {
const sdk = makeSdk();
const codec = createCipherstashStringCodec(sdk);
const ctx: SqlCodecCallContext = {
column: { table: 'user', name: 'email' },
};
const wire = { c: 'cipher-blob', i: { t: 'user', c: 'email' } };

const envelope = await codec.decode(wire, ctx);

expect(envelope).toBeInstanceOf(EncryptedString);
const handle = getInternalHandle(envelope);
expect(handle.table).toBe('user');
expect(handle.column).toBe('email');
expect(handle.ciphertext).toBe(wire);
expect(handle.sdk).toBe(sdk);
});

it('decode without ctx.column throws (the codec needs the column ref to construct a routing-aware envelope)', async () => {
const codec = createCipherstashStringCodec(makeSdk());
await expect(codec.decode('wire', {})).rejects.toThrow(/requires ctx\.column/);
});
});

describe('cipherstash codec — AC-CODEC3 (encode reads ciphertext from handle)', () => {
it('after the middleware has populated ciphertext, encode returns the ciphertext', async () => {
const codec = createCipherstashStringCodec(makeSdk());
const envelope = EncryptedString.from('secret');
setHandleCiphertext(envelope, { c: 'wire-blob' });

const wire = await codec.encode(envelope, {});
expect(wire).toEqual({ c: 'wire-blob' });
});

it('encode of an envelope whose ciphertext slot is empty (middleware did not run) throws a clear error', async () => {
const codec = createCipherstashStringCodec(makeSdk());
const envelope = EncryptedString.from('secret');
await expect(codec.encode(envelope, {})).rejects.toThrow(/bulk-encrypt middleware/);
});
});

describe('cipherstash codec — AC-CODEC4 (renderOutputType)', () => {
it('renderOutputType returns `EncryptedString`', () => {
const codec = createCipherstashStringCodec(makeSdk());
expect(codec.renderOutputType?.({})).toBe('EncryptedString');
expect(codec.renderOutputType?.({ equality: true, freeTextSearch: false })).toBe(
'EncryptedString',
);
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add coverage for the metadata codec and JSON hooks.

This suite never exercises cipherstashStringCodecMetadata or either codec's encodeJson / decodeJson branches, so a large part of src/core/codecs.ts stays unexecuted. That lines up with the current coverage failure and is easy to close with a few focused assertions here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/3-extensions/cipherstash/test/codecs.test.ts` around lines 15 - 81,
Add tests that exercise cipherstashStringCodecMetadata and the JSON hooks: call
cipherstashStringCodecMetadata(makeSdk()) and assert its id/meta matches
CIPHERSTASH_STRING_CODEC_ID and postgres nativeType; for encodeJson/decodeJson,
invoke createCipherstashStringCodec(makeSdk()).encodeJson and .decodeJson with a
sample plaintext and a JSON wire object respectively and assert round-trip
behavior (decodeJson returns EncryptedString-like envelope with
handle.sdk/table/column when given ctx.column, and encodeJson returns the
ciphertext when handle.ciphertext is set via setHandleCiphertext). Use existing
helpers EncryptedString.from, getInternalHandle, setHandleCiphertext and makeSdk
to locate the right codec instances and verify expected errors/outputs for
missing ctx.column or missing ciphertext.

Comment on lines +16 to +25
it('decrypt() does not require the SDK on the write-side handle', async () => {
const sdk: CipherstashSdk = {
decrypt: vi.fn(),
bulkEncrypt: vi.fn(),
bulkDecrypt: vi.fn(),
};
const envelope = EncryptedString.from('hello');
await envelope.decrypt();
expect(sdk.decrypt).not.toHaveBeenCalled();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This SDK mock is disconnected from the code under test.

EncryptedString.from('hello') never captures sdk, so expect(sdk.decrypt).not.toHaveBeenCalled() will pass regardless of how write-side decrypt is implemented. Either drop this case as redundant with the previous plaintext round-trip test or rewrite it around a path where the same mock can actually be observed.

As per coding guidelines, avoid tautological tests that only restate fixture input - tests must verify behavior, not mirror object shape passed by the test itself.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/3-extensions/cipherstash/test/envelope.test.ts` around lines 16 -
25, The test is tautological because the mock `sdk` is never used by
`EncryptedString.from('hello')`, so `expect(sdk.decrypt).not.toHaveBeenCalled()`
is meaningless; either remove this test or rewrite it to exercise a code path
that actually receives the SDK mock. Concretely: delete this spec, or change it
to construct/use the API that accepts an SDK (e.g., create an envelope via the
write-side method that captures the SDK or call `envelope.decrypt(...)`/the
decrypt helper that takes the `sdk`), then assert `sdk.decrypt` was/was not
invoked; reference `EncryptedString.from` and `decrypt` to locate the current
test and replace it with a version that passes the `sdk` into the code under
test so the mock can be observed.

Comment on lines +77 to +87
it('descriptor.factory(params)(ctx) yields a codec wired to the captured sdk', async () => {
const sdk = makeSdk();
const descriptors = createParameterizedCodecDescriptors(sdk);
const factory = descriptors[0]?.factory;
expect(factory).toBeDefined();
const resolved = factory?.({ equality: true, freeTextSearch: false })({
name: 'cipherstash-string-instance',
});
expect(resolved).toBeDefined();
expect(resolved?.id).toBe(CIPHERSTASH_STRING_CODEC_ID);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The “sdk-bound” assertion never inspects SDK wiring.

This only checks the resolved codec id, so it would still pass if the factory closed over the wrong sdk. Drive the codec through decode(..., { column }) and assert the resulting envelope carries the same SDK, or call decrypt() and verify the captured mock receives the request.

As per coding guidelines, avoid tautological tests that only restate fixture input - tests must verify behavior, not mirror object shape passed by the test itself.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/3-extensions/cipherstash/test/runtime.test.ts` around lines 77 - 87,
The test currently only asserts resolved?.id and doesn't verify the codec
actually closed over the supplied sdk; update the test that calls
createParameterizedCodecDescriptors(sdk) and factory to exercise the codec
(resolved) by invoking its decode(...) with a sample { column } (or call
resolved.decrypt(...) if available) and assert that the resulting envelope
includes the same sdk instance or that the mock sdk from makeSdk() received the
expected call; target symbols: makeSdk, createParameterizedCodecDescriptors,
factory, resolved, decode/decrypt, and envelope/CIPHERSTASH_STRING_CODEC_ID so
the test fails if the codec wired a different sdk.

Comment on lines +774 to +786
function renderRawSql(ast: RawSqlExpr, contract: PostgresContract, pim: ParamIndexMap): string {
const out: string[] = [];
for (let i = 0; i < ast.fragments.length; i++) {
out.push(ast.fragments[i] ?? '');
if (i < ast.args.length) {
const arg = ast.args[i];
if (arg !== undefined) {
out.push(renderExpr(arg, contract, pim));
}
}
}
return out.join('');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast on malformed RawSqlExpr shapes.

renderRawSql() currently masks arity bugs with ?? '' and arg !== undefined. If middleware or an extension builds a node with fragments.length !== args.length + 1, this renderer can emit truncated SQL while collectOrderedParamRefs() still contributes all params, which turns a bad AST into a SQL/params mismatch instead of a clear error.

🧩 Proposed fix
 function renderRawSql(ast: RawSqlExpr, contract: PostgresContract, pim: ParamIndexMap): string {
+  if (ast.fragments.length !== ast.args.length + 1) {
+    throw new Error(
+      `RawSqlExpr requires exactly one more fragment than arg; got ${ast.fragments.length} fragment(s) and ${ast.args.length} arg(s)`,
+    );
+  }
+
   const out: string[] = [];
   for (let i = 0; i < ast.fragments.length; i++) {
-    out.push(ast.fragments[i] ?? '');
+    out.push(ast.fragments[i]);
     if (i < ast.args.length) {
-      const arg = ast.args[i];
-      if (arg !== undefined) {
-        out.push(renderExpr(arg, contract, pim));
-      }
+      out.push(renderExpr(ast.args[i], contract, pim));
     }
   }
   return out.join('');
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts` around lines
774 - 786, renderRawSql currently tolerates malformed RawSqlExpr shapes
(fragments.length !== args.length + 1) by using "?? ''" and skipping undefined
args, which can produce SQL/params mismatch; update renderRawSql (signature:
renderRawSql(ast: RawSqlExpr, contract: PostgresContract, pim: ParamIndexMap))
to validate the AST shape up front: assert fragments is an array and
fragments.length === args.length + 1, and throw a clear error if not; also
validate each fragment and arg is present (no null/undefined) before
concatenation and remove the "?? ''" and loose "arg !== undefined" handling so
malformed nodes fail fast rather than producing truncated SQL that will mismatch
collectOrderedParamRefs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants