Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/skills-eql-v3-accuracy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'stash': patch
---

Correct stale EQL v3 guidance in the bundled agent skills.

`@cipherstash/migrate` and the `stash encrypt *` commands gained EQL v3 support
(cipherstash/stack#648, now closed), but the shipped skills still told readers the
rollout tooling was v2-only. Since these skills are copied into customer repos, the
stale text steered users away from v3 and toward workarounds they no longer need.

- **`stash-drizzle`, `stash-supabase`** — replaced the "v3 not supported end-to-end"
callouts with an accurate EQL version note: the tooling auto-detects a column's
generation from its Postgres domain type, and the two lifecycles differ at the end.
v3 is `backfill → switch the app to the encrypted column by name → drop` with no
cut-over rename; v2 keeps the `stash encrypt cutover` rename plus config promotion.
- **`stash-supabase`** — removed the "Interim path until #648: the v2 encrypted twin"
section; a v2 twin is no longer needed to get CLI-managed backfill.
- **`stash-drizzle`, `stash-supabase`** — the drop step now documents that
`stash encrypt drop` targets the *original* column under v3 (there is no
`<col>_plaintext`, since nothing was renamed) and `<col>_plaintext` under v2.
- **`stash-cli`** — corrected the documented `EQLInstaller` default: `eqlVersion`
defaults to `3`, not `2`, matching the `--eql-version` CLI default. Also reworded
the v2 cut-over known-gap note, which cited cipherstash/stack#585 as open tracking
when it was resolved by making v3 the default.
4 changes: 2 additions & 2 deletions skills/stash-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ In one transaction it renames `<col>` → `<col>_plaintext` and `<col>_encrypted

> **After cutover, `<col>` holds ciphertext — the read path is not automatic.** Wire reads through the encryption client (`decryptModel(row, table)` for Drizzle, the `encryptedSupabaseV3` wrapper for Supabase — `encryptedSupabase` on the legacy v2 surface, otherwise `decrypt` / `bulkDecryptModels`) before returning values to callers. Skip this and your read paths hand raw EQL payloads to end users. The integration skill has the exact API. **CipherStash Proxy is the one exception** — it decrypts on the wire, so Proxy users need no application change. The cutover plan written by `stash plan` includes this read-path switch as an explicit step.
>
> **Known gap (v2).** The pending-configuration precondition is satisfied by `stash db push`. SDK-only users (who otherwise never need `db push`) must therefore run it once before `encrypt cutover`. (EQL v3 columns sidestep this entirely no configuration table, no cutover; see above.) Tracked in [issue #585](https://github.com/cipherstash/stack/issues/585).
> **Known gap (v2).** The pending-configuration precondition is satisfied by `stash db push`. SDK-only users (who otherwise never need `db push`) must therefore run it once before `encrypt cutover`. This gap is specific to the legacy v2 path and is not being decoupled — EQL v3 columns sidestep it entirely (no configuration table, no cut-over; see above), which is how [issue #585](https://github.com/cipherstash/stack/issues/585) was resolved when v3 became the default.

Flags: `--table`, `--column`, `--proxy-url <url>`, `--migrations-dir <path>`.

Expand Down Expand Up @@ -604,7 +604,7 @@ await installer.getInstalledVersion() // string | 'unknown' | null
await installer.install({ supabase: true }) // executes in a transaction
```

`isInstalled`, `getInstalledVersion`, and `install` all accept `eqlVersion?: 2 | 3` (default `2`), selecting the `eql_v2` or `eql_v3` schema. `install` also takes `excludeOperatorFamily`, `supabase`, and `latest` (v2 only).
`isInstalled`, `getInstalledVersion`, and `install` all accept `eqlVersion?: 2 | 3` (default `3`, matching the CLI's `--eql-version` default), selecting the `eql_v3` or `eql_v2` schema. `install` also takes `excludeOperatorFamily`, `supabase`, and `latest` (v2 only).

```typescript
type PermissionCheckResult = {
Expand Down
32 changes: 22 additions & 10 deletions skills/stash-drizzle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,9 @@ if (!decrypted.failure) {

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.

CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and an **encryption cutover** (backfill + rename + drop). (If using CipherStash Proxy, the rollout also includes `stash db push` to register the encryption config with EQL.) The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Drizzle-specific shape.
CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and a **cutover step** (backfill + switch reads + drop — under EQL v2 the switch is a rename, under v3 it is an application-side change). (If using CipherStash Proxy, the rollout also includes `stash db push` to register the encryption config with EQL.) The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Drizzle-specific shape.

> **⚠️ v3 backfill tooling status.** The CLI backfill/cutover tooling (`stash encrypt backfill`, `stash encrypt cutover`, and the underlying `@cipherstash/migrate`) currently targets **EQL v2 columns**. v3 compatibility is tracked in [cipherstash/stack#648](https://github.com/cipherstash/stack/issues/648). The lifecycle below (schema-add → dual-write → deploy gate → backfill → cutover → drop) is the correct shape for v3 either way — until #648 lands, run the backfill/rename steps with your own scripts (encrypt with `bulkEncryptModels`, write in chunks) instead of the `stash encrypt` commands.
> **EQL version note.** The CLI rollout tooling (`stash encrypt *`, and the underlying `@cipherstash/migrate`) works with **both EQL versions** and auto-detects a column's version from its Postgres domain type — there is no flag. The lifecycles differ at the end: **v3** (the default, and what the schema below uses) is `schema-add → dual-write → deploy gate → backfill → switch the app to the encrypted column by name → drop`, with **no cut-over rename**; **v2** finishes with `stash encrypt cutover` (a rename swap plus an `eql_v2_configuration` promotion) before the drop. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished).

> **Where am I?** Run `stash status` first (substitute the runner per the note above). It shows you which Drizzle tables/columns are mid-rollout, which are post-deploy, and what the next move is. Re-run after every transition.

Expand Down Expand Up @@ -474,8 +474,6 @@ Once dual-writes are live in production and `cs_migrations` records `dual_writin

#### Backfill: encrypt the historical rows

> Until [#648](https://github.com/cipherstash/stack/issues/648) lands, the commands in this step target v2 columns — for v3 columns, replicate the same shape with a script (chunked `bulkEncryptModels` + UPDATE inside transactions, resumable and idempotent).

```bash
stash encrypt backfill --table users --column email
# (Interactive: answer 'yes' to the dual-write confirmation prompt.)
Expand All @@ -486,9 +484,18 @@ Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination ord

If something goes wrong (e.g. you discover the dual-write code wasn't actually live when backfill ran), re-run with `--force` to re-encrypt every row regardless of current state.

> **SDK-only note:** `stash encrypt cutover` currently requires a pending EQL configuration set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`.
> **SDK-only note (EQL v2 only):** `stash encrypt cutover` requires a pending EQL configuration set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. EQL v3 columns never hit this — cut-over doesn't apply to them.

#### Switch reads to the encrypted column

**EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps
its own name — you switch the application to it by name, verify reads, then drop
the plaintext column. Point your queries at `email_encrypted`, deploy, and
confirm reads decrypt correctly; then skip ahead to the drop step. Running
`stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished).

#### Cutover: rename swap and activate
The rest of this subsection is the **EQL v2** path (a `eql_v2_encrypted` twin),
kept for existing v2 deployments.

First, update the Drizzle schema to the post-cutover shape — switch `email` to the encrypted type and remove the `email_encrypted` column.

Expand Down Expand Up @@ -547,17 +554,22 @@ Once read paths are updated and you're confident reads are decrypting correctly,
stash encrypt drop --table users --column email
```

The CLI emits a Drizzle migration file with `ALTER TABLE users DROP COLUMN email_plaintext;`. Review and apply with `drizzle-kit migrate`. Update the schema to remove `email_plaintext`:
The CLI emits a Drizzle migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects:

- **v3** — drops the original plaintext column, `ALTER TABLE users DROP COLUMN email;`. There was no rename, so no `email_plaintext` exists. Requires the column to be in the `backfilled` phase, plus a live coverage check.
- **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase.

Review and apply with `drizzle-kit migrate`, then update the schema to its final shape — the encrypted column is the only one left:

```typescript
// src/db/schema.ts (final)
// src/db/schema.ts (final, EQL v3)
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: types.TextSearch('email'),
email_encrypted: types.TextSearch('email_encrypted'),
})
```

Also remove the dual-write code from app paths — `email_plaintext` is gone; only `email` (encrypted) is written now.
Also remove the dual-write code from app paths — the plaintext column is gone; only the encrypted column is written now.

### Inspecting progress at any time

Expand Down
81 changes: 26 additions & 55 deletions skills/stash-supabase/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,58 +627,14 @@ The hard case: a Supabase table that already exists with live data in a plaintex

CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and an **encryption cutover** (backfill + rename + drop). The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Supabase-specific shape.

> **Known gap (EQL v3 backfill).** The `stash encrypt backfill` / `stash encrypt cutover` tooling currently targets EQL v2 (`eql_v2_encrypted`) columns — EQL v3 domain columns are not yet supported end-to-end. Tracked in [cipherstash/stack#648](https://github.com/cipherstash/stack/issues/648). The lifecycle below (rollout → deploy gate → cutover) is the correct shape either way; until #648 lands, run the CLI backfill/cutover commands against a v2 encrypted twin (see "Interim path until #648" just below), or backfill v3 columns with your own script.
> **EQL version note.** The `stash encrypt *` tooling works with **both EQL versions** and auto-detects a column's version from its Postgres domain type — there is no flag. The lifecycles differ at the end: **v3** (the default, and what this section's schema uses) is `rollout → deploy gate → backfill → switch the app to the encrypted column by name → drop`, with **no cut-over rename**; **v2** finishes with `stash encrypt cutover` (a rename swap plus an `eql_v2_configuration` promotion) before the drop. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished).

> **Using CipherStash Proxy?** If you query encrypted data through [CipherStash Proxy](https://github.com/cipherstash/proxy) instead of the SDK, also run `stash db push` after schema-add and again before cutover to register the encrypted column shape with EQL.

> **Runner note.** `stash init` adds `stash` to the project as a dev dependency, so `stash <command>` runs through whichever package manager the project uses (Bun, pnpm, Yarn, or npm) — examples below show this bare form. Before init has run, prefix with your package manager's one-shot runner: `bunx`, `pnpm dlx`, `yarn dlx`, or `npx`. The CLI's behaviour is identical across all of them.

> **Where am I?** Run `stash status` first (substitute the runner per the note above). It shows you which tables/columns are mid-rollout, which are post-deploy, and what the next move is. Re-run after every transition.

### Interim path until #648: the v2 encrypted twin

**This is the interim EQL v2 path, distinct from the v3 surface the rest of
this skill documents.** `stash encrypt backfill` / `stash encrypt cutover`
only operate on `eql_v2_encrypted` columns today, so to get the CLI-managed
backfill/cutover, make the twin a **v2** column and dual-write it through the
legacy `encryptedSupabase` (v2) wrapper. The lifecycle below (rollout → deploy
gate → cutover) is unchanged — only the twin's column type and the dual-write
client differ:

```sql
-- schema-add: v2 twin instead of the v3 domain (still nullable)
ALTER TABLE users
ADD COLUMN email_encrypted eql_v2_encrypted;
```

```typescript
// Dual-write through the legacy v2 wrapper: hand-written client-side schema,
// two-argument from(table, schema).
import { Encryption } from '@cipherstash/stack'
import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema'
import { encryptedSupabase } from '@cipherstash/stack-supabase'

const users = encryptedTable('users', {
email_encrypted: encryptedColumn('email_encrypted').equality().freeTextSearch(),
})

const encryptionClient = await Encryption({ schemas: [users] })
const esV2 = encryptedSupabase({ supabaseClient, encryptionClient })

// Dual-write: BOTH the plaintext column and the encrypted twin.
export async function insertUser(email: string) {
return esV2.from('users', users).insert({
email, // plaintext — keep writing until drop
email_encrypted: email, // v2 twin — encrypted automatically
})
}
```

With the v2 twin in place, the backfill and cutover commands in Step 2 work
as written. Alternatively, keep the twin v3 (as shown in the steps below) and
backfill it with your own script — once #648 lands, the v3 twin becomes the
CLI-supported path end to end.

### Starting state

You have:
Expand Down Expand Up @@ -786,11 +742,22 @@ stash encrypt backfill --table users --column email
# (CI: pass --confirm-dual-writes-deployed instead.)
```

Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination order, encrypts each chunk via the encryption client, and writes the ciphertext into `email_encrypted` inside transactions that also checkpoint to `cs_migrations`. SIGINT-safe. (Remember the known-gap callout above: today this command targets EQL v2 columns — see #648.)
Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination order, encrypts each chunk via the encryption client, and writes the ciphertext into `email_encrypted` inside transactions that also checkpoint to `cs_migrations`. SIGINT-safe. It auto-detects whether the column is EQL v2 or v3 and records that in `cs_migrations`.

If something goes wrong (e.g. you discover the dual-write code wasn't actually live when backfill ran), re-run with `--force` to re-encrypt every row regardless of current state.

#### Cutover: rename swap and activate
#### Switch reads to the encrypted column

**EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps
its own name — point your application at `email_encrypted` through the
`encryptedSupabaseV3` wrapper, deploy, verify reads decrypt correctly, then skip
ahead to the drop step. Running `stash encrypt cutover` on a **backfilled** v3
column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't
finished).

The rest of this subsection is the **EQL v2** path (an `eql_v2_encrypted` twin
queried through the legacy `encryptedSupabase` wrapper), kept for existing v2
deployments.

First, if you use declared `schemas`, update them to the post-cutover shape — the encrypted column will live under the original column name:

Expand All @@ -803,7 +770,7 @@ export const users = encryptedTable('users', {

(Without declared schemas, introspection picks up the renamed column at the next client startup.)

> **Known gap (SDK-only users):** `stash encrypt cutover` currently requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. This will be decoupled in a future release (tracked separately).
> **Known gap (EQL v2, SDK-only users):** `stash encrypt cutover` requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. EQL v3 columns never hit this — cut-over doesn't apply to them.
>
> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix):
>
Expand Down Expand Up @@ -843,7 +810,12 @@ Once read paths are routing through the wrapper and you're confident reads are d
stash encrypt drop --table users --column email
```

The CLI emits a Supabase migration file with `ALTER TABLE users DROP COLUMN email_plaintext;`. Review and apply with `supabase migration up` (or `supabase db reset` locally). Then remove the dual-write code from app paths — `email_plaintext` is gone; only `email` (encrypted) is written now, through the wrapper.
The CLI emits a Supabase migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects:

- **v3** — drops the original plaintext column, `ALTER TABLE users DROP COLUMN email;`. There was no rename, so no `email_plaintext` exists. Requires the `backfilled` phase plus a live coverage check.
- **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase.

Review and apply with `supabase migration up` (or `supabase db reset` locally). Then remove the dual-write code from app paths — the plaintext column is gone; only the encrypted column is written now, through the wrapper.

### Inspecting progress at any time

Expand All @@ -864,9 +836,8 @@ supabaseClient, encryptionClient })` factory, which takes a hand-written
client-side schema and a two-argument `from(tableName, schema)`. That surface
still ships in `@cipherstash/stack-supabase` and is unchanged — keep using it
for existing v2 deployments — but it is not the recommended path for new
projects: use `encryptedSupabaseV3`. One current exception: the v2 wrapper is
also the interim dual-write path for CLI-managed `stash encrypt backfill` /
`stash encrypt cutover` until [cipherstash/stack#648](https://github.com/cipherstash/stack/issues/648)
lands — see "Interim path until #648: the v2 encrypted twin" in the migration
section above for a minimal recipe. For the v2 wrapper's full API and
semantics, see the docs at https://cipherstash.com/docs.
projects: use `encryptedSupabaseV3`. The CLI rollout tooling (`stash encrypt
backfill` / `cutover` / `drop`) supports both generations and auto-detects which
one a column uses, so a v2 twin is no longer needed to get CLI-managed
backfill — see the EQL version note in the migration section above. For the v2
wrapper's full API and semantics, see the docs at https://cipherstash.com/docs.
Loading