Skip to content
Open
65 changes: 65 additions & 0 deletions .changeset/stash-postgres-edge-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
'stash': minor
'@cipherstash/wizard': minor
'@cipherstash/stack': patch
---

Two new bundled agent skills for the integrations that don't use an ORM —
`stash-postgres` and `stash-edge` (#754).

Everything a raw-SQL or edge integration needed was reachable only from
`dist/*.d.ts` JSDoc, the Postgres catalog, or experiment: grepping the skills
`stash init` installs for `postgres-js|::jsonb::eql|sql.json|query_text_search`
returned a single hit, in an unrelated code comment.

**`stash-postgres`** — hand-written SQL over `pg` / `postgres-js`, no ORM. The
column-domain-to-query-domain operator matrix (which of `=`, `<>`, `<`, `>=`,
`@@`, `@>` each encrypted domain accepts, and against which `eql_v3.query_*`
operand), the storage-vs-query payload distinction, per-driver parameter
binding, recipes for equality / free-text / range / `ORDER BY` / JSON
containment / JSON field selectors, and the `information_schema` drift check.
Two failure modes get their mechanism spelled out: pre-stringifying a payload
on postgres-js double-encodes it into a jsonb *string* scalar, tripping the
domain CHECK with a message naming neither JSON nor encoding; and leaving an
operand as bare `jsonb` silently selects a different operator overload — one
that coerces to the *storage* domain and so rejects the ciphertext-free query
term. It also scopes itself against the two things a hand-written-SQL reader
is otherwise left to infer: **CipherStash Proxy** (where you write plaintext
SQL and none of the skill applies — the `usesProxy` fork `stash init` already
asked about), and the provenance of the operator surface itself (the EQL
bundle from `cipherstash/encrypt-query-language`, version-checkable with
`SELECT eql_v3.version()`, and where operator gaps should be filed). Its
domain and operator tables are explicitly marked as a snapshot of a versioned
surface, with a ranked list of authorities to confirm current types against —
the EQL skill first, then the generated `@cipherstash/eql` types and install
SQL, both of which need only `node_modules` and no database.

**`stash-edge`** — the `@cipherstash/stack/wasm-inline` entry for Deno,
Supabase Edge Functions, Cloudflare Workers, and Bun. Import specifier per
runtime, the four mandatory `CS_*` variables and minting them with
`stash env`, how the WASM client surface differs from the native typed client
(no `.audit()`, no `.withLockContext()`, per-item bulk shape, ESM-only), and
the auth-strategy `Result` that must be unwrapped before it reaches
`config.authStrategy`.

Both carry **the credential-identity rule**, a silent data-loss footgun now
also stated in `stash-cli` (under `env` and `encrypt backfill`) and
`stash-supabase`: EQL index terms derive from the ZeroKMS client key, so rows
written under one credential and queried under another decrypt correctly and
never match a query, with no error.

`stash-encryption` now states that the two entries' schema types **do not
interchange** — their column classes carry private fields, so TypeScript
compares them nominally and rejects a shared schema module in both directions.
It works at runtime, which makes a type assertion the tempting fix; the
guidance is to author the schema against exactly one entry instead.

`stash init` / `stash impl` handoffs and the `@cipherstash/wizard` skills
prompt install both skills for the `postgresql` and `supabase` integrations.
Drizzle and Prisma Next get cross-links from their own skills instead, since
those integrations emit correctly-typed operands themselves.
Comment on lines +57 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add example coverage and a documentation-site follow-up.

These changes alter the public stash init / wizard handoff workflow and introduce raw-SQL/WASM usage guidance. Add or update repository examples and explicitly track corresponding updates for the separately maintained documentation site.

As per coding guidelines, “When public APIs or user-facing workflows change, update repository examples and flag that the separately maintained documentation site needs corresponding updates.”

🤖 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 @.changeset/stash-sql-edge-skills.md around lines 48 - 51, Update the change
description to include repository examples covering the revised stash init/impl
handoffs, wizard skill installation, and raw-SQL/WASM guidance, and explicitly
note that the separately maintained documentation site requires corresponding
updates. Preserve the existing integration-specific guidance and cross-links.

Source: Coding guidelines


Also fixes the `@cipherstash/stack/wasm-inline` module JSDoc, which showed
`OidcFederationStrategy.create(...)`'s `Result` being passed straight to
`config.authStrategy` without unwrapping — the same JSDoc the raw-SQL surface
was being reverse-engineered from.
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ If these variables are missing, tests that require live encryption will fail or
- `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README)
- `examples/*`: Working apps (basic, prisma, supabase-worker)
- `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo).
- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`)
- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`)

## Agent Skills — these ship to customers

Expand Down Expand Up @@ -113,6 +113,8 @@ nothing type-checks them, and the damage lands in a customer's repo, not ours.
| Drizzle / Supabase / Prisma Next / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-prisma-next`, `skills/stash-dynamodb` |
| The rollout/cutover lifecycle (`packages/migrate`, `stash encrypt *`) | `skills/stash-encryption` and `skills/stash-cli` |
| The `@cipherstash/eql` pin, `eql install`/`eql migration` behaviour, or index-related SQL guidance | `skills/stash-indexing` |
| The EQL operator/domain surface (`eql_v3.query_*` casts, predicate forms) | `skills/stash-postgres` |
| `packages/stack/src/wasm-inline.ts`, the WASM entry's exports, or `stash env` | `skills/stash-edge` |
| pnpm config, CI workflows, dependency policy | `skills/stash-supply-chain-security` |
| The durable agent rules themselves | `packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md` |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,28 @@ describe('SKILL_MAP', () => {
expect(SKILL_MAP.postgresql).not.toContain('stash-supabase')
expect(SKILL_MAP.postgresql).not.toContain('stash-prisma-next')
})

// #754: the no-ORM path had no source for the raw-SQL binding surface or
// the WASM entry — an integration on it had to reverse-engineer both from
// `dist/*.d.ts` and the Postgres catalog. `postgresql` is that path;
// Supabase shares it (Edge Functions are the flagship WASM-entry use, and
// its migrations/RPC are hand-written SQL).
it.each([
'postgresql',
'supabase',
] as const)('%s includes the raw-SQL and edge skills', (integration) => {
expect(SKILL_MAP[integration]).toContain('stash-postgres')
expect(SKILL_MAP[integration]).toContain('stash-edge')
})

// The ORM integrations emit correctly-typed operands themselves, so they
// get cross-links from their own skills rather than the full install.
it.each([
'drizzle',
'prisma-next',
] as const)('%s does not install the raw-SQL skill', (integration) => {
expect(SKILL_MAP[integration]).not.toContain('stash-postgres')
})
})

describe('skillsFor', () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/commands/init/lib/install-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ import { findBundledDir } from './bundled-paths.js'
*/
export const SKILL_MAP: Record<Integration, readonly string[]> = {
drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'],
// Supabase gets the raw-SQL and edge skills on top of its own: Edge
// Functions are the flagship use of the WASM entry, and Supabase projects
// write hand-written SQL in migrations and RPC even when the app itself
// goes through PostgREST (#754).
supabase: [
'stash-encryption',
'stash-supabase',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
'prisma-next': [
Expand All @@ -24,7 +30,16 @@ export const SKILL_MAP: Record<Integration, readonly string[]> = {
'stash-indexing',
'stash-cli',
],
postgresql: ['stash-encryption', 'stash-indexing', 'stash-cli'],
// The no-ORM path: `stash-postgres` (binding + predicate forms) and `stash-edge`
// (WASM entry, CS_* credentials) are the two skills this integration has no
// other source for — everything else assumes an ORM emits the operands.
postgresql: [
'stash-encryption',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
}

/** The skills every integration gets — the safe fallback for an unmapped one. */
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/init/lib/setup-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ const SKILL_PURPOSES: Record<string, string> = {
'Prisma Next-specific patterns: `cipherstash.*` field constructors, migration flow, encrypted query operators',
'stash-indexing':
'index recipes for encrypted columns — the `eql_v3` extractor functional indexes, Supabase/managed-Postgres constraints, EXPLAIN verification',
'stash-postgres':
'hand-written Postgres SQL over `pg` / `postgres-js`: the encrypted predicate matrix, `eql_v3.query_*` operand casts, per-driver parameter binding',
'stash-edge':
'the `@cipherstash/stack/wasm-inline` entry for Deno / Supabase Edge Functions / Workers: imports, `CS_*` credentials, the credential-identity rule',
'stash-dynamodb':
'DynamoDB encryption: per-item encrypt/decrypt, HMAC attribute keys, audit logging',
'stash-cli':
Expand Down
15 changes: 11 additions & 4 deletions packages/stack/src/wasm-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,24 @@
* import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline"
* import { cookieStore } from "@cipherstash/auth/cookies"
*
* const authStrategy = OidcFederationStrategy.create(
* // `create` returns a Result — UNWRAP it. `config.authStrategy` takes the
* // strategy itself; handing it the Result fails later and opaquely.
* const strategy = OidcFederationStrategy.create(
* "crn:ap-southeast-2.aws:my-workspace-id", () => getClerkSessionToken(req),
* { store: cookieStore({ request: req, responseHeaders }) },
* )
* const client = await Encryption({ schemas, config: { authStrategy, clientId, clientKey } })
* if (strategy.failure) throw new Error(strategy.failure.error.message)
*
* const client = await Encryption({
* schemas, config: { authStrategy: strategy.data, clientId, clientKey },
* })
* ```
*
* For service-to-service / CI use with a custom token store, build an
* `AccessKeyStrategy.create(workspaceCrn, accessKey, { store })` the same
* way (it derives the region from the CRN). Both strategies are
* re-exported from this entry.
* way — same Result-unwrapping, and it derives the region from the CRN. Both
* strategies are re-exported from this entry. An auth strategy and
* `config.accessKey` are mutually exclusive.
*/

import { withResult } from '@byteslice/result'
Expand Down
12 changes: 11 additions & 1 deletion packages/wizard/src/lib/install-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ import type { Integration } from './types.js'
*/
const SKILL_MAP: Record<Integration, readonly string[]> = {
drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'],
// `stash-postgres` / `stash-edge` mirror the CLI's SKILL_MAP — see the comments
// there for why Supabase and the generic (no-ORM) path get them (#754).
supabase: [
'stash-encryption',
'stash-supabase',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
prisma: ['stash-encryption', 'stash-indexing', 'stash-cli'],
generic: ['stash-encryption', 'stash-indexing', 'stash-cli'],
generic: [
'stash-encryption',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
}

/**
Expand Down
10 changes: 10 additions & 0 deletions skills/stash-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ Backfill **detects a `public.eql_v3_*` target column as EQL v3** from its Postgr

**Dual-write precondition.** The application must already write both `<col>` and `<col>_encrypted` on every insert and update. Otherwise rows written *during* the backfill land in plaintext only, silently. The first run prompts (interactive) or requires `--confirm-dual-writes-deployed` (non-interactive), then records `dual_writing`. Resumes don't re-prompt.

**Credential precondition — run the backfill with the *application's* credentials.** Backfill encrypts through whichever `CS_*` credentials are in its environment, and EQL index terms derive from the ZeroKMS client key. Backfilling from a laptop on the local device profile, then querying from an app using credentials minted by `stash env`, produces rows that decrypt correctly and **never match a query** — with no error. Export the target environment's `CS_*` values in the shell running the backfill. See [`env`](#env) and `stash-edge` § The Credential-Identity Rule.

| Flag | Description |
|---|---|
| `--table` / `--column` | Required |
Expand Down Expand Up @@ -516,6 +518,14 @@ CS_CLIENT_KEY=<hex>
CS_CLIENT_ACCESS_KEY=CSAK…
```

> **Every writer of a searchable column must use these same credentials** —
> including `stash encrypt backfill`, seed scripts, and admin tools — or their
> rows decrypt but never match a query. EQL index terms derive from the ZeroKMS
> client key, so a row written under one credential and queried under another
> decrypts correctly and silently fails every search. Mint one credential per
> environment and export it for **every** process that writes that
> environment's data. See `stash-edge` § The Credential-Identity Rule.

Things to know:

- **The access key is shown exactly once** — CTS cannot re-reveal it. Pipe the
Expand Down
2 changes: 2 additions & 0 deletions skills/stash-drizzle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ import { index } from "drizzle-orm/pg-core"

Run `ANALYZE <table>` after the migration applies — an expression index gathers no statistics at `CREATE INDEX` time. For when to create indexes during a rollout (after backfill, before switching reads), engagement rules, and `EXPLAIN` verification, see the `stash-indexing` skill.

> **Dropping to raw SQL?** ``db.execute(sql`…`)`` bypasses the operators this integration emits, so you own the operand casts yourself — an encrypted predicate needs its needle cast to the column's `eql_v3.query_*` domain, and the driver's parameter-binding rules differ between `pg` and `postgres-js`. The `stash-postgres` skill is the reference for both.

## Migrating an Existing Column to Encrypted

The hard case: a Drizzle table that already exists in production with live data in a plaintext column you want to encrypt. You can't just change the column type — that would drop the data and break NOT NULL constraints.
Expand Down
Loading