From 689021ea0cbdff6c0857a1257fa4c6af3a6db428 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 17:21:29 +1000 Subject: [PATCH 1/4] docs: add EQL v2 removal PR1 plan --- .../2026-07-22-eql-v2-removal-pr1-plan.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md diff --git a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md new file mode 100644 index 000000000..f37c3219b --- /dev/null +++ b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md @@ -0,0 +1,70 @@ +# EQL v2 removal — PR 1 step-plan (delete published v2 packages) + +Executes PR 1 of `docs/plans/2026-07-22-eql-v2-final-removal-design.md`. +Branch: `feat/remove-eql-v2-pr1-delete-packages` (worktree). Scoped against `origin/main` = `6ce53817`. + +## Goal +Delete the closed v2-only dependency chain — `@cipherstash/protect-dynamodb` → +`@cipherstash/protect` → `@cipherstash/schema` — and remove every reference so the +build, lockfile, and changeset state stay consistent. Mergeable in isolation. + +## Verified scope (live-code survey, not just design line-counts) +Closed-chain claim CONFIRMED for code imports: nothing outside the three imports them, +and `@cipherstash/stack` depends only on `@cipherstash/protect-ffi` (a different, external +package), not on any of the three. + +Corrections vs. the design's PR-1 paragraph: +- `.changeset/config.json` — the three are NOT in the `fixed` group and `ignore` is `[]`. + No config edit required (design assumed a fixed-group removal). +- `pnpm-workspace.yaml` uses globs (`packages/*`), no explicit entries to remove. +- No `tsconfig` `references` anywhere — nothing to unpick. +- Extra build blockers the design folded under "root config": `e2e/package.json` dep edge + and root `package.json` `build:js` turbo filter. +- Extra changeset-state fixes: pending `schema-stevec-standard-pin.md` targets the doomed + `@cipherstash/schema`; `pre.json` pins all three in `initialVersions`. + +## Steps + +### 1. Delete the three package directories +- `rm -rf packages/protect-dynamodb packages/protect packages/schema` + +### 2. Fix build blockers (dangling references that break compile/CI) +- `e2e/package.json` — remove the `"@cipherstash/protect": "workspace:*"` dependency line. +- root `package.json` — `build:js`: drop `--filter './packages/protect'`, keep `./packages/nextjs`. + +### 3. Clean stale (non-breaking) references +- `scripts/lint-no-hardcoded-runners.mjs` — remove the `packages/protect/src/bin/runner.ts` + allowlist entry (verify the script doesn't assert the path exists — if it does, this is + actually a blocker). +- `packages/nextjs/package.json` — description references `@cipherstash/protect`; repoint to + `@cipherstash/stack`. +- `skills/stash-drizzle/SKILL.md:38` — inspect the `@cipherstash/protect` mention; fix only if + the deletion made it wrong (a historical note about the legacy protect-based package may stay). + +### 4. Changeset / RC-mode housekeeping +- Delete `.changeset/schema-stevec-standard-pin.md` (only target is the deleted `@cipherstash/schema`; + already consumed in a prior rc per `pre.json`). +- `.changeset/pre.json` — remove the three from `initialVersions`; remove `schema-stevec-standard-pin` + from the `changesets` array (keeps it consistent with the deleted file). +- Add deletion-notice changeset `.changeset/remove-eql-v2-packages.md`: + `'@cipherstash/stack': patch` (successor surface for all three; group already major via + `stack-1-0-0-rc`), prose body naming each removed package and its migration path + (`@cipherstash/protect` → `@cipherstash/stack`; `@cipherstash/schema` → `@cipherstash/stack/schema`; + `@cipherstash/protect-dynamodb` → `@cipherstash/stack/dynamodb` `encryptedDynamoDB`). + Follows the `remove-legacy-drizzle-package.md` precedent. + +### 5. Meta-file honesty (trim what described the removed packages) +- `SECURITY.md` — drop the three rows from the package list. +- `AGENTS.md` — Repository Layout entries (protect, schema, protect-dynamodb) + prose mentions; + keep the "maintained implementation is `packages/stack/src/dynamodb`" guidance. + +### 6. Regenerate lockfile +- `pnpm install` (updates `pnpm-lock.yaml` for removed packages + e2e edge). CI is + `--frozen-lockfile`, so the committed lockfile must match. + +## Verification (green gate before commit) +- `pnpm changeset status` — no changeset references a missing package. +- `pnpm run build` — whole-repo turbo build; proves no dangling import/reference. +- `pnpm run code:check` — biome, error-free. +- `git grep -n "@cipherstash/protect\b\|@cipherstash/schema\|@cipherstash/protect-dynamodb"` — + only intentional survivors (e.g. migration-path prose, `protect-ffi` which is unrelated). From 27a0915902586d0b1d794762f4ed3c2c80629500 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 17:34:35 +1000 Subject: [PATCH 2/4] docs: correct plan changeset targets and stale-ref grep pattern - Note the intentional @cipherstash/nextjs patch (package.json description edit) - Use PCRE negative lookahead so the stale-reference check excludes protect-ffi --- docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md index f37c3219b..54e53c516 100644 --- a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md +++ b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md @@ -48,7 +48,9 @@ Corrections vs. the design's PR-1 paragraph: from the `changesets` array (keeps it consistent with the deleted file). - Add deletion-notice changeset `.changeset/remove-eql-v2-packages.md`: `'@cipherstash/stack': patch` (successor surface for all three; group already major via - `stack-1-0-0-rc`), prose body naming each removed package and its migration path + `stack-1-0-0-rc`) and `'@cipherstash/nextjs': patch` (its `package.json` description changes + from `@cipherstash/protect` to `@cipherstash/stack` — a published-metadata edit), prose body + naming each removed package and its migration path (`@cipherstash/protect` → `@cipherstash/stack`; `@cipherstash/schema` → `@cipherstash/stack/schema`; `@cipherstash/protect-dynamodb` → `@cipherstash/stack/dynamodb` `encryptedDynamoDB`). Follows the `remove-legacy-drizzle-package.md` precedent. @@ -66,5 +68,7 @@ Corrections vs. the design's PR-1 paragraph: - `pnpm changeset status` — no changeset references a missing package. - `pnpm run build` — whole-repo turbo build; proves no dangling import/reference. - `pnpm run code:check` — biome, error-free. -- `git grep -n "@cipherstash/protect\b\|@cipherstash/schema\|@cipherstash/protect-dynamodb"` — - only intentional survivors (e.g. migration-path prose, `protect-ffi` which is unrelated). +- `git grep -nP "@cipherstash/protect(?!-ffi)|@cipherstash/schema|@cipherstash/protect-dynamodb"` — + only intentional survivors (e.g. migration-path prose). The `(?!-ffi)` lookahead (PCRE, hence + `-P`) excludes the unrelated `@cipherstash/protect-ffi`; a plain `\b` would not, since a word + boundary sits between `protect` and `-ffi`. From a88fac420f115a1b3a46415d0c1fddb02e224be5 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 17:33:04 +1000 Subject: [PATCH 3/4] refactor(migrate): drop EQL v2 from the domain-type classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `eql_v2_encrypted → 2` branch from `classifyEqlDomain`, so the migrate domain-type resolution (`detectColumnEqlVersion`, `listEncryptedColumns`, `resolveEncryptedColumn`) recognises only the self-describing `eql_v3_*` domains. v3 is the sole generation this workspace authors and backfills; a legacy v2 column's version is carried by the manifest's recorded `eqlVersion` (the CLI status renderers already fall back to it), so v2 status output is unchanged. This drops v2 *classification*, not the v2 read path — existing v2 ciphertext stays decryptable via `@cipherstash/stack`. `EqlVersion` keeps its `2` member for manifest-sourced legacy values and the exported function signatures are unchanged. Tests in `version.test.ts` are updated to assert v2 domains are no longer classified (excluded from `listEncryptedColumns`, `null` from the detectors). Decision 6 guard: `classifyEqlDomain` is a source-column classifier for backfill planning and read-only CLI status display — no decrypt/round-trip consumes its `2` result — so dropping v2 here is safe. NOTE: `packages/migrate/src/eql.ts` (the `eql_v2.*` config-lifecycle wrappers) is NOT deleted in this PR. Although it carries no decrypt path, it is hard- imported by the CLI's v2 cut-over/config commands (`encrypt cutover`, `db activate`, `db push`); deleting it would break the CLI build, which is out of this migrate-scoped PR. Its removal must be sequenced with the CLI v2-command removal. PR 2 of the EQL v2 final removal (#707). --- .../remove-eql-v2-migrate-classifier.md | 17 ++++++++++ .../migrate/src/__tests__/version.test.ts | 34 ++++++++++++++----- packages/migrate/src/version.ts | 15 +++++--- 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 .changeset/remove-eql-v2-migrate-classifier.md diff --git a/.changeset/remove-eql-v2-migrate-classifier.md b/.changeset/remove-eql-v2-migrate-classifier.md new file mode 100644 index 000000000..45d163efa --- /dev/null +++ b/.changeset/remove-eql-v2-migrate-classifier.md @@ -0,0 +1,17 @@ +--- +'@cipherstash/migrate': patch +--- + +Drop EQL v2 from the domain-type classifier. `classifyEqlDomain` (and the +`detectColumnEqlVersion` / `listEncryptedColumns` / `resolveEncryptedColumn` +resolution built on it) no longer recognise the legacy `eql_v2_encrypted` +domain — v3 is the sole generation this workspace authors and backfills, so a +column's version is now determined solely from its self-describing `eql_v3_*` +domain type. A legacy v2 column's version is carried by the manifest's recorded +`eqlVersion` instead (the CLI's `encrypt status` / `status` renderers already +fall back to it), so status output is unchanged for v2 columns. + +This removes v2 *classification*, not the v2 read path: existing v2 ciphertext +remains decryptable through `@cipherstash/stack`. `EqlVersion` keeps its `2` +member for manifest-sourced legacy values; the exported function signatures are +unchanged. diff --git a/packages/migrate/src/__tests__/version.test.ts b/packages/migrate/src/__tests__/version.test.ts index b6cfdb9d6..96e5cfbb6 100644 --- a/packages/migrate/src/__tests__/version.test.ts +++ b/packages/migrate/src/__tests__/version.test.ts @@ -18,8 +18,13 @@ function mockClient(rows: Array>) { } describe('classifyEqlDomain', () => { - it('maps eql_v2_encrypted to 2', () => { - expect(classifyEqlDomain('eql_v2_encrypted')).toBe(2) + it('no longer classifies eql_v2_encrypted — v3 is the sole authored generation', () => { + // The v2 branch was removed: this workspace authors/backfills v3 only, so + // the domain classifier recognises `eql_v3_*` alone. A legacy v2 column's + // version now comes from the manifest's recorded `eqlVersion`, not here. + // (Existing v2 ciphertext stays decryptable — only classification of v2 as + // an authorable generation is dropped.) + expect(classifyEqlDomain('eql_v2_encrypted')).toBeNull() }) it('maps any eql_v3_* domain to 3', () => { @@ -46,10 +51,20 @@ describe('classifyEqlDomain', () => { describe('detectColumnEqlVersion', () => { it('classifies from the domain type', async () => { - const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }]) + const { client } = mockClient([{ domain_name: 'eql_v3_text_search' }]) expect( await detectColumnEqlVersion(client, 'users', 'email_encrypted'), - ).toBe(2) + ).toBe(3) + }) + + it('returns null for a legacy eql_v2_encrypted domain (v2 no longer classified)', async () => { + // A v2 column still exists physically, but the classifier no longer treats + // it as an authorable EQL generation — callers fall back to the manifest's + // recorded eqlVersion. + const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }]) + expect( + await detectColumnEqlVersion(client, 'users', 'ssn_encrypted'), + ).toBeNull() }) it('returns null for a plaintext column (base type, not a domain)', async () => { @@ -88,16 +103,17 @@ describe('detectColumnEqlVersion', () => { }) describe('listEncryptedColumns', () => { - it('returns only EQL-domain columns, classified', async () => { + it('returns only EQL v3-domain columns, classified (legacy v2 domains excluded)', async () => { const { client } = mockClient([ { column: 'id', domain_name: 'int8' }, { column: 'email', domain_name: 'text' }, { column: 'email_enc', domain_name: 'eql_v3_text_search' }, + // A legacy v2 column is no longer classified as EQL, so it drops out of + // the encrypted-column listing entirely. { column: 'ssn_encrypted', domain_name: 'eql_v2_encrypted' }, ]) expect(await listEncryptedColumns(client, 'users')).toEqual([ { column: 'email_enc', domain: 'eql_v3_text_search', version: 3 }, - { column: 'ssn_encrypted', domain: 'eql_v2_encrypted', version: 2 }, ]) }) }) @@ -153,10 +169,10 @@ describe('pickEncryptedColumn', () => { }) it('never resolves the plaintext column to itself', () => { - // Post-cutover v2: `email` itself carries the v2 domain. It is the - // ciphertext, not a counterpart of itself. + // A column that IS the plaintext argument cannot be its own encrypted + // counterpart, even when it's the table's sole EQL-domain column. expect( - pickEncryptedColumn([col('email', 'eql_v2_encrypted', 2)], 'email'), + pickEncryptedColumn([col('email', 'eql_v3_encrypted')], 'email'), ).toBeNull() }) diff --git a/packages/migrate/src/version.ts b/packages/migrate/src/version.ts index dccfe7e35..60e1d9d41 100644 --- a/packages/migrate/src/version.ts +++ b/packages/migrate/src/version.ts @@ -17,7 +17,7 @@ export type EqlVersion = 2 | 3 export interface EncryptedColumnInfo { /** The column's name, exactly as Postgres reports it. */ column: string - /** The EQL domain name, e.g. `eql_v2_encrypted` or `eql_v3_text_search`. */ + /** The EQL domain name, e.g. `eql_v3_text_search` or `eql_v3_integer_ord`. */ domain: string version: EqlVersion } @@ -30,12 +30,19 @@ export interface EncryptedColumnInfo { * rule lives, and why detection never relies on column NAMES: the * `_encrypted` naming is a convention, neither enforced nor required. * - * - `eql_v2_encrypted` → 2 * - `eql_v3_*` (e.g. `eql_v3_text_search`, `eql_v3_integer_ord`) → 3 - * - anything else → `null` (not an EQL column) + * - anything else → `null` (not an EQL v3 column) + * + * v3 is the sole generation this workspace authors and backfills, so the + * classifier only recognises `eql_v3_*` domains. A legacy `eql_v2_encrypted` + * column is therefore no longer classified here — its version is carried by + * the manifest's recorded `eqlVersion` instead (see the `?? eqlVersion` + * fallbacks in the CLI's `encrypt status` / `status` renderers). Existing v2 + * ciphertext stays decryptable; only its *classification as an authorable + * generation* is dropped. `EqlVersion` keeps the `2` member for those + * manifest-sourced legacy values. */ export function classifyEqlDomain(domain: string): EqlVersion | null { - if (domain === 'eql_v2_encrypted') return 2 // Underscore included: a bare `startsWith('eql_v3')` would also claim // hypothetical future generations like `eql_v30_*`. if (domain.startsWith('eql_v3_')) return 3 From b3a3499d5057e6d400dfa100b0400d822e14c682 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 15:07:51 +1000 Subject: [PATCH 4/4] refactor(cli): drop the unreachable v2 branch left by the classifier change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classifyEqlDomain` now returns `3 | null`, so `listEncryptedColumns` can never emit `version: 2` and the `c.version === 2` exemption in `explainUnresolved` is dead. Removing it is provably behaviour-preserving: a post-cutover v2 table (`` carrying the v2 domain) now reaches `explainUnresolved` with an EMPTY candidate list, which the `candidates.length === 0` guard above already falls through on. - Remove the branch; rewrite the doc comment and the stale v2 parentheticals in `drop.ts` / `cutover.ts` to say why the post-cutover state now arrives as "no EQL columns". - Rewrite the drop test that hand-built a `version: 2` candidate — a state resolution can no longer produce, so it only exercised the dead branch — to the state that actually occurs. - Add unit tests pinning `explainUnresolved`'s contract, including that a candidate sharing the plaintext column's name still fails closed (the removed branch's only behaviour, and wrong at v3, which has no cut-over rename). - Correct the backfill manifest comments: `null` now also means a legacy `eql_v2_encrypted` domain, so a v2 column backfilled from here on records no `eqlVersion` and reports no version in `encrypt status`. The live-domain fallback yields null for that case too. Existing manifests are unaffected. Noted in the changeset. - Drop the PR-1 plan doc that landed in this PR's diff. --- .../remove-eql-v2-migrate-classifier.md | 5 +- .../2026-07-22-eql-v2-removal-pr1-plan.md | 74 ------------------- .../encrypt/__tests__/encrypt-v3.test.ts | 12 ++- packages/cli/src/commands/encrypt/backfill.ts | 20 +++-- packages/cli/src/commands/encrypt/cutover.ts | 6 +- packages/cli/src/commands/encrypt/drop.ts | 5 +- .../encrypt/lib/__tests__/resolve-eql.test.ts | 59 +++++++++++++++ .../src/commands/encrypt/lib/resolve-eql.ts | 24 +++--- 8 files changed, 106 insertions(+), 99 deletions(-) delete mode 100644 docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md create mode 100644 packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts diff --git a/.changeset/remove-eql-v2-migrate-classifier.md b/.changeset/remove-eql-v2-migrate-classifier.md index 45d163efa..e6ae23fb6 100644 --- a/.changeset/remove-eql-v2-migrate-classifier.md +++ b/.changeset/remove-eql-v2-migrate-classifier.md @@ -9,7 +9,10 @@ domain — v3 is the sole generation this workspace authors and backfills, so a column's version is now determined solely from its self-describing `eql_v3_*` domain type. A legacy v2 column's version is carried by the manifest's recorded `eqlVersion` instead (the CLI's `encrypt status` / `status` renderers already -fall back to it), so status output is unchanged for v2 columns. +fall back to it), so status output is unchanged for v2 columns already recorded +in `.cipherstash/migrations.json`. A v2 column backfilled from here on records +no `eqlVersion` and so reports no version in `stash encrypt status` — the v2 +lifecycle itself (cut-over, then dropping `_plaintext`) is unaffected. This removes v2 *classification*, not the v2 read path: existing v2 ciphertext remains decryptable through `@cipherstash/stack`. `EqlVersion` keeps its `2` diff --git a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md deleted file mode 100644 index 54e53c516..000000000 --- a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md +++ /dev/null @@ -1,74 +0,0 @@ -# EQL v2 removal — PR 1 step-plan (delete published v2 packages) - -Executes PR 1 of `docs/plans/2026-07-22-eql-v2-final-removal-design.md`. -Branch: `feat/remove-eql-v2-pr1-delete-packages` (worktree). Scoped against `origin/main` = `6ce53817`. - -## Goal -Delete the closed v2-only dependency chain — `@cipherstash/protect-dynamodb` → -`@cipherstash/protect` → `@cipherstash/schema` — and remove every reference so the -build, lockfile, and changeset state stay consistent. Mergeable in isolation. - -## Verified scope (live-code survey, not just design line-counts) -Closed-chain claim CONFIRMED for code imports: nothing outside the three imports them, -and `@cipherstash/stack` depends only on `@cipherstash/protect-ffi` (a different, external -package), not on any of the three. - -Corrections vs. the design's PR-1 paragraph: -- `.changeset/config.json` — the three are NOT in the `fixed` group and `ignore` is `[]`. - No config edit required (design assumed a fixed-group removal). -- `pnpm-workspace.yaml` uses globs (`packages/*`), no explicit entries to remove. -- No `tsconfig` `references` anywhere — nothing to unpick. -- Extra build blockers the design folded under "root config": `e2e/package.json` dep edge - and root `package.json` `build:js` turbo filter. -- Extra changeset-state fixes: pending `schema-stevec-standard-pin.md` targets the doomed - `@cipherstash/schema`; `pre.json` pins all three in `initialVersions`. - -## Steps - -### 1. Delete the three package directories -- `rm -rf packages/protect-dynamodb packages/protect packages/schema` - -### 2. Fix build blockers (dangling references that break compile/CI) -- `e2e/package.json` — remove the `"@cipherstash/protect": "workspace:*"` dependency line. -- root `package.json` — `build:js`: drop `--filter './packages/protect'`, keep `./packages/nextjs`. - -### 3. Clean stale (non-breaking) references -- `scripts/lint-no-hardcoded-runners.mjs` — remove the `packages/protect/src/bin/runner.ts` - allowlist entry (verify the script doesn't assert the path exists — if it does, this is - actually a blocker). -- `packages/nextjs/package.json` — description references `@cipherstash/protect`; repoint to - `@cipherstash/stack`. -- `skills/stash-drizzle/SKILL.md:38` — inspect the `@cipherstash/protect` mention; fix only if - the deletion made it wrong (a historical note about the legacy protect-based package may stay). - -### 4. Changeset / RC-mode housekeeping -- Delete `.changeset/schema-stevec-standard-pin.md` (only target is the deleted `@cipherstash/schema`; - already consumed in a prior rc per `pre.json`). -- `.changeset/pre.json` — remove the three from `initialVersions`; remove `schema-stevec-standard-pin` - from the `changesets` array (keeps it consistent with the deleted file). -- Add deletion-notice changeset `.changeset/remove-eql-v2-packages.md`: - `'@cipherstash/stack': patch` (successor surface for all three; group already major via - `stack-1-0-0-rc`) and `'@cipherstash/nextjs': patch` (its `package.json` description changes - from `@cipherstash/protect` to `@cipherstash/stack` — a published-metadata edit), prose body - naming each removed package and its migration path - (`@cipherstash/protect` → `@cipherstash/stack`; `@cipherstash/schema` → `@cipherstash/stack/schema`; - `@cipherstash/protect-dynamodb` → `@cipherstash/stack/dynamodb` `encryptedDynamoDB`). - Follows the `remove-legacy-drizzle-package.md` precedent. - -### 5. Meta-file honesty (trim what described the removed packages) -- `SECURITY.md` — drop the three rows from the package list. -- `AGENTS.md` — Repository Layout entries (protect, schema, protect-dynamodb) + prose mentions; - keep the "maintained implementation is `packages/stack/src/dynamodb`" guidance. - -### 6. Regenerate lockfile -- `pnpm install` (updates `pnpm-lock.yaml` for removed packages + e2e edge). CI is - `--frozen-lockfile`, so the committed lockfile must match. - -## Verification (green gate before commit) -- `pnpm changeset status` — no changeset references a missing package. -- `pnpm run build` — whole-repo turbo build; proves no dangling import/reference. -- `pnpm run code:check` — biome, error-free. -- `git grep -nP "@cipherstash/protect(?!-ffi)|@cipherstash/schema|@cipherstash/protect-dynamodb"` — - only intentional survivors (e.g. migration-path prose). The `(?!-ffi)` lookahead (PCRE, hence - `-P`) excludes the unrelated `@cipherstash/protect-ffi`; a plain `\b` would not, since a word - boundary sits between `protect` and `-ffi`. diff --git a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts index da2389aff..45def1ef2 100644 --- a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts +++ b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts @@ -430,10 +430,14 @@ describe('encrypt drop — EQL version awareness', () => { // After cutover renamed the ciphertext onto `email`, no counterpart is // resolvable BY DESIGN. The fail-closed guard must recognize this state // rather than blocking the one drop the lifecycle actually wants. - lifecycleMock.mockResolvedValue({ - info: null, - candidates: [{ column: 'email', domain: 'eql_v2_encrypted', version: 2 }], - }) + // + // `candidates` is EMPTY, not `[{ column: 'email', version: 2 }]`: the + // classifier no longer recognises `eql_v2_encrypted`, so a post-cutover v2 + // column drops out of `listEncryptedColumns` entirely. The state reaches + // `explainUnresolved` as "no EQL columns at all", which is exactly the + // case it already falls through on. This pins that the v2 lifecycle still + // works through the narrower classifier. + lifecycleMock.mockResolvedValue({ info: null, candidates: [] }) migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' }) await dropCommand({ table: 'users', column: 'email' }) diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts index 7f7edb006..1698d8825 100644 --- a/packages/cli/src/commands/encrypt/backfill.ts +++ b/packages/cli/src/commands/encrypt/backfill.ts @@ -141,8 +141,11 @@ export async function backfillCommand(options: BackfillCommandOptions) { // v2 or v3 changes the rest of the LIFECYCLE (v3 has no cut-over — the // ladder is backfill → switch-by-name → drop), so detect it up front, // record it in the manifest, and tell the user which path they're on. - // `null` means the target column doesn't exist or isn't an EQL domain — - // let the existing checks below produce their specific errors. + // `null` means the target column doesn't exist, isn't an EQL domain, or is + // a legacy `eql_v2_encrypted` column (no longer classified — v3 is the sole + // authored generation). Every one of those falls through to the v2 ladder, + // which is the correct default for the v2 case and lets the existing checks + // below produce their specific errors for the other two. const eqlVersion = await detectColumnEqlVersion( db, options.table, @@ -553,12 +556,19 @@ function buildManifestEntry( // convention only, never relied upon. encryptedColumn, // v2's ladder ends with the rename cut-over; v3 has none — its end - // state is the plaintext column dropped. + // state is the plaintext column dropped. An unclassified column (null, + // which now includes a legacy v2 domain) takes the v2 ladder. targetPhase: eqlVersion === 3 ? 'dropped' : 'cut-over', } if (pkColumn) entry.pkColumn = pkColumn - // Absent means UNKNOWN (detection couldn't see the column), not v2 — - // readers fall back to the domain type in the database. + // Absent means the classifier returned null: the column isn't there, isn't + // an EQL domain, or carries the legacy `eql_v2_encrypted` domain (which + // `classifyEqlDomain` no longer recognises — v3 is the sole authored + // generation). Readers fall back to the live domain type, which yields null + // for those same three cases, so `encrypt status` reports no version rather + // than guessing one. Manifests written before v2 classification was dropped + // still carry `eqlVersion: 2`, so existing v2 columns keep their version; + // only a v2 column backfilled from here on records none. if (eqlVersion) entry.eqlVersion = eqlVersion return entry } diff --git a/packages/cli/src/commands/encrypt/cutover.ts b/packages/cli/src/commands/encrypt/cutover.ts index 6584ef075..676c4e244 100644 --- a/packages/cli/src/commands/encrypt/cutover.ts +++ b/packages/cli/src/commands/encrypt/cutover.ts @@ -78,8 +78,10 @@ export async function cutoverCommand(options: CutoverCommandOptions) { // Fail closed on ambiguity: `info === null` with EQL columns present // means we can't tell WHICH lifecycle applies — running the v2 config // machine against (possibly) v3 columns would only produce a misleading - // downstream error. (No EQL columns at all, or the post-cutover v2 - // same-name state, still falls through to the v2 preconditions below.) + // downstream error. (A table with no EQL v3 columns still falls through to + // the v2 preconditions below — which now also covers the post-cutover v2 + // same-name state, since an `eql_v2_encrypted` column is no longer + // classified and so never appears as a candidate.) const unresolved = explainUnresolved( options.table, options.column, diff --git a/packages/cli/src/commands/encrypt/drop.ts b/packages/cli/src/commands/encrypt/drop.ts index 2fe440bb0..9beb6953a 100644 --- a/packages/cli/src/commands/encrypt/drop.ts +++ b/packages/cli/src/commands/encrypt/drop.ts @@ -84,8 +84,9 @@ export async function dropCommand(options: DropCommandOptions) { // the wrong ciphertext and generate an irreversible drop of the wrong // data. (The post-cutover v2 state — `` itself carries the v2 // domain, counterpart legitimately unresolvable — falls through to the - // v2 path; live truth from the DB wins over the manifest's cached - // version throughout.) + // v2 path: the classifier recognises `eql_v3_*` only, so that column is + // not a candidate and the table reads as having no EQL columns. Live + // truth from the DB wins over the manifest's cached version throughout.) const unresolved = explainUnresolved( options.table, options.column, diff --git a/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts new file mode 100644 index 000000000..1e5bc6cae --- /dev/null +++ b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts @@ -0,0 +1,59 @@ +/** + * Pins {@link explainUnresolved}'s fail-closed contract now that the domain + * classifier recognises `eql_v3_*` only. + * + * `listEncryptedColumns` can no longer emit `version: 2` — a legacy + * `eql_v2_encrypted` column is not classified as an EQL column at all, so it + * never reaches this function as a candidate. The post-cutover v2 state (the + * ciphertext renamed onto the plaintext column's own name) therefore arrives + * here as an EMPTY candidate list, which the first guard already falls through + * on. These tests exist so removing the now-unreachable `version === 2` branch + * is provably behaviour-preserving, and so a future v2 sweep cannot delete the + * empty-list guard the v2 lifecycle actually depends on. + */ + +import type { EncryptedColumnInfo } from '@cipherstash/migrate' +import { describe, expect, it } from 'vitest' +import { explainUnresolved } from '../resolve-eql.js' + +const v3 = ( + column: string, + domain = 'eql_v3_text_eq', +): EncryptedColumnInfo => ({ + column, + domain, + version: 3, +}) + +describe('explainUnresolved', () => { + it('falls through (null) when the table has no EQL columns at all', () => { + // Both the not-yet-backfilled case and the post-cutover v2 same-name case + // land here: the caller's own preconditions produce the accurate error. + expect(explainUnresolved('users', 'email', [])).toBeNull() + }) + + it('fails closed, naming every candidate, when none is identifiable', () => { + const message = explainUnresolved('users', 'email', [ + v3('a_enc'), + v3('b_enc', 'eql_v3_text_search'), + ]) + + expect(message).toContain('Cannot identify which encrypted column') + expect(message).toContain('a_enc (eql_v3_text_eq)') + expect(message).toContain('b_enc (eql_v3_text_search)') + expect(message).toContain('--encrypted-column') + }) + + it('gives no free pass to a candidate sharing the plaintext column name', () => { + // The removed branch exempted a SAME-NAME candidate, but only at + // `version === 2`. A v3 domain on the plaintext column's own name is not + // the post-cutover state (v3 has no cut-over rename), so it must still + // fail closed rather than let a destructive command guess a lifecycle. + const message = explainUnresolved('users', 'email', [ + v3('email'), + v3('email_enc'), + ]) + + expect(message).toContain('Cannot identify which encrypted column') + }) +}) diff --git a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts index 9ce9afb6a..f3cb0afc2 100644 --- a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts +++ b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts @@ -68,16 +68,21 @@ export async function resolveColumnLifecycle( /** * Explain a failed resolution (`info === null`) to the user, or return - * `null` when the failure is fine to fall through to the v2 lifecycle: + * `null` when the failure is fine to fall through to the v2 lifecycle. * - * - No EQL columns at all → the v2 phase/config preconditions produce the - * accurate error ("not backfilled", "no pending config", …). - * - The plaintext column ITSELF carries the v2 domain → the normal - * post-cutover v2 state (`` was renamed onto the ciphertext), where - * "no counterpart" is expected, not a problem. + * The one fall-through case is "no EQL columns at all", which the v2 + * phase/config preconditions turn into an accurate error ("not backfilled", + * "no pending config", …). Since `classifyEqlDomain` recognises `eql_v3_*` + * only, that case now also covers the post-cutover v2 state — `` was + * renamed onto the ciphertext, and its `eql_v2_encrypted` domain is no longer + * classified, so the column never appears as a candidate. (It used to arrive + * here as a `version: 2` candidate and needed its own exemption.) * - * Anything else means EQL columns exist but none is identifiable — the - * caller must fail closed with this message rather than guess a lifecycle. + * A non-empty candidate list therefore means EQL v3 columns exist but none is + * identifiable — the caller must fail closed with this message rather than + * guess a lifecycle, including when one candidate happens to share the + * plaintext column's name (v3 has no cut-over rename, so that is not the + * post-cutover state). */ export function explainUnresolved( table: string, @@ -85,9 +90,6 @@ export function explainUnresolved( candidates: readonly EncryptedColumnInfo[], ): string | null { if (candidates.length === 0) return null - if (candidates.some((c) => c.column === column && c.version === 2)) { - return null - } const listed = candidates .map((c) => ` - ${c.column} (${c.domain})`) .join('\n')