Skip to content

Commit 5edef3b

Browse files
authored
Merge pull request #706 from cipherstash/fix/skills-accuracy
fix(skills): correct stale EQL v3 rollout guidance in bundled skills
2 parents 9540ec9 + e2d76b7 commit 5edef3b

4 files changed

Lines changed: 75 additions & 67 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
Correct stale EQL v3 guidance in the bundled agent skills.
6+
7+
`@cipherstash/migrate` and the `stash encrypt *` commands gained EQL v3 support
8+
(cipherstash/stack#648, now closed), but the shipped skills still told readers the
9+
rollout tooling was v2-only. Since these skills are copied into customer repos, the
10+
stale text steered users away from v3 and toward workarounds they no longer need.
11+
12+
- **`stash-drizzle`, `stash-supabase`** — replaced the "v3 not supported end-to-end"
13+
callouts with an accurate EQL version note: the tooling auto-detects a column's
14+
generation from its Postgres domain type, and the two lifecycles differ at the end.
15+
v3 is `backfill → switch the app to the encrypted column by name → drop` with no
16+
cut-over rename; v2 keeps the `stash encrypt cutover` rename plus config promotion.
17+
- **`stash-supabase`** — removed the "Interim path until #648: the v2 encrypted twin"
18+
section; a v2 twin is no longer needed to get CLI-managed backfill.
19+
- **`stash-drizzle`, `stash-supabase`** — the drop step now documents that
20+
`stash encrypt drop` targets the *original* column under v3 (there is no
21+
`<col>_plaintext`, since nothing was renamed) and `<col>_plaintext` under v2.
22+
- **`stash-cli`** — corrected the documented `EQLInstaller` default: `eqlVersion`
23+
defaults to `3`, not `2`, matching the `--eql-version` CLI default. Also reworded
24+
the v2 cut-over known-gap note, which cited cipherstash/stack#585 as open tracking
25+
when it was resolved by making v3 the default.

skills/stash-cli/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ In one transaction it renames `<col>` → `<col>_plaintext` and `<col>_encrypted
508508

509509
> **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.
510510
>
511-
> **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).
511+
> **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.
512512
513513
Flags: `--table`, `--column`, `--proxy-url <url>`, `--migrations-dir <path>`.
514514

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

607-
`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).
607+
`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).
608608

609609
```typescript
610610
type PermissionCheckResult = {

skills/stash-drizzle/SKILL.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,9 @@ if (!decrypted.failure) {
365365

366366
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.
367367

368-
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.
368+
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.
369369

370-
> **⚠️ 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.
370+
> **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).
371371
372372
> **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.
373373
@@ -474,8 +474,6 @@ Once dual-writes are live in production and `cs_migrations` records `dual_writin
474474
475475
#### Backfill: encrypt the historical rows
476476
477-
> 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).
478-
479477
```bash
480478
stash encrypt backfill --table users --column email
481479
# (Interactive: answer 'yes' to the dual-write confirmation prompt.)
@@ -486,9 +484,18 @@ Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination ord
486484
487485
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.
488486
489-
> **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`.
487+
> **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.
488+
489+
#### Switch reads to the encrypted column
490+
491+
**EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps
492+
its own name — you switch the application to it by name, verify reads, then drop
493+
the plaintext column. Point your queries at `email_encrypted`, deploy, and
494+
confirm reads decrypt correctly; then skip ahead to the drop step. Running
495+
`stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished).
490496
491-
#### Cutover: rename swap and activate
497+
The rest of this subsection is the **EQL v2** path (a `eql_v2_encrypted` twin),
498+
kept for existing v2 deployments.
492499
493500
First, update the Drizzle schema to the post-cutover shape — switch `email` to the encrypted type and remove the `email_encrypted` column.
494501
@@ -547,17 +554,22 @@ Once read paths are updated and you're confident reads are decrypting correctly,
547554
stash encrypt drop --table users --column email
548555
```
549556
550-
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`:
557+
The CLI emits a Drizzle migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects:
558+
559+
- **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.
560+
- **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase.
561+
562+
Review and apply with `drizzle-kit migrate`, then update the schema to its final shape — the encrypted column is the only one left:
551563
552564
```typescript
553-
// src/db/schema.ts (final)
565+
// src/db/schema.ts (final, EQL v3)
554566
export const users = pgTable('users', {
555567
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
556-
email: types.TextSearch('email'),
568+
email_encrypted: types.TextSearch('email_encrypted'),
557569
})
558570
```
559571
560-
Also remove the dual-write code from app paths — `email_plaintext` is gone; only `email` (encrypted) is written now.
572+
Also remove the dual-write code from app paths — the plaintext column is gone; only the encrypted column is written now.
561573
562574
### Inspecting progress at any time
563575

skills/stash-supabase/SKILL.md

Lines changed: 26 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -627,58 +627,14 @@ The hard case: a Supabase table that already exists with live data in a plaintex
627627
628628
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.
629629
630-
> **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.
630+
> **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 `rolloutdeploy gatebackfillswitch the app to the encrypted column by namedrop`, 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).
631631
632632
> **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.
633633
634634
> **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.
635635
636636
> **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.
637637
638-
### Interim path until #648: the v2 encrypted twin
639-
640-
**This is the interim EQL v2 path, distinct from the v3 surface the rest of
641-
this skill documents.** `stash encrypt backfill` / `stash encrypt cutover`
642-
only operate on `eql_v2_encrypted` columns today, so to get the CLI-managed
643-
backfill/cutover, make the twin a **v2** column and dual-write it through the
644-
legacy `encryptedSupabase` (v2) wrapper. The lifecycle below (rollout → deploy
645-
gate → cutover) is unchanged — only the twin's column type and the dual-write
646-
client differ:
647-
648-
```sql
649-
-- schema-add: v2 twin instead of the v3 domain (still nullable)
650-
ALTER TABLE users
651-
ADD COLUMN email_encrypted eql_v2_encrypted;
652-
```
653-
654-
```typescript
655-
// Dual-write through the legacy v2 wrapper: hand-written client-side schema,
656-
// two-argument from(table, schema).
657-
import { Encryption } from '@cipherstash/stack'
658-
import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema'
659-
import { encryptedSupabase } from '@cipherstash/stack-supabase'
660-
661-
const users = encryptedTable('users', {
662-
email_encrypted: encryptedColumn('email_encrypted').equality().freeTextSearch(),
663-
})
664-
665-
const encryptionClient = await Encryption({ schemas: [users] })
666-
const esV2 = encryptedSupabase({ supabaseClient, encryptionClient })
667-
668-
// Dual-write: BOTH the plaintext column and the encrypted twin.
669-
export async function insertUser(email: string) {
670-
return esV2.from('users', users).insert({
671-
email, // plaintext — keep writing until drop
672-
email_encrypted: email, // v2 twin — encrypted automatically
673-
})
674-
}
675-
```
676-
677-
With the v2 twin in place, the backfill and cutover commands in Step 2 work
678-
as written. Alternatively, keep the twin v3 (as shown in the steps below) and
679-
backfill it with your own script — once #648 lands, the v3 twin becomes the
680-
CLI-supported path end to end.
681-
682638
### Starting state
683639
684640
You have:
@@ -786,11 +742,22 @@ stash encrypt backfill --table users --column email
786742
# (CI: pass --confirm-dual-writes-deployed instead.)
787743
```
788744
789-
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.)
745+
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`.
790746
791747
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.
792748
793-
#### Cutover: rename swap and activate
749+
#### Switch reads to the encrypted column
750+
751+
**EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps
752+
its own name — point your application at `email_encrypted` through the
753+
`encryptedSupabaseV3` wrapper, deploy, verify reads decrypt correctly, then skip
754+
ahead to the drop step. Running `stash encrypt cutover` on a **backfilled** v3
755+
column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't
756+
finished).
757+
758+
The rest of this subsection is the **EQL v2** path (an `eql_v2_encrypted` twin
759+
queried through the legacy `encryptedSupabase` wrapper), kept for existing v2
760+
deployments.
794761
795762
First, if you use declared `schemas`, update them to the post-cutover shape — the encrypted column will live under the original column name:
796763
@@ -803,7 +770,7 @@ export const users = encryptedTable('users', {
803770
804771
(Without declared schemas, introspection picks up the renamed column at the next client startup.)
805772
806-
> **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).
773+
> **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.
807774
>
808775
> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix):
809776
>
@@ -843,7 +810,12 @@ Once read paths are routing through the wrapper and you're confident reads are d
843810
stash encrypt drop --table users --column email
844811
```
845812
846-
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.
813+
The CLI emits a Supabase migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects:
814+
815+
- **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.
816+
- **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase.
817+
818+
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.
847819
848820
### Inspecting progress at any time
849821
@@ -864,9 +836,8 @@ supabaseClient, encryptionClient })` factory, which takes a hand-written
864836
client-side schema and a two-argument `from(tableName, schema)`. That surface
865837
still ships in `@cipherstash/stack-supabase` and is unchanged — keep using it
866838
for existing v2 deployments — but it is not the recommended path for new
867-
projects: use `encryptedSupabaseV3`. One current exception: the v2 wrapper is
868-
also the interim dual-write path for CLI-managed `stash encrypt backfill` /
869-
`stash encrypt cutover` until [cipherstash/stack#648](https://github.com/cipherstash/stack/issues/648)
870-
lands — see "Interim path until #648: the v2 encrypted twin" in the migration
871-
section above for a minimal recipe. For the v2 wrapper's full API and
872-
semantics, see the docs at https://cipherstash.com/docs.
839+
projects: use `encryptedSupabaseV3`. The CLI rollout tooling (`stash encrypt
840+
backfill` / `cutover` / `drop`) supports both generations and auto-detects which
841+
one a column uses, so a v2 twin is no longer needed to get CLI-managed
842+
backfill — see the EQL version note in the migration section above. For the v2
843+
wrapper's full API and semantics, see the docs at https://cipherstash.com/docs.

0 commit comments

Comments
 (0)