diff --git a/.changeset/migrate-eql-v3.md b/.changeset/migrate-eql-v3.md
new file mode 100644
index 000000000..d1e055117
--- /dev/null
+++ b/.changeset/migrate-eql-v3.md
@@ -0,0 +1,60 @@
+---
+'@cipherstash/migrate': minor
+'stash': minor
+---
+
+EQL v3 support for the encryption rollout lifecycle (#648). The `stash
+encrypt *` commands (and `@cipherstash/migrate` underneath) now resolve a
+column's EQL version and its encrypted counterpart from the **Postgres domain
+types** — the EQL v3 types are self-describing, so the `
_encrypted`
+naming is a convention only, never enforced or relied upon — and follow the
+right lifecycle, no new flags:
+
+- **`encrypt backfill`** works on v3 columns unchanged (the engine was always
+ version-agnostic; pass an `EncryptionV3` client and real v3 envelopes land
+ in the concrete `eql_v3_*` domain column — verified live against a real
+ database, including the domain CHECK and a decrypt round-trip). The
+ manifest records the detected version, the encrypted column's name, and the
+ v3 target phase, and the command prints v3-appropriate next steps.
+- **`encrypt cutover`** on a backfilled v3 column reports "not applicable"
+ (exit 0) with guidance: v3 has no rename cut-over — the application
+ switches to the encrypted column by name. Before backfill completes it
+ exits 1 and says to finish the backfill instead of instructing the switch.
+ On a database with no `eql_v2_configuration` table (a v3-only install) the
+ v2 path now explains that instead of surfacing a raw Postgres error.
+- **`encrypt drop`** is version-aware: v3 runs from the `backfilled` phase,
+ **verifies live coverage** (refuses to generate the migration while any row
+ still has the plaintext set and the encrypted column NULL — the
+ `countUnencrypted` check), and drops the ORIGINAL plaintext column (there
+ is no `_plaintext` under v3); v2 behaviour is unchanged. The generated
+ v3 migration **re-verifies coverage at apply time** — it locks the table,
+ re-counts, and aborts without dropping if plaintext-only rows appeared
+ after generation. And because dropping is the one irreversible step, it
+ requires a positively asserted plaintext↔ciphertext pairing (the
+ manifest's recorded `encryptedColumn` or the naming convention): a match
+ found only by being the table's sole EQL column is refused with
+ instructions, and an ambiguous table (several EQL columns, none
+ identifiable) fails closed listing the candidates — as does `cutover`.
+- **`encrypt status`** classifies each column from the observed domain type
+ (manifest as fallback), shows `v3` in the EQL column, and no longer raises
+ the v2-only `not-registered` / `plaintext-col-missing` drift flags for v3
+ columns. `stash status`'s quest ladder and the `stash init` agent handoff
+ prompt teach the version-appropriate next step (no more "run cutover" on
+ v3 columns).
+- New `@cipherstash/migrate` exports: `classifyEqlDomain`,
+ `resolveEncryptedColumn`, `pickEncryptedColumn`, `listEncryptedColumns`
+ (domain-type resolution — case-exact for quoted/mixed-case table names),
+ `countEncrypted` / `countUnencrypted` (coverage counts), and manifest
+ `eqlVersion` + `encryptedColumn` fields. `EqlVersion` is numeric (`2 | 3`),
+ matching the manifest and the installer. Resolved columns carry `via:
+ 'hint' | 'convention' | 'sole'` so callers can tell a positively asserted
+ pairing from a by-elimination guess.
+- Fixed: `encrypt cutover`/`encrypt drop` precondition failures now actually
+ exit 1 — the early-return guards previously skipped the exit-code path
+ entirely, so failed preconditions exited 0. (This also applies to v2
+ preconditions: scripted pipelines that relied on the erroneous exit 0 will
+ now see the documented exit 1.)
+
+The `stash-cli` and `stash-encryption` skills and the `@cipherstash/migrate`
+README document the two lifecycles (v2: backfill → cutover → drop;
+v3: backfill → switch-by-name → drop).
diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts
index e4f6306b8..515423778 100644
--- a/packages/cli/src/bin/main.ts
+++ b/packages/cli/src/bin/main.ts
@@ -122,7 +122,7 @@ Commands:
encrypt status Show per-column migration status (phase, progress, drift)
encrypt plan Diff intent (.cipherstash/migrations.json) vs observed state
encrypt backfill Resumably encrypt plaintext into the encrypted column
- encrypt cutover Rename swap encrypted → primary column
+ encrypt cutover Rename swap encrypted → primary column (EQL v2 only)
encrypt drop Generate a migration to drop the plaintext column
env (experimental) Print production env vars for deployment
diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts
index 66f527f5a..3c1fb8122 100644
--- a/packages/cli/src/cli/registry.ts
+++ b/packages/cli/src/cli/registry.ts
@@ -498,7 +498,7 @@ export const registry: CommandGroup[] = [
},
{
name: 'encrypt cutover',
- summary: 'Rename swap encrypted → primary column',
+ summary: 'Rename swap encrypted → primary column (EQL v2 only)',
flags: [
TABLE_FLAG,
COLUMN_FLAG,
diff --git a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
new file mode 100644
index 000000000..da2389aff
--- /dev/null
+++ b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
@@ -0,0 +1,445 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The EQL-v3 lifecycle branches (#648/#649): v3 has no cut-over rename —
+// `encrypt cutover` must short-circuit with guidance instead of running the
+// v2 config machine, and `encrypt drop` must target the ORIGINAL plaintext
+// column (there is no `_plaintext`) gated on `backfilled` AND a live
+// coverage check. Version + encrypted-column NAME come from the domain types
+// via `resolveColumnLifecycle` — the `_encrypted` naming is a
+// convention, never relied upon — and both commands FAIL CLOSED when
+// resolution is ambiguous instead of guessing a lifecycle. The v2 paths are
+// pinned alongside as regression guards.
+
+const queryMock = vi.hoisted(() =>
+ vi.fn(async (_sql: string) => ({ rows: [] as unknown[] })),
+)
+vi.mock('pg', () => ({
+ default: {
+ Client: class {
+ connect = vi.fn(async () => {})
+ end = vi.fn(async () => {})
+ query = queryMock
+ },
+ },
+}))
+
+type ColumnInfo = { column: string; domain: string; version: 2 | 3 }
+type ResolvedInfo = ColumnInfo & { via: 'hint' | 'convention' | 'sole' }
+type Lifecycle = { info: ResolvedInfo | null; candidates: ColumnInfo[] }
+
+const migrateMocks = vi.hoisted(() => ({
+ listEncryptedColumns: vi.fn(async (): Promise => []),
+ pickEncryptedColumn: vi.fn(() => null),
+ readManifest: vi.fn(async () => null),
+ countUnencrypted: vi.fn(async () => 0),
+ progress: vi.fn(
+ async () => ({ phase: 'backfilled' }) as { phase: string } | null,
+ ),
+ appendEvent: vi.fn(async () => {}),
+ setManifestTargetPhase: vi.fn(async () => {}),
+ renameEncryptedColumns: vi.fn(async () => {}),
+ migrateConfig: vi.fn(async () => {}),
+ activateConfig: vi.fn(async () => {}),
+ reloadConfig: vi.fn(async () => {}),
+}))
+vi.mock('@cipherstash/migrate', () => migrateMocks)
+
+// Mock the lifecycle RESOLUTION (each test states its scenario directly)
+// but keep the real `explainUnresolved` — the fail-closed messaging is part
+// of what these tests pin.
+const lifecycleMock = vi.hoisted(() =>
+ vi.fn(
+ async (): Promise<{
+ info: (ColumnInfo & { via: string }) | null
+ candidates: ColumnInfo[]
+ }> => ({ info: null, candidates: [] }),
+ ),
+)
+vi.mock('../lib/resolve-eql.js', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, resolveColumnLifecycle: lifecycleMock }
+})
+
+vi.mock('@clack/prompts', () => ({
+ intro: vi.fn(),
+ outro: vi.fn(),
+ confirm: vi.fn(async () => true),
+ isCancel: vi.fn(() => false),
+ log: {
+ info: vi.fn(),
+ success: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ step: vi.fn(),
+ },
+ note: vi.fn(),
+}))
+vi.mock('@/config/index.js', () => ({
+ loadStashConfig: vi.fn(async () => ({ databaseUrl: 'postgres://test' })),
+}))
+vi.mock('@/commands/db/detect.js', () => ({
+ detectDrizzle: vi.fn(() => false),
+}))
+vi.mock('@/commands/init/utils.js', () => ({
+ detectPackageManager: vi.fn(() => 'npm'),
+ runnerCommand: vi.fn((_pm: string, ref: string) => `npx ${ref}`),
+}))
+const scaffoldMock = vi.hoisted(() =>
+ vi.fn(async ({ name }: { name: string }) => ({
+ path: `drizzle/${name}.sql`,
+ })),
+)
+vi.mock('../drizzle-helper.js', () => ({
+ scaffoldDrizzleMigration: scaffoldMock,
+}))
+const writeFileMock = vi.hoisted(() => vi.fn())
+vi.mock('node:fs', () => ({
+ default: { mkdirSync: vi.fn(), writeFileSync: writeFileMock },
+}))
+
+import * as p from '@clack/prompts'
+import { cutoverCommand } from '../cutover.js'
+import { dropCommand } from '../drop.js'
+
+const V3_INFO: ColumnInfo = {
+ column: 'email_encrypted',
+ domain: 'eql_v3_text_search',
+ version: 3,
+}
+const V3_CUSTOM_INFO: ColumnInfo = {
+ column: 'email_enc',
+ domain: 'eql_v3_text_search',
+ version: 3,
+}
+const V2_INFO: ColumnInfo = {
+ column: 'email_encrypted',
+ domain: 'eql_v2_encrypted',
+ version: 2,
+}
+
+function resolved(
+ info: ColumnInfo,
+ via: ResolvedInfo['via'] = 'convention',
+): Lifecycle {
+ return { info: { ...info, via }, candidates: [info] }
+}
+
+/** v2 config machine present + a pending config row. */
+function mockV2ConfigQueries() {
+ queryMock.mockImplementation(async (sql: string) => {
+ if (typeof sql !== 'string') return { rows: [] }
+ if (sql.includes('to_regclass')) {
+ return { rows: [{ exists: 'eql_v2_configuration' }] }
+ }
+ if (sql.includes('eql_v2_configuration')) {
+ return { rows: [{ exists: true }] }
+ }
+ return { rows: [] }
+ })
+}
+
+function spyExit() {
+ return vi
+ .spyOn(process, 'exit')
+ .mockImplementation((() => undefined) as never)
+}
+
+describe('encrypt cutover — EQL version awareness', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ queryMock.mockResolvedValue({ rows: [] })
+ migrateMocks.progress.mockResolvedValue({ phase: 'backfilled' })
+ lifecycleMock.mockResolvedValue(resolved(V3_INFO))
+ })
+
+ it('short-circuits on a backfilled v3 column: guidance, no rename, no config machine, exit 0', async () => {
+ const exitSpy = spyExit()
+
+ await cutoverCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.info).toHaveBeenCalledWith(
+ expect.stringContaining('not applicable to EQL v3'),
+ )
+ expect(p.log.info).toHaveBeenCalledWith(
+ expect.stringContaining('stash encrypt drop'),
+ )
+ expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
+ expect(migrateMocks.migrateConfig).not.toHaveBeenCalled()
+ expect(migrateMocks.activateConfig).not.toHaveBeenCalled()
+ expect(migrateMocks.appendEvent).not.toHaveBeenCalled()
+ expect(exitSpy).not.toHaveBeenCalled()
+ exitSpy.mockRestore()
+ })
+
+ it('v3 mid-backfill: does NOT tell the user to switch; exits 1', async () => {
+ migrateMocks.progress.mockResolvedValue({ phase: 'dual-writing' })
+ const exitSpy = spyExit()
+
+ await cutoverCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining("hasn't finished backfilling"),
+ )
+ // The switch-now guidance must not appear before backfill completes —
+ // following it mid-backfill reads NULLs for unbackfilled rows.
+ expect(p.log.info).not.toHaveBeenCalledWith(
+ expect.stringContaining('point your application'),
+ )
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
+ })
+
+ it('v3 already dropped: terminal phase is "nothing to do", not "finish the backfill"; exit 0', async () => {
+ migrateMocks.progress.mockResolvedValue({ phase: 'dropped' })
+ const exitSpy = spyExit()
+
+ await cutoverCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.info).toHaveBeenCalledWith(
+ expect.stringContaining('already completed'),
+ )
+ expect(p.log.error).not.toHaveBeenCalled()
+ expect(exitSpy).not.toHaveBeenCalled()
+ exitSpy.mockRestore()
+ })
+
+ it('uses the RESOLVED encrypted column name, not the naming convention', async () => {
+ lifecycleMock.mockResolvedValue(resolved(V3_CUSTOM_INFO, 'hint'))
+
+ await cutoverCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.info).toHaveBeenCalledWith(
+ expect.stringContaining('email_enc'),
+ )
+ expect(p.log.info).not.toHaveBeenCalledWith(
+ expect.stringContaining('email_encrypted'),
+ )
+ })
+
+ it('fails closed when EQL columns exist but none is identifiable', async () => {
+ lifecycleMock.mockResolvedValue({
+ info: null,
+ candidates: [
+ { column: 'a_enc', domain: 'eql_v3_text_eq', version: 3 },
+ { column: 'b_enc', domain: 'eql_v3_text_eq', version: 3 },
+ ],
+ })
+ const exitSpy = spyExit()
+
+ await cutoverCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('Cannot identify which encrypted column'),
+ )
+ expect(p.log.error).toHaveBeenCalledWith(expect.stringContaining('a_enc'))
+ expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
+ })
+
+ it('still runs the v2 flow for a v2 column (regression pin)', async () => {
+ lifecycleMock.mockResolvedValue(resolved(V2_INFO))
+ mockV2ConfigQueries()
+
+ await cutoverCommand({ table: 'users', column: 'email' })
+
+ expect(migrateMocks.renameEncryptedColumns).toHaveBeenCalled()
+ expect(migrateMocks.migrateConfig).toHaveBeenCalled()
+ expect(migrateMocks.activateConfig).toHaveBeenCalled()
+ })
+
+ it('explains a v3-only database instead of a raw relation-does-not-exist error', async () => {
+ // Detection missed (e.g. no EQL columns visible) → v2 path — but the
+ // config table doesn't exist on this database.
+ lifecycleMock.mockResolvedValue({ info: null, candidates: [] })
+ queryMock.mockImplementation(async (sql: string) =>
+ typeof sql === 'string' && sql.includes('to_regclass')
+ ? { rows: [{ exists: null }] }
+ : { rows: [] },
+ )
+ const exitSpy = spyExit()
+
+ await cutoverCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('no EQL v2 configuration table'),
+ )
+ expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
+ })
+})
+
+describe('encrypt drop — EQL version awareness', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ queryMock.mockResolvedValue({ rows: [] })
+ lifecycleMock.mockResolvedValue(resolved(V3_INFO))
+ migrateMocks.countUnencrypted.mockResolvedValue(0)
+ })
+
+ it('v3: requires phase backfilled, verifies coverage, and drops the ORIGINAL column', async () => {
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ // The coverage gate ran against the RESOLVED encrypted column.
+ expect(migrateMocks.countUnencrypted).toHaveBeenCalledWith(
+ expect.anything(),
+ 'users',
+ 'email',
+ 'email_encrypted',
+ )
+ // Non-drizzle fallback writes the SQL file — inspect the generated DDL.
+ const sql = writeFileMock.mock.calls[0]?.[1] as string
+ expect(sql).toContain('DROP COLUMN "email"')
+ expect(sql).not.toContain('email_plaintext')
+ expect(migrateMocks.appendEvent).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ event: 'dropped', phase: 'dropped' }),
+ )
+ })
+
+ it('v3: the generated migration re-verifies coverage at APPLY time, atomically', async () => {
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ // The CLI-side count goes stale the moment the file is written; the
+ // migration must lock, re-count, and abort without dropping if any
+ // plaintext-only row appeared between generation and application.
+ const sql = writeFileMock.mock.calls[0]?.[1] as string
+ expect(sql).toContain('LOCK TABLE "users" IN ACCESS EXCLUSIVE MODE')
+ expect(sql).toContain('RAISE EXCEPTION')
+ expect(sql).toContain('"email" IS NOT NULL')
+ expect(sql).toContain('"email_encrypted" IS NULL')
+ // The DROP itself runs inside the same DO block (EXECUTE), so
+ // check-and-drop stay atomic even under non-transactional runners.
+ expect(sql).toMatch(/EXECUTE 'ALTER TABLE "users" DROP COLUMN "email"'/)
+ })
+
+ it('v3: refuses to generate when rows are still plaintext-only', async () => {
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
+ migrateMocks.countUnencrypted.mockResolvedValueOnce(7)
+ const exitSpy = spyExit()
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('7 row(s)'),
+ )
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('stash encrypt backfill'),
+ )
+ expect(writeFileMock).not.toHaveBeenCalled()
+ expect(migrateMocks.appendEvent).not.toHaveBeenCalled()
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
+ })
+
+ it('v3: gates coverage on the RESOLVED encrypted column name', async () => {
+ lifecycleMock.mockResolvedValue(resolved(V3_CUSTOM_INFO, 'hint'))
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ expect(migrateMocks.countUnencrypted).toHaveBeenCalledWith(
+ expect.anything(),
+ 'users',
+ 'email',
+ 'email_enc',
+ )
+ const sql = writeFileMock.mock.calls[0]?.[1] as string
+ expect(sql).toContain('DROP COLUMN "email"')
+ })
+
+ it("v3: refuses a by-elimination ('sole') match — uniqueness cannot prove the pairing", async () => {
+ // The table's ONE EQL column may encrypt a DIFFERENT field; gating
+ // coverage on it and dropping `email` could destroy the only copy.
+ lifecycleMock.mockResolvedValue(
+ resolved(
+ { column: 'secret_blob', domain: 'eql_v3_text_search', version: 3 },
+ 'sole',
+ ),
+ )
+ // No progress stub: the sole-match guard fires before the phase gate
+ // ever consults cs_migrations. (Queuing an unconsumed
+ // mockResolvedValueOnce here would leak into the next test.)
+ const exitSpy = spyExit()
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('nothing confirms it encrypts "email"'),
+ )
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('--encrypted-column secret_blob'),
+ )
+ expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
+ expect(writeFileMock).not.toHaveBeenCalled()
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
+ })
+
+ it('fails closed when EQL columns exist but none is identifiable', async () => {
+ lifecycleMock.mockResolvedValue({
+ info: null,
+ candidates: [
+ { column: 'a_enc', domain: 'eql_v3_text_eq', version: 3 },
+ { column: 'b_enc', domain: 'eql_v3_text_eq', version: 3 },
+ ],
+ })
+ const exitSpy = spyExit()
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('Cannot identify which encrypted column'),
+ )
+ expect(writeFileMock).not.toHaveBeenCalled()
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
+ })
+
+ it('v3: rejects when not yet backfilled', async () => {
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilling' })
+ const exitSpy = spyExit()
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining("Must be 'backfilled'"),
+ )
+ expect(writeFileMock).not.toHaveBeenCalled()
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
+ })
+
+ it('v2: unchanged — requires cut-over, no coverage gate, drops _plaintext (regression pin)', async () => {
+ lifecycleMock.mockResolvedValue(resolved(V2_INFO))
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' })
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ const sql = writeFileMock.mock.calls[0]?.[1] as string
+ expect(sql).toContain('DROP COLUMN "email_plaintext"')
+ expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
+ })
+
+ it('v2 post-cutover: `email` itself carrying the v2 domain is NOT ambiguity — proceeds down the v2 path', async () => {
+ // 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 }],
+ })
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' })
+
+ await dropCommand({ table: 'users', column: 'email' })
+
+ const sql = writeFileMock.mock.calls[0]?.[1] as string
+ expect(sql).toContain('DROP COLUMN "email_plaintext"')
+ expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts
index 84b2ceab8..7f7edb006 100644
--- a/packages/cli/src/commands/encrypt/backfill.ts
+++ b/packages/cli/src/commands/encrypt/backfill.ts
@@ -1,5 +1,7 @@
import {
appendEvent,
+ detectColumnEqlVersion,
+ type EqlVersion,
type ManifestColumn,
progress,
runBackfill,
@@ -136,6 +138,22 @@ export async function backfillCommand(options: BackfillCommandOptions) {
const encryptedColumn =
options.encryptedColumn ?? `${options.column}_encrypted`
+ // 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.
+ const eqlVersion = await detectColumnEqlVersion(
+ db,
+ options.table,
+ encryptedColumn,
+ )
+ if (eqlVersion) {
+ p.log.info(
+ `${options.table}.${encryptedColumn} is EQL v${eqlVersion}${eqlVersion === 3 ? ' — lifecycle is backfill → switch the app to the encrypted column by name → drop (no cut-over rename).' : ''}`,
+ )
+ }
+
// Phase guard: backfill requires the application to already be writing
// to both columns, otherwise rows inserted *during* the backfill land
// in plaintext only and create silent migration drift. Records the
@@ -185,7 +203,9 @@ export async function backfillCommand(options: BackfillCommandOptions) {
column,
schemaColumnKey,
plaintextColumn,
+ encryptedColumn,
options.pkColumn,
+ eqlVersion,
)
await upsertManifestColumn(options.table, manifestEntry)
p.log.success(
@@ -254,6 +274,12 @@ export async function backfillCommand(options: BackfillCommandOptions) {
return
}
+ if (eqlVersion === 3) {
+ p.note(
+ `EQL v3 has no cut-over. Next:\n 1. Point your application at ${encryptedColumn} (schema + queries), deploy, verify reads.\n 2. Generate the plaintext drop: stash encrypt drop --table ${options.table} --column ${plaintextColumn}`,
+ 'Next steps (EQL v3)',
+ )
+ }
p.outro(
`Backfill complete. ${result.rowsProcessed.toLocaleString()} rows encrypted.`,
)
@@ -498,7 +524,9 @@ function buildManifestEntry(
column: ColumnSchema | undefined,
schemaColumnKey: string,
plaintextColumn: string,
+ encryptedColumn: string,
pkColumn: string | undefined,
+ eqlVersion: EqlVersion | null,
): ManifestColumn {
// SDK `cast_as` ('string', 'number', …) and EQL `castAs` ('text',
// 'double', …) are different vocabularies; translate via the same
@@ -516,13 +544,23 @@ function buildManifestEntry(
(kind) => indexConfig[kind] !== undefined,
)
- return {
+ const entry: ManifestColumn = {
column: plaintextColumn,
castAs,
indexes,
- targetPhase: 'cut-over',
- ...(pkColumn ? { pkColumn } : {}),
+ // Recorded so later commands (cutover/drop/status) don't have to guess
+ // the name from the `_encrypted` convention — the name is a
+ // 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.
+ 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.
+ if (eqlVersion) entry.eqlVersion = eqlVersion
+ return entry
}
// Drop the wrapping default so unknown values fail validation instead of
diff --git a/packages/cli/src/commands/encrypt/cutover.ts b/packages/cli/src/commands/encrypt/cutover.ts
index 3d8d4c816..6584ef075 100644
--- a/packages/cli/src/commands/encrypt/cutover.ts
+++ b/packages/cli/src/commands/encrypt/cutover.ts
@@ -12,6 +12,7 @@ import { detectDrizzle } from '@/commands/db/detect.js'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { scaffoldDrizzleMigration } from './drizzle-helper.js'
+import { explainUnresolved, resolveColumnLifecycle } from './lib/resolve-eql.js'
/**
* Options accepted by `stash encrypt cutover`. Swaps the plaintext and
@@ -62,7 +63,63 @@ export async function cutoverCommand(options: CutoverCommandOptions) {
try {
await client.connect()
+ // Cut-over is an EQL v2 concept: v2 hides the swap behind
+ // `eql_v2.rename_encrypted_columns()` + a Proxy config promotion. A v3
+ // column has neither — the application switches to the encrypted column
+ // BY NAME, and the plaintext column is dropped later. Resolve from the
+ // DOMAIN TYPES (manifest name as a hint; the `_encrypted` naming is
+ // a convention, never relied upon) before any phase/config checks so v3
+ // users get the real answer, not a confusing precondition error.
+ const { info, candidates } = await resolveColumnLifecycle(
+ client,
+ options.table,
+ options.column,
+ )
+ // 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.)
+ const unresolved = explainUnresolved(
+ options.table,
+ options.column,
+ candidates,
+ )
+ if (!info && unresolved) {
+ p.log.error(unresolved)
+ exitCode = 1
+ return
+ }
const state = await progress(client, options.table, options.column)
+
+ if (info?.version === 3) {
+ const encryptedColumn = info.column
+ if (state?.phase === 'dropped') {
+ // Terminal phase — the lifecycle already finished. Not an error and
+ // not "finish the backfill": there is nothing left to backfill.
+ p.log.info(
+ `${options.table}.${options.column} has already completed the EQL v3 lifecycle (plaintext dropped). Nothing to cut over.`,
+ )
+ p.outro('Nothing to do for EQL v3.')
+ return
+ }
+ if (state?.phase !== 'backfilled') {
+ // Not a "nothing to do" — the user isn't ready for ANY next step
+ // yet. Exit 1 so scripted pipelines gating on cutover don't read
+ // an incomplete backfill as success.
+ p.log.error(
+ `Cut-over is not applicable to EQL v3 columns, and ${options.table}.${options.column} hasn't finished backfilling (phase '${state?.phase ?? '—'}'). Finish the backfill first:\n stash encrypt backfill --table ${options.table} --column ${options.column}`,
+ )
+ exitCode = 1
+ return
+ }
+ p.log.info(
+ `Cut-over is not applicable to EQL v3 columns. ${options.table}.${encryptedColumn} is EQL v3 (${info.domain}): there is no rename step — point your application at ${encryptedColumn} (update your schema/queries), verify reads, then generate the plaintext drop with:\n stash encrypt drop --table ${options.table} --column ${options.column}`,
+ )
+ p.outro('Nothing to do for EQL v3.')
+ return
+ }
+
if (state?.phase !== 'backfilled') {
p.log.error(
`Cannot cut over: ${options.table}.${options.column} is in phase '${state?.phase ?? '—'}'. Must be 'backfilled'.`,
@@ -71,6 +128,21 @@ export async function cutoverCommand(options: CutoverCommandOptions) {
return
}
+ // Guard the v2 config machinery's existence before querying it: on a
+ // v3-only database (v3 is the default install) there is no
+ // `eql_v2_configuration` relation, and an unguarded query surfaces as a
+ // raw "relation does not exist" error instead of an explanation.
+ const configTable = await client.query<{ exists: string | null }>(
+ "SELECT to_regclass('public.eql_v2_configuration')::text AS exists",
+ )
+ if (configTable.rows[0]?.exists == null) {
+ p.log.error(
+ `This database has no EQL v2 configuration table — it looks like an EQL v3-only install. Cut-over only applies to EQL v2 columns; for v3, point your application at the encrypted column by name, then generate the plaintext drop with:\n stash encrypt drop --table ${options.table} --column ${options.column}`,
+ )
+ exitCode = 1
+ return
+ }
+
// Verify a pending EQL config exists. cutover assumes the user has
// already run `stash db push` against a schema that switches the
// column from `_encrypted` (or whatever twin name) to `` —
@@ -183,8 +255,13 @@ export async function cutoverCommand(options: CutoverCommandOptions) {
exitCode = 1
} finally {
await client.end()
+ // In `finally` (not after the try/catch) deliberately: the precondition
+ // guards above `return` from inside `try`, which skips any code placed
+ // after the block — so a trailing `if (exitCode) process.exit(...)`
+ // was unreachable on exactly the failure paths it existed for, and
+ // guard failures exited 0.
+ if (exitCode) process.exit(exitCode)
}
- if (exitCode) process.exit(exitCode)
}
/**
diff --git a/packages/cli/src/commands/encrypt/drop.ts b/packages/cli/src/commands/encrypt/drop.ts
index 8f75034e8..2fe440bb0 100644
--- a/packages/cli/src/commands/encrypt/drop.ts
+++ b/packages/cli/src/commands/encrypt/drop.ts
@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import {
appendEvent,
+ countUnencrypted,
progress,
setManifestTargetPhase,
} from '@cipherstash/migrate'
@@ -11,19 +12,24 @@ import { detectDrizzle } from '@/commands/db/detect.js'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { scaffoldDrizzleMigration } from './drizzle-helper.js'
+import { explainUnresolved, resolveColumnLifecycle } from './lib/resolve-eql.js'
/**
- * Options accepted by `stash encrypt drop`. Generates a migration file
- * that drops the now-unused plaintext column (renamed to `_plaintext`
- * by cutover). Does *not* apply the migration — the user runs their usual
- * migration tool (drizzle-kit, prisma, psql) to actually execute it.
+ * Options accepted by `stash encrypt drop`. Generates a migration file that
+ * drops the now-unused plaintext column — for EQL v2 that is
+ * `_plaintext` (the name cutover's rename left it with); for EQL v3
+ * (no rename) it is the original `` itself. Does *not* apply the
+ * migration — the user runs their usual migration tool (drizzle-kit,
+ * prisma, psql) to actually execute it.
*/
export interface DropCommandOptions {
/** Physical table name, e.g. `users`. */
table: string
/**
- * Physical column — the original plaintext name. The generated migration
- * drops `_plaintext` (the name the column has *after* cutover).
+ * Physical column — the original plaintext name. What gets dropped
+ * depends on the column's EQL version: v2 drops `_plaintext`
+ * (post-cutover leftover); v3 drops `` itself, gated on a live
+ * ciphertext-coverage check.
*/
column: string
/**
@@ -35,8 +41,11 @@ export interface DropCommandOptions {
}
/**
- * CLI handler for `stash encrypt drop`. Requires the column to be in
- * phase `cut-over`; otherwise errors out.
+ * CLI handler for `stash encrypt drop`. Version-aware preconditions:
+ * EQL v2 requires phase `cut-over`; EQL v3 (which has no cut-over) requires
+ * `backfilled` plus a live coverage check — and the generated v3 migration
+ * re-verifies coverage at APPLY time, since rows can be written between
+ * generation and application.
*
* For Drizzle projects, scaffolds the migration via `drizzle-kit generate
* --custom` so the file lands with the correct sequential prefix and a
@@ -58,21 +67,99 @@ export async function dropCommand(options: DropCommandOptions) {
try {
await client.connect()
+ // The plaintext column's name depends on the EQL version's lifecycle:
+ // v2's cut-over renames `` → `_plaintext` (so that's what we
+ // drop, after `cut-over`); v3 has no rename — the app switched to the
+ // encrypted column by name, so the original `` IS the plaintext
+ // column, droppable straight after `backfilled`. The version and the
+ // encrypted column's name are resolved from the DOMAIN TYPES (manifest
+ // name as a hint) — the `_encrypted` naming is a convention only.
+ const { info, candidates } = await resolveColumnLifecycle(
+ client,
+ options.table,
+ options.column,
+ )
+ // Fail closed on ambiguity: with EQL columns present but no identifiable
+ // counterpart, guessing a lifecycle here could validate coverage against
+ // 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.)
+ const unresolved = explainUnresolved(
+ options.table,
+ options.column,
+ candidates,
+ )
+ if (!info && unresolved) {
+ p.log.error(unresolved)
+ exitCode = 1
+ return
+ }
+ // A `via: 'sole'` match only proves the column is the table's ONE EQL
+ // column — it may encrypt a DIFFERENT field, in which case the coverage
+ // gate below would count the wrong ciphertext and wave through a drop
+ // that destroys the only copy of this data. Dropping is the single
+ // irreversible step in the lifecycle, so it demands a positively
+ // asserted pairing (manifest hint or the naming convention).
+ if (info?.via === 'sole') {
+ p.log.error(
+ `${options.table}.${info.column} (${info.domain}) is the table's only encrypted column, but nothing confirms it encrypts "${options.column}" — refusing to generate an irreversible drop on that guess. Record the pairing and retry: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column ${info.column}\` (which writes it to the manifest), or set "encryptedColumn": "${info.column}" for this column in .cipherstash/migrations.json.`,
+ )
+ exitCode = 1
+ return
+ }
+ const isV3 = info?.version === 3
+ const encryptedColumn = info?.column ?? `${options.column}_encrypted`
+ const requiredPhase = isV3 ? 'backfilled' : 'cut-over'
+ const plaintextToDrop = isV3
+ ? options.column
+ : `${options.column}_plaintext`
+
const state = await progress(client, options.table, options.column)
- if (state?.phase !== 'cut-over') {
+ if (state?.phase !== requiredPhase) {
p.log.error(
- `Cannot generate drop migration: ${options.table}.${options.column} is in phase '${state?.phase ?? '—'}'. Must be 'cut-over'.`,
+ `Cannot generate drop migration: ${options.table}.${options.column} is in phase '${state?.phase ?? '—'}'. Must be '${requiredPhase}'${isV3 ? ' (EQL v3 has no cut-over — backfill, switch the app to the encrypted column, then drop)' : ''}.`,
)
exitCode = 1
return
}
+ if (isV3) {
+ // The phase gate above proves a backfill FINISHED at some point; it
+ // says nothing about rows written since (a bulk import or a service
+ // that isn't dual-writing leaves plaintext-only rows). Dropping the
+ // original column is the one irreversible step in the v3 ladder, so
+ // verify live coverage before generating the migration.
+ const unencrypted = await countUnencrypted(
+ client,
+ options.table,
+ options.column,
+ encryptedColumn,
+ )
+ if (unencrypted > 0) {
+ p.log.error(
+ `Refusing to generate the drop migration: ${unencrypted} row(s) in ${options.table} have "${options.column}" set but "${encryptedColumn}" NULL — dropping "${options.column}" would permanently destroy that data. Likely rows written without dual-writes since the backfill. Re-run:\n stash encrypt backfill --table ${options.table} --column ${options.column}\nthen generate the drop again.`,
+ )
+ exitCode = 1
+ return
+ }
+ p.log.success(
+ `Verified: no rows with "${options.column}" set and "${encryptedColumn}" NULL.`,
+ )
+ p.log.info(
+ `${options.table}.${encryptedColumn} is EQL v3 (${info?.domain}) — the drop targets the original plaintext column "${options.column}" (v3 has no rename, so there is no "${options.column}_plaintext"). Make sure your application reads/writes ${encryptedColumn} before applying this migration.`,
+ )
+ }
+
const dot = options.table.indexOf('.')
const qualifiedTable =
dot >= 0
? `"${options.table.slice(0, dot)}"."${options.table.slice(dot + 1)}"`
: `"${options.table}"`
- const dropSql = `-- Generated by stash encrypt drop\n-- Drops the plaintext column now that ${options.table}.${options.column} is encrypted.\n\nALTER TABLE ${qualifiedTable} DROP COLUMN "${options.column}_plaintext";\n`
+ const dropSql = isV3
+ ? buildV3DropSql(qualifiedTable, options.column, encryptedColumn)
+ : `-- Generated by stash encrypt drop\n-- Drops the plaintext column now that ${options.table}.${options.column} is encrypted.\n\nALTER TABLE ${qualifiedTable} DROP COLUMN "${plaintextToDrop}";\n`
const cwd = process.cwd()
const migrationsDir = options.migrationsDir ?? 'drizzle'
@@ -85,7 +172,7 @@ export async function dropCommand(options: DropCommandOptions) {
// Without this, the file ships but `drizzle-kit migrate` never picks it
// up because the journal doesn't reference it.
const result = await scaffoldDrizzleMigration({
- name: `drop_${options.table}_${options.column}_plaintext`,
+ name: `drop_${options.table}_${plaintextToDrop}`,
outDir: migrationsDir,
sql: dropSql,
})
@@ -100,7 +187,7 @@ export async function dropCommand(options: DropCommandOptions) {
.toISOString()
.replace(/[-:.TZ]/g, '')
.slice(0, 14)
- const fileName = `${ts}_drop_${options.table}_${options.column}_plaintext.sql`
+ const fileName = `${ts}_drop_${options.table}_${plaintextToDrop}.sql`
filePath = path.join(dirAbs, fileName)
fs.writeFileSync(filePath, dropSql, 'utf-8')
nextStep = `Review the migration, then apply with your migration tool:\n - prisma migrate deploy\n - psql -f ${fileName}`
@@ -129,6 +216,61 @@ export async function dropCommand(options: DropCommandOptions) {
exitCode = 1
} finally {
await client.end()
+ // In `finally` (not after the try/catch) deliberately: the precondition
+ // guards above `return` from inside `try`, which skips any code placed
+ // after the block — so a trailing `if (exitCode) process.exit(...)`
+ // was unreachable on exactly the failure paths it existed for, and
+ // guard failures exited 0.
+ if (exitCode) process.exit(exitCode)
}
- if (exitCode) process.exit(exitCode)
+}
+
+/**
+ * Build the v3 drop migration. The CLI's coverage check above goes stale
+ * the moment the file is written — rows can be inserted plaintext-only
+ * between generation and application (a bulk import, a service that isn't
+ * dual-writing). So the migration re-verifies coverage at APPLY time,
+ * atomically: it takes the same ACCESS EXCLUSIVE lock the DROP COLUMN
+ * needs (blocking concurrent writes for the remainder of the transaction),
+ * re-counts, and aborts the whole migration — dropping nothing — if any
+ * plaintext-only row appeared. The DROP runs via EXECUTE inside the same
+ * DO block so check-and-drop stay atomic even under migration runners that
+ * don't wrap files in a transaction (plain `psql -f`).
+ *
+ * Identifiers arrive pre-validated (they resolved against the live catalog
+ * above); embedded string literals escape single quotes defensively.
+ */
+function buildV3DropSql(
+ qualifiedTable: string,
+ plaintextColumn: string,
+ encryptedColumn: string,
+): string {
+ const lit = (s: string) => s.replace(/'/g, "''")
+ return `-- Generated by stash encrypt drop
+-- Drops the plaintext column now that ${qualifiedTable}."${encryptedColumn}" is encrypted.
+-- Coverage is re-verified here, at apply time: the check stash ran at
+-- generation time cannot see rows written after it.
+
+DO $stash_drop$
+DECLARE
+ unencrypted bigint;
+BEGIN
+ -- The lock DROP COLUMN takes anyway, acquired up front so the
+ -- count-then-drop below is atomic against concurrent writes.
+ LOCK TABLE ${qualifiedTable} IN ACCESS EXCLUSIVE MODE;
+
+ SELECT count(*) INTO unencrypted
+ FROM ${qualifiedTable}
+ WHERE "${plaintextColumn}" IS NOT NULL
+ AND "${encryptedColumn}" IS NULL;
+
+ IF unencrypted > 0 THEN
+ RAISE EXCEPTION 'stash encrypt drop: refusing to drop %.% — % row(s) have plaintext set but % NULL. Dropping now would permanently destroy that data. Re-run: stash encrypt backfill, then regenerate this migration.',
+ '${lit(qualifiedTable)}', '${lit(plaintextColumn)}', unencrypted, '${lit(encryptedColumn)}';
+ END IF;
+
+ EXECUTE 'ALTER TABLE ${lit(qualifiedTable)} DROP COLUMN "${lit(plaintextColumn)}"';
+END
+$stash_drop$;
+`
}
diff --git a/packages/cli/src/commands/encrypt/lib/db-readers.ts b/packages/cli/src/commands/encrypt/lib/db-readers.ts
index cd64365db..f0e214ec1 100644
--- a/packages/cli/src/commands/encrypt/lib/db-readers.ts
+++ b/packages/cli/src/commands/encrypt/lib/db-readers.ts
@@ -82,7 +82,12 @@ export async function fetchActiveEqlConfig(
}
/**
- * Read `information_schema.columns` and group column names by table.
+ * Read `information_schema.columns` and group columns by table, mapping each
+ * column name to its DOMAIN type (or `null` for plain types). The domain is
+ * what makes EQL columns self-describing (`eql_v2_encrypted` / `eql_v3_*`),
+ * so callers can classify encryption state from the types themselves rather
+ * than relying on the `_encrypted` naming convention.
+ *
* When `tables` is provided the query is constrained to that set —
* status's quest log only ever needs ~5 specific tables, so passing
* the manifest's tables avoids a full-schema scan.
@@ -90,25 +95,32 @@ export async function fetchActiveEqlConfig(
export async function fetchPhysicalColumns(
client: pg.ClientBase,
tables?: ReadonlyArray,
-): Promise