Skip to content

Commit 605c40a

Browse files
committed
fix(cli,migrate): harden EQL column resolution per #649 review
Review follow-ups from Toby, James, and CodeRabbit: - Resolution provenance: pickEncryptedColumn (new pure core of resolveEncryptedColumn) tags every match `via: hint | convention | sole`. A sole-EQL-column match only proves uniqueness, not the plaintext<->ciphertext pairing, so `encrypt drop` — the one irreversible step — now refuses it with instructions instead of gating coverage on a possibly unrelated column. Display paths keep using it, so convention-free resolution still works everywhere else. - Fail closed on ambiguity: cutover and drop error listing the candidate EQL columns when none is identifiable, instead of falling through to the v2 machinery; the post-cutover v2 same-name state is recognised and still falls through to the v2 preconditions. - The generated v3 drop migration re-verifies coverage at APPLY time: LOCK TABLE, re-count plaintext-only rows, RAISE EXCEPTION without dropping if any appeared since generation; the DROP runs via EXECUTE in the same DO block so check-and-drop stay atomic under non-transactional runners. - resolveColumnLifecycle no longer swallows manifest read failures (ENOENT stays null via readManifest; malformed JSON/schema/permission errors propagate) and fetches the catalog once instead of up to three times. - cutover on an already-dropped v3 column says "lifecycle complete" (exit 0) instead of "finish the backfill". - stash status derives eqlVersion from the encrypted column's domain type (manifest encryptedColumn hint over the naming convention), falling back to the manifest only when the DB isn't visible — matching encrypt status. - Stale drop.ts JSDoc rewritten version-aware; migrate README fence language (MD040); stash-cli skill blockquote joined (MD028). Test-cast note: the `as unknown as ClientBase` factories in migrate tests stay (ClientBase's overloaded query signature can't be met by a structural fixture) but most resolution tests now target the pure pickEncryptedColumn and need no client at all. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 8153419 commit 605c40a

13 files changed

Lines changed: 513 additions & 132 deletions

File tree

.changeset/migrate-eql-v3.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,29 @@ right lifecycle, no new flags:
2626
**verifies live coverage** (refuses to generate the migration while any row
2727
still has the plaintext set and the encrypted column NULL — the
2828
`countUnencrypted` check), and drops the ORIGINAL plaintext column (there
29-
is no `<col>_plaintext` under v3); v2 behaviour is unchanged.
29+
is no `<col>_plaintext` under v3); v2 behaviour is unchanged. The generated
30+
v3 migration **re-verifies coverage at apply time** — it locks the table,
31+
re-counts, and aborts without dropping if plaintext-only rows appeared
32+
after generation. And because dropping is the one irreversible step, it
33+
requires a positively asserted plaintext↔ciphertext pairing (the
34+
manifest's recorded `encryptedColumn` or the naming convention): a match
35+
found only by being the table's sole EQL column is refused with
36+
instructions, and an ambiguous table (several EQL columns, none
37+
identifiable) fails closed listing the candidates — as does `cutover`.
3038
- **`encrypt status`** classifies each column from the observed domain type
3139
(manifest as fallback), shows `v3` in the EQL column, and no longer raises
3240
the v2-only `not-registered` / `plaintext-col-missing` drift flags for v3
3341
columns. `stash status`'s quest ladder and the `stash init` agent handoff
3442
prompt teach the version-appropriate next step (no more "run cutover" on
3543
v3 columns).
3644
- New `@cipherstash/migrate` exports: `classifyEqlDomain`,
37-
`resolveEncryptedColumn`, `listEncryptedColumns` (domain-type resolution —
38-
case-exact for quoted/mixed-case table names), `countEncrypted` /
39-
`countUnencrypted` (coverage counts), and manifest `eqlVersion` +
40-
`encryptedColumn` fields. `EqlVersion` is numeric (`2 | 3`), matching the
41-
manifest and the installer.
45+
`resolveEncryptedColumn`, `pickEncryptedColumn`, `listEncryptedColumns`
46+
(domain-type resolution — case-exact for quoted/mixed-case table names),
47+
`countEncrypted` / `countUnencrypted` (coverage counts), and manifest
48+
`eqlVersion` + `encryptedColumn` fields. `EqlVersion` is numeric (`2 | 3`),
49+
matching the manifest and the installer. Resolved columns carry `via:
50+
'hint' | 'convention' | 'sole'` so callers can tell a positively asserted
51+
pairing from a by-elimination guess.
4252
- Fixed: `encrypt cutover`/`encrypt drop` precondition failures now actually
4353
exit 1 — the early-return guards previously skipped the exit-code path
4454
entirely, so failed preconditions exited 0. (This also applies to v2

packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts

Lines changed: 155 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55
// v2 config machine, and `encrypt drop` must target the ORIGINAL plaintext
66
// column (there is no `<col>_plaintext`) gated on `backfilled` AND a live
77
// coverage check. Version + encrypted-column NAME come from the domain types
8-
// via `resolveEncryptedColumn` — the `<col>_encrypted` naming is a
9-
// convention, never relied upon. The v2 paths are pinned alongside as
10-
// regression guards.
8+
// via `resolveColumnLifecycle` — the `<col>_encrypted` naming is a
9+
// convention, never relied upon — and both commands FAIL CLOSED when
10+
// resolution is ambiguous instead of guessing a lifecycle. The v2 paths are
11+
// pinned alongside as regression guards.
1112

1213
const queryMock = vi.hoisted(() =>
1314
vi.fn(async (_sql: string) => ({ rows: [] as unknown[] })),
@@ -23,16 +24,12 @@ vi.mock('pg', () => ({
2324
}))
2425

2526
type ColumnInfo = { column: string; domain: string; version: 2 | 3 }
27+
type ResolvedInfo = ColumnInfo & { via: 'hint' | 'convention' | 'sole' }
28+
type Lifecycle = { info: ResolvedInfo | null; candidates: ColumnInfo[] }
2629

2730
const migrateMocks = vi.hoisted(() => ({
28-
resolveEncryptedColumn: vi.fn(
29-
async (): Promise<ColumnInfo | null> => ({
30-
column: 'email_encrypted',
31-
domain: 'eql_v3_text_search',
32-
version: 3,
33-
}),
34-
),
3531
listEncryptedColumns: vi.fn(async (): Promise<ColumnInfo[]> => []),
32+
pickEncryptedColumn: vi.fn(() => null),
3633
readManifest: vi.fn(async () => null),
3734
countUnencrypted: vi.fn(async () => 0),
3835
progress: vi.fn(
@@ -47,6 +44,22 @@ const migrateMocks = vi.hoisted(() => ({
4744
}))
4845
vi.mock('@cipherstash/migrate', () => migrateMocks)
4946

47+
// Mock the lifecycle RESOLUTION (each test states its scenario directly)
48+
// but keep the real `explainUnresolved` — the fail-closed messaging is part
49+
// of what these tests pin.
50+
const lifecycleMock = vi.hoisted(() =>
51+
vi.fn(
52+
async (): Promise<{
53+
info: (ColumnInfo & { via: string }) | null
54+
candidates: ColumnInfo[]
55+
}> => ({ info: null, candidates: [] }),
56+
),
57+
)
58+
vi.mock('../lib/resolve-eql.js', async (importOriginal) => {
59+
const actual = await importOriginal<typeof import('../lib/resolve-eql.js')>()
60+
return { ...actual, resolveColumnLifecycle: lifecycleMock }
61+
})
62+
5063
vi.mock('@clack/prompts', () => ({
5164
intro: vi.fn(),
5265
outro: vi.fn(),
@@ -104,6 +117,13 @@ const V2_INFO: ColumnInfo = {
104117
version: 2,
105118
}
106119

120+
function resolved(
121+
info: ColumnInfo,
122+
via: ResolvedInfo['via'] = 'convention',
123+
): Lifecycle {
124+
return { info: { ...info, via }, candidates: [info] }
125+
}
126+
107127
/** v2 config machine present + a pending config row. */
108128
function mockV2ConfigQueries() {
109129
queryMock.mockImplementation(async (sql: string) => {
@@ -129,7 +149,7 @@ describe('encrypt cutover — EQL version awareness', () => {
129149
vi.clearAllMocks()
130150
queryMock.mockResolvedValue({ rows: [] })
131151
migrateMocks.progress.mockResolvedValue({ phase: 'backfilled' })
132-
migrateMocks.resolveEncryptedColumn.mockResolvedValue(V3_INFO)
152+
lifecycleMock.mockResolvedValue(resolved(V3_INFO))
133153
})
134154

135155
it('short-circuits on a backfilled v3 column: guidance, no rename, no config machine, exit 0', async () => {
@@ -169,8 +189,22 @@ describe('encrypt cutover — EQL version awareness', () => {
169189
exitSpy.mockRestore()
170190
})
171191

192+
it('v3 already dropped: terminal phase is "nothing to do", not "finish the backfill"; exit 0', async () => {
193+
migrateMocks.progress.mockResolvedValue({ phase: 'dropped' })
194+
const exitSpy = spyExit()
195+
196+
await cutoverCommand({ table: 'users', column: 'email' })
197+
198+
expect(p.log.info).toHaveBeenCalledWith(
199+
expect.stringContaining('already completed'),
200+
)
201+
expect(p.log.error).not.toHaveBeenCalled()
202+
expect(exitSpy).not.toHaveBeenCalled()
203+
exitSpy.mockRestore()
204+
})
205+
172206
it('uses the RESOLVED encrypted column name, not the naming convention', async () => {
173-
migrateMocks.resolveEncryptedColumn.mockResolvedValue(V3_CUSTOM_INFO)
207+
lifecycleMock.mockResolvedValue(resolved(V3_CUSTOM_INFO, 'hint'))
174208

175209
await cutoverCommand({ table: 'users', column: 'email' })
176210

@@ -182,8 +216,29 @@ describe('encrypt cutover — EQL version awareness', () => {
182216
)
183217
})
184218

219+
it('fails closed when EQL columns exist but none is identifiable', async () => {
220+
lifecycleMock.mockResolvedValue({
221+
info: null,
222+
candidates: [
223+
{ column: 'a_enc', domain: 'eql_v3_text_eq', version: 3 },
224+
{ column: 'b_enc', domain: 'eql_v3_text_eq', version: 3 },
225+
],
226+
})
227+
const exitSpy = spyExit()
228+
229+
await cutoverCommand({ table: 'users', column: 'email' })
230+
231+
expect(p.log.error).toHaveBeenCalledWith(
232+
expect.stringContaining('Cannot identify which encrypted column'),
233+
)
234+
expect(p.log.error).toHaveBeenCalledWith(expect.stringContaining('a_enc'))
235+
expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
236+
expect(exitSpy).toHaveBeenCalledWith(1)
237+
exitSpy.mockRestore()
238+
})
239+
185240
it('still runs the v2 flow for a v2 column (regression pin)', async () => {
186-
migrateMocks.resolveEncryptedColumn.mockResolvedValue(V2_INFO)
241+
lifecycleMock.mockResolvedValue(resolved(V2_INFO))
187242
mockV2ConfigQueries()
188243

189244
await cutoverCommand({ table: 'users', column: 'email' })
@@ -196,8 +251,7 @@ describe('encrypt cutover — EQL version awareness', () => {
196251
it('explains a v3-only database instead of a raw relation-does-not-exist error', async () => {
197252
// Detection missed (e.g. no EQL columns visible) → v2 path — but the
198253
// config table doesn't exist on this database.
199-
migrateMocks.resolveEncryptedColumn.mockResolvedValue(null)
200-
migrateMocks.listEncryptedColumns.mockResolvedValue([])
254+
lifecycleMock.mockResolvedValue({ info: null, candidates: [] })
201255
queryMock.mockImplementation(async (sql: string) =>
202256
typeof sql === 'string' && sql.includes('to_regclass')
203257
? { rows: [{ exists: null }] }
@@ -220,7 +274,7 @@ describe('encrypt drop — EQL version awareness', () => {
220274
beforeEach(() => {
221275
vi.clearAllMocks()
222276
queryMock.mockResolvedValue({ rows: [] })
223-
migrateMocks.resolveEncryptedColumn.mockResolvedValue(V3_INFO)
277+
lifecycleMock.mockResolvedValue(resolved(V3_INFO))
224278
migrateMocks.countUnencrypted.mockResolvedValue(0)
225279
})
226280

@@ -246,6 +300,24 @@ describe('encrypt drop — EQL version awareness', () => {
246300
)
247301
})
248302

303+
it('v3: the generated migration re-verifies coverage at APPLY time, atomically', async () => {
304+
migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
305+
306+
await dropCommand({ table: 'users', column: 'email' })
307+
308+
// The CLI-side count goes stale the moment the file is written; the
309+
// migration must lock, re-count, and abort without dropping if any
310+
// plaintext-only row appeared between generation and application.
311+
const sql = writeFileMock.mock.calls[0]?.[1] as string
312+
expect(sql).toContain('LOCK TABLE "users" IN ACCESS EXCLUSIVE MODE')
313+
expect(sql).toContain('RAISE EXCEPTION')
314+
expect(sql).toContain('"email" IS NOT NULL')
315+
expect(sql).toContain('"email_encrypted" IS NULL')
316+
// The DROP itself runs inside the same DO block (EXECUTE), so
317+
// check-and-drop stay atomic even under non-transactional runners.
318+
expect(sql).toMatch(/EXECUTE 'ALTER TABLE "users" DROP COLUMN "email"'/)
319+
})
320+
249321
it('v3: refuses to generate when rows are still plaintext-only', async () => {
250322
migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
251323
migrateMocks.countUnencrypted.mockResolvedValueOnce(7)
@@ -266,7 +338,7 @@ describe('encrypt drop — EQL version awareness', () => {
266338
})
267339

268340
it('v3: gates coverage on the RESOLVED encrypted column name', async () => {
269-
migrateMocks.resolveEncryptedColumn.mockResolvedValue(V3_CUSTOM_INFO)
341+
lifecycleMock.mockResolvedValue(resolved(V3_CUSTOM_INFO, 'hint'))
270342
migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
271343

272344
await dropCommand({ table: 'users', column: 'email' })
@@ -281,6 +353,54 @@ describe('encrypt drop — EQL version awareness', () => {
281353
expect(sql).toContain('DROP COLUMN "email"')
282354
})
283355

356+
it("v3: refuses a by-elimination ('sole') match — uniqueness cannot prove the pairing", async () => {
357+
// The table's ONE EQL column may encrypt a DIFFERENT field; gating
358+
// coverage on it and dropping `email` could destroy the only copy.
359+
lifecycleMock.mockResolvedValue(
360+
resolved(
361+
{ column: 'secret_blob', domain: 'eql_v3_text_search', version: 3 },
362+
'sole',
363+
),
364+
)
365+
// No progress stub: the sole-match guard fires before the phase gate
366+
// ever consults cs_migrations. (Queuing an unconsumed
367+
// mockResolvedValueOnce here would leak into the next test.)
368+
const exitSpy = spyExit()
369+
370+
await dropCommand({ table: 'users', column: 'email' })
371+
372+
expect(p.log.error).toHaveBeenCalledWith(
373+
expect.stringContaining('nothing confirms it encrypts "email"'),
374+
)
375+
expect(p.log.error).toHaveBeenCalledWith(
376+
expect.stringContaining('--encrypted-column secret_blob'),
377+
)
378+
expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
379+
expect(writeFileMock).not.toHaveBeenCalled()
380+
expect(exitSpy).toHaveBeenCalledWith(1)
381+
exitSpy.mockRestore()
382+
})
383+
384+
it('fails closed when EQL columns exist but none is identifiable', async () => {
385+
lifecycleMock.mockResolvedValue({
386+
info: null,
387+
candidates: [
388+
{ column: 'a_enc', domain: 'eql_v3_text_eq', version: 3 },
389+
{ column: 'b_enc', domain: 'eql_v3_text_eq', version: 3 },
390+
],
391+
})
392+
const exitSpy = spyExit()
393+
394+
await dropCommand({ table: 'users', column: 'email' })
395+
396+
expect(p.log.error).toHaveBeenCalledWith(
397+
expect.stringContaining('Cannot identify which encrypted column'),
398+
)
399+
expect(writeFileMock).not.toHaveBeenCalled()
400+
expect(exitSpy).toHaveBeenCalledWith(1)
401+
exitSpy.mockRestore()
402+
})
403+
284404
it('v3: rejects when not yet backfilled', async () => {
285405
migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilling' })
286406
const exitSpy = spyExit()
@@ -296,7 +416,24 @@ describe('encrypt drop — EQL version awareness', () => {
296416
})
297417

298418
it('v2: unchanged — requires cut-over, no coverage gate, drops <col>_plaintext (regression pin)', async () => {
299-
migrateMocks.resolveEncryptedColumn.mockResolvedValue(V2_INFO)
419+
lifecycleMock.mockResolvedValue(resolved(V2_INFO))
420+
migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' })
421+
422+
await dropCommand({ table: 'users', column: 'email' })
423+
424+
const sql = writeFileMock.mock.calls[0]?.[1] as string
425+
expect(sql).toContain('DROP COLUMN "email_plaintext"')
426+
expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
427+
})
428+
429+
it('v2 post-cutover: `email` itself carrying the v2 domain is NOT ambiguity — proceeds down the v2 path', async () => {
430+
// After cutover renamed the ciphertext onto `email`, no counterpart is
431+
// resolvable BY DESIGN. The fail-closed guard must recognize this state
432+
// rather than blocking the one drop the lifecycle actually wants.
433+
lifecycleMock.mockResolvedValue({
434+
info: null,
435+
candidates: [{ column: 'email', domain: 'eql_v2_encrypted', version: 2 }],
436+
})
300437
migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' })
301438

302439
await dropCommand({ table: 'users', column: 'email' })

packages/cli/src/commands/encrypt/cutover.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { detectDrizzle } from '@/commands/db/detect.js'
1212
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
1313
import { loadStashConfig } from '@/config/index.js'
1414
import { scaffoldDrizzleMigration } from './drizzle-helper.js'
15-
import { resolveColumnLifecycle } from './lib/resolve-eql.js'
15+
import { explainUnresolved, resolveColumnLifecycle } from './lib/resolve-eql.js'
1616

1717
/**
1818
* Options accepted by `stash encrypt cutover`. Swaps the plaintext and
@@ -70,15 +70,39 @@ export async function cutoverCommand(options: CutoverCommandOptions) {
7070
// DOMAIN TYPES (manifest name as a hint; the `<col>_encrypted` naming is
7171
// a convention, never relied upon) before any phase/config checks so v3
7272
// users get the real answer, not a confusing precondition error.
73-
const { info } = await resolveColumnLifecycle(
73+
const { info, candidates } = await resolveColumnLifecycle(
7474
client,
7575
options.table,
7676
options.column,
7777
)
78+
// Fail closed on ambiguity: `info === null` with EQL columns present
79+
// means we can't tell WHICH lifecycle applies — running the v2 config
80+
// machine against (possibly) v3 columns would only produce a misleading
81+
// downstream error. (No EQL columns at all, or the post-cutover v2
82+
// same-name state, still falls through to the v2 preconditions below.)
83+
const unresolved = explainUnresolved(
84+
options.table,
85+
options.column,
86+
candidates,
87+
)
88+
if (!info && unresolved) {
89+
p.log.error(unresolved)
90+
exitCode = 1
91+
return
92+
}
7893
const state = await progress(client, options.table, options.column)
7994

8095
if (info?.version === 3) {
8196
const encryptedColumn = info.column
97+
if (state?.phase === 'dropped') {
98+
// Terminal phase — the lifecycle already finished. Not an error and
99+
// not "finish the backfill": there is nothing left to backfill.
100+
p.log.info(
101+
`${options.table}.${options.column} has already completed the EQL v3 lifecycle (plaintext dropped). Nothing to cut over.`,
102+
)
103+
p.outro('Nothing to do for EQL v3.')
104+
return
105+
}
82106
if (state?.phase !== 'backfilled') {
83107
// Not a "nothing to do" — the user isn't ready for ANY next step
84108
// yet. Exit 1 so scripted pipelines gating on cutover don't read

0 commit comments

Comments
 (0)