Skip to content

feat(migrate): EQL v3 support#649

Merged
coderdan merged 6 commits into
mainfrom
feat/migrate-eql-v3
Jul 17, 2026
Merged

feat(migrate): EQL v3 support#649
coderdan merged 6 commits into
mainfrom
feat/migrate-eql-v3

Conversation

@coderdan

@coderdan coderdan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Makes @cipherstash/migrate (and the stash encrypt * CLI it backs) work with EQL v3 columns. Closes #648.

Design (from #648, sharpened in review)

  • v3 cut-over: none — switch by column name. Backfill the encrypted column, the app points at it, plaintext dropped later via encrypt drop. No v3 rename step.
  • The domain types are the source of truth. EQL v3 types are self-describing — that's the point of v3 — so version AND encrypted-column resolution come from the Postgres domain types (classifyEqlDomain / resolveEncryptedColumn / listEncryptedColumns). The <col>_encrypted naming is a convention only — neither enforced nor relied upon: an explicit name (manifest-recorded --encrypted-column) wins, then the convention is validated against the domain, then a table's sole EQL column resolves with no convention at all. No new flags.

What landed

Version + column resolution (@cipherstash/migrate):

  • classifyEqlDomain (the one home for the domain→version rule; eql_v3_ prefix, underscore included), detectColumnEqlVersion, listEncryptedColumns, resolveEncryptedColumn. Table resolution is case-exact (format('%I') before to_regclass) — a bare to_regclass case-folded Prisma-style "User" tables and silently misclassified v3 columns as v2. EqlVersion is numeric (2 | 3), matching the manifest and installer.
  • The manifest records encryptedColumn at backfill time (including any --encrypted-column override); cutover/drop resolve manifest-hint-first, so custom-named columns can't deadlock the v3 lifecycle. countEncrypted/countUnencrypted exported as coverage primitives.
  • runBackfill was already version-agnostic — the v2 config machine lives in eql.ts and is only called by the CLI, so lifecycle routing lands CLI-side.

Version-aware CLI (auto-detected, no flags):

  • encrypt backfill: logs the detected lifecycle, records eqlVersion + encryptedColumn + the v3 target phase (dropped) in .cipherstash/migrations.json, prints v3 next steps.
  • encrypt cutover: phase-aware v3 short-circuit — backfilled → "not applicable" + the actual next step, exit 0; mid-backfill → exit 1 with "finish the backfill" (never switch-now guidance onto a half-populated column). The v2 path guards the eql_v2_configuration query, so v3-only databases get an explanation instead of a raw relation-does-not-exist error.
  • encrypt drop: v3 runs from backfilled, verifies live coverage before generating — refuses (with count + re-backfill guidance) while any row has plaintext set and the encrypted column NULL. Dropping the original column is the irreversible v3 step; the phase gate alone only proves a backfill once finished. v2 unchanged.
  • encrypt status: classifies from the observed domain type (manifest as fallback) — no permanent false not-registered flags when the manifest predates detection; shows v3.
  • stash status quest ladder + the stash init agent handoff teach the version-appropriate ladder (4 rungs for v3, switch-by-name) instead of steering v3 users into the no-op cutover.
  • Drive-by fix the new tests exposed: cutover/drop precondition failures previously exited 0 (guards returned from inside try, making the trailing process.exit unreachable) — exit now lives in finally. Note this also corrects v2 behaviour to the documented contract.

Verification:

  • Live proof (real ZeroKMS, postgres-eql with EQL v3 installed by this branch's CLI): real EncryptionV3 ciphertext backfilled into a concrete eql_v3_text domain column — domain CHECK satisfied — detection → v3, countEncrypted exact (25/25), decrypt round-trip exact.
  • Integration suites (PG_TEST_URL harness, no credentials): flat + SteVec payloads through the leak guard and a domain-typed target column (implicit jsonb→domain cast + CHECK enforcement), countEncrypted, idempotency, and the resolver against a real catalog — mixed-case quoted tables and name-free resolution included.
  • migrate: 49 tests (unit + 2 live integration suites). cli: 507 unit (the encrypt-v3 suite pins the coverage gate, phase-aware cutover, resolved-name usage, and both versions' branches) + 58 pty e2e.

Docs: migrate README rewritten around the two lifecycles and the domain-resolution primitives; stash-cli + stash-encryption skills document the dual ladder (v2: backfill → cutover → drop; v3: backfill → switch-by-name → drop) and drop's coverage gate; changeset: @cipherstash/migrate + stash minor.

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

Summary by CodeRabbit

  • New Features
    • Added EQL v3 support across encryption rollout commands.
    • encrypt backfill now detects lifecycle versions and provides version-specific next steps.
    • encrypt drop safely removes plaintext columns with coverage checks before and during migration.
    • Encryption status now reports EQL v3 accurately and provides tailored progress guidance.
    • Added version-aware migration utilities for identifying encrypted columns and verifying coverage.
  • Bug Fixes
    • Encryption precondition failures now return exit code 1.
    • Ambiguous encrypted-column matches fail safely with actionable feedback.
  • Documentation
    • Updated CLI help and migration guidance for EQL v2 and v3 workflows.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a903e94-bc5b-494a-bba5-2facac34fe81

📥 Commits

Reviewing files that changed from the base of the PR and between 8b8a712 and 605c40a.

📒 Files selected for processing (13)
  • .changeset/migrate-eql-v3.md
  • packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
  • packages/cli/src/commands/encrypt/cutover.ts
  • packages/cli/src/commands/encrypt/drop.ts
  • packages/cli/src/commands/encrypt/lib/resolve-eql.ts
  • packages/cli/src/commands/status/index.ts
  • packages/cli/src/commands/status/quest.ts
  • packages/migrate/README.md
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts
  • packages/migrate/src/__tests__/version.test.ts
  • packages/migrate/src/index.ts
  • packages/migrate/src/version.ts
  • skills/stash-cli/SKILL.md

📝 Walkthrough

Walkthrough

The migration library and stash encrypt commands now detect EQL v2 or v3, persist lifecycle metadata, support v3 backfills and verification, skip v3 cutover, generate version-specific drop migrations, and display v3-aware status information.

Changes

EQL v3 encryption lifecycle

Layer / File(s) Summary
Migration detection and contracts
packages/migrate/src/version.ts, packages/migrate/src/cursor.ts, packages/migrate/src/manifest.ts, packages/migrate/src/index.ts, packages/cli/src/commands/encrypt/lib/resolve-eql.ts
Adds domain-based version detection, encrypted-column resolution, coverage counting, manifest metadata, and public exports.
EQL v3 backfill validation
packages/cli/src/commands/encrypt/backfill.ts, packages/migrate/src/__tests__/backfill-v3.integration.test.ts, packages/migrate/src/__tests__/version.test.ts, packages/migrate/vitest.config.ts, packages/migrate/README.md
Records version-specific backfill phases and validates scalar, SteVec, idempotent, and catalog-resolution behavior for v3.
Version-aware encrypt commands and status
packages/cli/src/commands/encrypt/*, packages/cli/src/commands/status/*, packages/cli/src/commands/init/lib/*, packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
Adds lifecycle resolution, v3 cutover and drop rules, coverage checks, corrected failure exit codes, domain-aware status rendering, and v3 quest guidance.
Lifecycle documentation and command help
.changeset/migrate-eql-v3.md, packages/migrate/README.md, skills/stash-cli/SKILL.md, skills/stash-encryption/SKILL.md, packages/cli/src/bin/main.ts, packages/cli/src/cli/registry.ts
Documents version-specific lifecycle phases, APIs, client requirements, drop behavior, and the v2-only cutover command.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant EncryptCLI
  participant MigrateLibrary
  participant Postgres
  Operator->>EncryptCLI: encrypt backfill
  EncryptCLI->>MigrateLibrary: resolveColumnLifecycle
  MigrateLibrary->>Postgres: inspect encrypted column domain
  Postgres-->>MigrateLibrary: EQL version and column metadata
  EncryptCLI->>MigrateLibrary: run version-aware backfill
  MigrateLibrary->>Postgres: write encrypted payloads
  Operator->>EncryptCLI: encrypt drop
  EncryptCLI->>MigrateLibrary: countUnencrypted
  MigrateLibrary->>Postgres: verify plaintext/encrypted coverage
  EncryptCLI-->>Operator: generate version-specific drop migration
Loading

Possibly related issues

  • cipherstash/stack#648: Directly tracks the EQL v3 compatibility implemented across the migration library and encrypt CLI.
  • cipherstash/stack#534: Related to the EQL v3 domain-based schema behavior used by this lifecycle support.

Possibly related PRs

Suggested reviewers: freshtonic, tobyhede

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements EQL v3 support across migrate, CLI lifecycle commands, tests, and docs, matching the linked issue's acceptance criteria.
Out of Scope Changes check ✅ Passed The changes stay focused on EQL v3 migration support, with docs, tests, and help text all directly tied to the new lifecycle.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: EQL v3 support in migrate.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/migrate-eql-v3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 605c40a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@cipherstash/migrate Minor
stash Minor
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/prisma-next Minor
@cipherstash/wizard Minor
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderdan
coderdan force-pushed the feat/migrate-eql-v3 branch from af92d80 to 3d81993 Compare July 16, 2026 07:58
coderdan added a commit that referenced this pull request Jul 16, 2026
Completes #649. The backfill engine was already version-agnostic (it never
touches the v2 config machine in eql.ts — that's CLI-called only), and
isEncryptedPayload already accepts both v3 wire shapes, so the work lands
where the v2 coupling actually lived:

migrate:
- countEncrypted(client, table, column) — the v3 verification primitive
  (v3 has no eql_v2.count_encrypted_with_active_config; a plain count of
  the populated domain column is the equivalent, the domain CHECK already
  guarantees validity).
- Manifest gains an optional eqlVersion (2|3) field, recorded at backfill
  time, so status/plan branch without a DB round-trip.
- backfill-v3.integration.test.ts pins the v3 contract against a real
  Postgres: flat-scalar and SteVec payloads through the leak guard, the
  $N::jsonb write, countEncrypted, idempotency. vitest fileParallelism
  disabled for the package — the two integration suites share the
  singleton cipherstash.cs_migrations schema and raced when parallel.

cli (all auto-detected via detectColumnEqlVersion, no new flags):
- encrypt backfill: detects the version, logs the lifecycle, records
  eqlVersion + the v3 target phase ('dropped' — no cut-over) in the
  manifest, and prints v3 next steps (switch-by-name, then drop).
- encrypt cutover: v3 short-circuits with "not applicable" guidance
  (exit 0) before any v2 config-machine call.
- encrypt drop: v3 runs from 'backfilled' and drops the ORIGINAL column
  (no <col>_plaintext exists under v3); v2 unchanged, both pinned by tests.
- encrypt status: v2-only drift flags (not-registered,
  plaintext-col-missing) suppressed for v3 columns; EQL column shows 'v3'.
- Fixed a pre-existing exit-code bug the new tests exposed: cutover/drop
  precondition guards `return` from inside `try`, so the trailing
  `if (exitCode) process.exit(...)` was unreachable — precondition
  failures exited 0. The exit now lives in `finally`.

Verified live against postgres-eql + EQL v3 installed by this branch's CLI:
real EncryptionV3 ciphertext backfilled into a concrete eql_v3_text domain
column (CHECK satisfied), detectColumnEqlVersion → v3, countEncrypted
exact, decrypt round-trip exact.

Docs: migrate README rewritten around the two lifecycles; stash-cli and
stash-encryption skills updated (the #648 v2-only notes are gone).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan marked this pull request as ready for review July 16, 2026 08:24
@coderdan
coderdan requested a review from a team as a code owner July 16, 2026 08:24
@coderdan
coderdan marked this pull request as draft July 16, 2026 08:27

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/migrate/src/manifest.ts`:
- Around line 63-71: Update the `eqlVersion` field in `ManifestColumnSchema` to
validate the string literals `'v2'` and `'v3'`, matching the `EqlVersion` values
returned by `detectColumnEqlVersion`, while keeping the field optional for older
manifests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea91f446-d211-4211-bdd1-e4288e3d212d

📥 Commits

Reviewing files that changed from the base of the PR and between bab1307 and 897372b.

📒 Files selected for processing (18)
  • .changeset/migrate-eql-v3.md
  • packages/cli/src/bin/main.ts
  • packages/cli/src/cli/registry.ts
  • packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
  • packages/cli/src/commands/encrypt/backfill.ts
  • packages/cli/src/commands/encrypt/cutover.ts
  • packages/cli/src/commands/encrypt/drop.ts
  • packages/cli/src/commands/encrypt/status.ts
  • packages/migrate/README.md
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts
  • packages/migrate/src/__tests__/version.test.ts
  • packages/migrate/src/cursor.ts
  • packages/migrate/src/index.ts
  • packages/migrate/src/manifest.ts
  • packages/migrate/src/version.ts
  • packages/migrate/vitest.config.ts
  • skills/stash-cli/SKILL.md
  • skills/stash-encryption/SKILL.md

Comment thread packages/migrate/src/manifest.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds EQL v3 support to the @cipherstash/migrate primitives and the stash encrypt * CLI lifecycle by auto-detecting a column’s EQL version from its Postgres domain type and routing version-specific lifecycle steps accordingly.

Changes:

  • Added detectColumnEqlVersion (domain-type based EQL v2/v3 detection) plus v3 verification primitive countEncrypted, and recorded EQL version in the migration manifest.
  • Made stash encrypt backfill / cutover / drop / status version-aware (v3: no cut-over; drop targets original plaintext column; status suppresses v2-only drift flags).
  • Updated shipped skills + migrate README + changeset to document the v2 vs v3 rollout lifecycles, and serialized migrate’s integration tests to avoid DB/schema races.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
skills/stash-encryption/SKILL.md Updates rollout guidance to describe both v2 and v3 lifecycles and v3 “switch-by-name” behavior.
skills/stash-cli/SKILL.md Documents version-aware encrypt * behavior (v3: no cutover; drop semantics differ).
packages/migrate/vitest.config.ts Serializes test files to avoid shared-DB schema/state races.
packages/migrate/src/version.ts Introduces detectColumnEqlVersion for domain-based EQL v2/v3 detection.
packages/migrate/src/manifest.ts Adds optional eqlVersion field to persisted manifest column entries.
packages/migrate/src/index.ts Exports new helpers (countEncrypted, detectColumnEqlVersion, EqlVersion).
packages/migrate/src/cursor.ts Adds countEncrypted as v3’s backfill verification primitive.
packages/migrate/src/tests/version.test.ts Unit tests for detectColumnEqlVersion behavior.
packages/migrate/src/tests/backfill-v3.integration.test.ts New v3 integration coverage for backfill leak-guard acceptance + countEncrypted + idempotency.
packages/migrate/README.md Rewrites lifecycle documentation to clearly distinguish v2 vs v3.
packages/cli/src/commands/encrypt/status.ts Renders v3 status correctly and suppresses v2-only drift flags for v3 columns (manifest-driven).
packages/cli/src/commands/encrypt/drop.ts Makes drop version-aware (v3: drop original plaintext column; v2: drop <col>_plaintext) and fixes exit-code handling.
packages/cli/src/commands/encrypt/cutover.ts Short-circuits on v3 with guidance (no-op) and fixes exit-code handling for guard failures.
packages/cli/src/commands/encrypt/backfill.ts Detects and records EQL version at backfill time; sets version-appropriate target phase; prints v3 next steps.
packages/cli/src/commands/encrypt/tests/encrypt-v3.test.ts Unit tests asserting v3/v2 branching for encrypt cutover and encrypt drop.
packages/cli/src/cli/registry.ts Marks encrypt cutover as “EQL v2 only” in the registry summary.
packages/cli/src/bin/main.ts Updates CLI help text for encrypt cutover to indicate v2-only behavior.
.changeset/migrate-eql-v3.md Changeset documenting new v3 support and lifecycle behavior changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/migrate/src/__tests__/backfill-v3.integration.test.ts
Comment thread packages/migrate/src/version.ts Outdated
@coderdan
coderdan marked this pull request as ready for review July 16, 2026 11:27
coderdan added 2 commits July 16, 2026 21:31
First slice of EQL v3 support (#648): detectColumnEqlVersion() inspects an
encrypted column's Postgres domain type — public.eql_v2_encrypted -> v2, a
concrete eql_v3_* domain -> v3, anything else (plaintext / not found) -> null.
This is the keystone the version-aware lifecycle branches on. Unit-tested with a
mocked pg client; no behaviour change to existing v2 paths.

Refs #648
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Completes #649. The backfill engine was already version-agnostic (it never
touches the v2 config machine in eql.ts — that's CLI-called only), and
isEncryptedPayload already accepts both v3 wire shapes, so the work lands
where the v2 coupling actually lived:

migrate:
- countEncrypted(client, table, column) — the v3 verification primitive
  (v3 has no eql_v2.count_encrypted_with_active_config; a plain count of
  the populated domain column is the equivalent, the domain CHECK already
  guarantees validity).
- Manifest gains an optional eqlVersion (2|3) field, recorded at backfill
  time, so status/plan branch without a DB round-trip.
- backfill-v3.integration.test.ts pins the v3 contract against a real
  Postgres: flat-scalar and SteVec payloads through the leak guard, the
  $N::jsonb write, countEncrypted, idempotency. vitest fileParallelism
  disabled for the package — the two integration suites share the
  singleton cipherstash.cs_migrations schema and raced when parallel.

cli (all auto-detected via detectColumnEqlVersion, no new flags):
- encrypt backfill: detects the version, logs the lifecycle, records
  eqlVersion + the v3 target phase ('dropped' — no cut-over) in the
  manifest, and prints v3 next steps (switch-by-name, then drop).
- encrypt cutover: v3 short-circuits with "not applicable" guidance
  (exit 0) before any v2 config-machine call.
- encrypt drop: v3 runs from 'backfilled' and drops the ORIGINAL column
  (no <col>_plaintext exists under v3); v2 unchanged, both pinned by tests.
- encrypt status: v2-only drift flags (not-registered,
  plaintext-col-missing) suppressed for v3 columns; EQL column shows 'v3'.
- Fixed a pre-existing exit-code bug the new tests exposed: cutover/drop
  precondition guards `return` from inside `try`, so the trailing
  `if (exitCode) process.exit(...)` was unreachable — precondition
  failures exited 0. The exit now lives in `finally`.

Verified live against postgres-eql + EQL v3 installed by this branch's CLI:
real EncryptionV3 ciphertext backfilled into a concrete eql_v3_text domain
column (CHECK satisfied), detectColumnEqlVersion → v3, countEncrypted
exact, decrypt round-trip exact.

Docs: migrate README rewritten around the two lifecycles; stash-cli and
stash-encryption skills updated (the #648 v2-only notes are gone).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan force-pushed the feat/migrate-eql-v3 branch from 897372b to 17b46e7 Compare July 16, 2026 11:31
…e drop

Code-review round on #649. The EQL v3 types are self-describing — that
was the point of v3 — so version and encrypted-column resolution now
come from the Postgres domain types, with the <col>_encrypted naming
demoted to an unenforced convention:

- @cipherstash/migrate: classifyEqlDomain (the one home for the rule),
  listEncryptedColumns, resolveEncryptedColumn (hint → convention →
  sole-EQL-column). detectColumnEqlVersion resolves tables case-exactly
  (format('%I') before to_regclass) — a bare to_regclass case-folded
  Prisma-style "User" tables to 'user' and silently wedged v3 columns
  into the v2 lifecycle. EqlVersion is numeric (2|3), matching the
  manifest and installer; no more string↔number translation.
- The manifest records encryptedColumn at backfill time; cutover and
  drop resolve manifest-hint-first instead of guessing the name, so a
  custom --encrypted-column no longer deadlocks the v3 lifecycle.
- encrypt drop (v3) now verifies live coverage before generating the
  migration: refuses while any row has plaintext set and ciphertext
  NULL (countUnencrypted) — the verification the README promised but
  nothing performed. Dropping the original column is the irreversible
  step; the phase gate alone only proved a backfill once finished.
- encrypt cutover (v3) is phase-aware: mid-backfill it exits 1 telling
  the user to finish the backfill, instead of exit-0 guidance to switch
  the app onto a half-populated column. The v2 path guards the
  eql_v2_configuration query so v3-only databases get an explanation,
  not a raw relation-does-not-exist error.
- encrypt status classifies from the observed domain type (manifest as
  fallback) — a v3 column whose manifest entry predates detection no
  longer collects permanent false not-registered flags. stash status's
  quest ladder and the init agent handoff teach the v3 next step (4-rung
  ladder, switch-by-name) instead of steering v3 users into the no-op
  cutover.
- Docs trued up: manifest docstrings, migrate README (drop's built-in
  coverage gate), skills' phase-ladder intros (both lifecycles), and the
  stale workflow comments.

Unit + real-catalog integration coverage for the resolver (mixed-case
tables, custom names, ambiguity); CLI tests pin the coverage gate, the
phase-aware cutover, and resolved-name usage. 507 CLI + 49 migrate +
58 pty-e2e green.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/encrypt/cutover.ts`:
- Around line 73-80: Update resolveColumnLifecycle handling in
packages/cli/src/commands/encrypt/cutover.ts at lines 73-80 to reject null or
otherwise unresolved/ambiguous info before entering the v2 configuration
transaction. In packages/cli/src/commands/encrypt/drop.ts at lines 70-80, reject
unresolved targets while explicitly allowing the valid post-cutover v2
same-name-domain case; preserve existing handling for resolved lifecycle states.

In `@packages/cli/src/commands/encrypt/drop.ts`:
- Around line 91-123: Update the generated dropSql migration around the existing
plaintextToDrop logic to lock the target table, revalidate that no rows have the
plaintext column set while encryptedColumn is NULL, and only then drop the
column within the same transaction. Ensure the migration aborts without dropping
anything when coverage validation fails, preserving the existing safety message
or equivalent.

In `@packages/cli/src/commands/encrypt/lib/resolve-eql.ts`:
- Around line 44-47: Update the manifest lookup around readManifest so it no
longer catches and converts every failure to null. Preserve readManifest’s
existing ENOENT-to-null behavior, while allowing malformed JSON, schema
validation, permission, and other I/O errors to propagate before accessing
manifest?.tables in the hint calculation.

In `@packages/migrate/README.md`:
- Line 11: Update the fenced code block in the migration README to include an
explicit language identifier, such as text, while preserving its existing
content.

In `@packages/migrate/src/__tests__/version.test.ts`:
- Around line 10-12: Replace unsafe unknown-based client casts with minimal
typed query-capable fixtures. In packages/migrate/src/__tests__/version.test.ts
lines 10-12, update mockClient to return a typed { query } client; apply the
same approach in createMockClient at
packages/migrate/src/__tests__/state.test.ts lines 18-40. In
packages/migrate/src/__tests__/backfill-v3.integration.test.ts lines 238-240,
248-250, 258-260, and 272-274, create and pass one small query-capable adapter
instead of casting pool at each call site.

In `@packages/migrate/src/version.ts`:
- Around line 159-166: Update the candidate resolution logic after the
`${plaintextColumn}_encrypted` lookup so it no longer selects an unrelated
column based solely on table-wide uniqueness. When no validated naming match or
manifest/hint identifies the encrypted counterpart, return null; preserve the
direct conventional match behavior.

In `@skills/stash-cli/SKILL.md`:
- Around line 467-473: Remove the blank line separating the v2 cutover guidance
from the “Known gap (v2)” paragraph in the blockquote. Keep both paragraphs
contiguous within the same blockquote so the Markdown content passes MD028.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 68b64239-3991-43d3-989d-a226793f5979

📥 Commits

Reviewing files that changed from the base of the PR and between 897372b and 8b8a712.

📒 Files selected for processing (24)
  • .changeset/migrate-eql-v3.md
  • packages/cli/src/bin/main.ts
  • packages/cli/src/cli/registry.ts
  • packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
  • packages/cli/src/commands/encrypt/backfill.ts
  • packages/cli/src/commands/encrypt/cutover.ts
  • packages/cli/src/commands/encrypt/drop.ts
  • packages/cli/src/commands/encrypt/lib/db-readers.ts
  • packages/cli/src/commands/encrypt/lib/resolve-eql.ts
  • packages/cli/src/commands/encrypt/status.ts
  • packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts
  • packages/cli/src/commands/init/lib/setup-prompt.ts
  • packages/cli/src/commands/status/index.ts
  • packages/cli/src/commands/status/quest.ts
  • packages/migrate/README.md
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts
  • packages/migrate/src/__tests__/version.test.ts
  • packages/migrate/src/cursor.ts
  • packages/migrate/src/index.ts
  • packages/migrate/src/manifest.ts
  • packages/migrate/src/version.ts
  • packages/migrate/vitest.config.ts
  • skills/stash-cli/SKILL.md
  • skills/stash-encryption/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/cli/src/cli/registry.ts
  • packages/migrate/src/cursor.ts
  • packages/cli/src/bin/main.ts
  • packages/migrate/vitest.config.ts
  • .changeset/migrate-eql-v3.md
  • packages/cli/src/commands/encrypt/tests/encrypt-v3.test.ts
  • packages/cli/src/commands/encrypt/backfill.ts

Comment thread packages/cli/src/commands/encrypt/cutover.ts Outdated
Comment thread packages/cli/src/commands/encrypt/drop.ts Outdated
Comment thread packages/cli/src/commands/encrypt/lib/resolve-eql.ts Outdated
Comment thread packages/migrate/README.md Outdated
Comment thread packages/migrate/src/__tests__/version.test.ts
Comment thread packages/migrate/src/version.ts Outdated
Comment thread skills/stash-cli/SKILL.md Outdated
coderdan added 2 commits July 16, 2026 22:41
Two review-thread fixes on #649: the v3 backfill integration table now
types email_encrypted with a domain carrying the real eql_v3_* storage
CHECK shape (both scalar and SteVec arms), so the $N::jsonb write
exercises the implicit jsonb→domain cast + CHECK enforcement instead of
plain jsonb; and classifyEqlDomain matches the 'eql_v3_' prefix with
the underscore so a hypothetical eql_v30_* generation can't classify
as v3 (negative pins added).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan requested review from freshtonic and tobyhede July 16, 2026 13:06

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Solid, well-motivated work. Makes @cipherstash/migrate and stash encrypt * version-aware for EQL v3:

  • Domain types are the source of truthversion.ts classifies the EQL generation from the Postgres domain type (classifyEqlDomain/detectColumnEqlVersion/listEncryptedColumns/resolveEncryptedColumn); the <col>_encrypted name is a hint only, never relied upon.
  • v3 has no cut-over — lifecycle routing is version-aware (backfill → switch-by-name → drop), landing CLI-side since runBackfill was already version-agnostic.
  • drop gains a live coverage gate for v3 (countUnencrypted) since dropping the original plaintext column is irreversible.
  • A genuine exit-code bug fix rides along (guards returned from inside try, making the trailing process.exit unreachable).

Reasoning is captured in comments, tests are thorough (unit + real-catalog integration + the live-crypto proof in the PR body), the changeset is present, and both affected skills + the migrate README are updated in-PR. I verified the exports the docs now reference (@cipherstash/stack/v3, EncryptionV3, encryptedSupabaseV3) all exist on the branch.

Correctness — strengths

  • The exit-code fix is real and correctly done. Moving if (exitCode) process.exit(exitCode) into finally fixes precondition failures that previously exited 0; the success path (exitCode === 0) still avoids the exit. Well pinned by the spyExit tests.
  • classifyEqlDomain guards the prefix boundary (eql_v3_ with the underscore), with an explicit test that eql_v30_text/eql_v3 do not classify as v3.
  • Case-exact table resolution via format('%I', …) before to_regclass — the fix for Prisma-style "User" tables being case-folded and misclassified as v2 — is the subtle correct choice, tested against both a mock (asserting SQL shape) and a real quoted-table catalog.
  • The v3 coverage gate counts the right thing (plaintext IS NOT NULL AND encrypted IS NULL) against the resolved encrypted column name, and refuses before generating.

Issues & suggestions — all minor, none blocking

1. (Low, UX) Misleading cutover message on an already-migrated v3 column. cutover.ts:80 — the v3 branch treats every non-backfilled phase as "hasn't finished backfilling", so a column already in dropped gets told to "finish the backfill first". Consider special-casing terminal phases (dropped) or softening the wording for phases past backfilled.

2. (Low, efficiency) resolveColumnLifecycle can issue up to three identical listEncryptedColumns queries on the stale/absent-hint path (each a fresh catalog scan). Failure-path only and tables are small, so cosmetic — but you could resolve candidates once and derive info in-process.

3. (Low, consistency) drop/cutover decide the version from live resolution only, while status falls back to the manifest's cached eqlVersion. If the encrypted column is ambiguous/missing, resolveColumnLifecycle returns info: null and a v3 column silently takes the v2 path (drop errors Must be 'cut-over'; cutover hits the "no EQL v2 configuration table" guard). Defensible (these commands have a DB connection, so live truth is preferable, and the manifest encryptedColumn hint normally resolves it), but worth a one-line comment on the divergence. ResolvedLifecycle.candidates is plumbed through but not yet used to produce a friendlier "which column did you mean?" error — natural follow-up.

4. (Nit, advisory only) The coverage gate is inherently TOCTOU — counts, then writes a migration applied later; new plaintext-only rows can appear in between, and stale ciphertext (both set, plaintext edited post-backfill) is undetectable without decryption. Same limitations v2 has, already acknowledged in a comment. No action needed.

Conventions, tests, security

  • Conventions: Result/exit-code contracts preserved, ESM .js specifiers, EqlVersion kept numeric (2 | 3) to match the manifest zod schema and installer; manifest fields added as optional (backward-compatible with pre-v3 manifests).
  • Tests: Strong — version.test.ts covers classification + all four resolveEncryptedColumn branches incl. ambiguity → null; encrypt-v3.test.ts pins v3 and v2 (regression) cutover/drop, resolved-name-vs-convention, and the coverage gate; the integration suite exercises a real domain-typed column with a CHECK mirroring eql_v3_*. The new vitest.config.ts (fileParallelism: false) correctly prevents the two integration suites racing on the shared cs_migrations schema.
  • Security: No plaintext logging introduced; identifiers quoted (quoteIdent/format('%I')), values parameterised or routed through existing quoting helpers; the irreversible v3 drop is gated behind both a phase check and a live coverage count.

Verdict

Approve. Correct, well-tested, and unusually well-documented in-code. The four items are low-severity polish. Worth a quick look at #1 (one-line message tweak for terminal phases); consider wiring ResolvedLifecycle.candidates into a friendlier ambiguity error in a follow-up.

@tobyhede tobyhede left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work 👍

Comment thread packages/cli/src/commands/status/index.ts Outdated
Comment thread packages/cli/src/commands/encrypt/drop.ts
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
@coderdan

Copy link
Copy Markdown
Contributor Author

Review follow-ups landed in 605c40a — thanks Toby, James (@freshtonic), and CodeRabbit. All nine inline threads are resolved with replies; James's four review-body items are also in:

  1. Terminal-phase cutover messagecutover on an already-dropped v3 column now says "lifecycle complete, nothing to cut over" (exit 0) instead of "finish the backfill".
  2. Triple catalog scanresolveColumnLifecycle fetches listEncryptedColumns once; picking is now a pure pickEncryptedColumn applied twice (hint, then convention/sole).
  3. Live-vs-manifest divergence — documented on resolveColumnLifecycle, and candidates are now used for a real "which column did you mean?" error: both cutover and drop fail closed listing the table's EQL columns when resolution is ambiguous (the post-cutover v2 same-name state is recognised and still falls through to the v2 preconditions).
  4. TOCTOU on the drop coverage gate — no longer advisory-only: the generated v3 migration re-verifies coverage at apply time (LOCK TABLE → re-count → RAISE EXCEPTION without dropping → EXECUTE the DROP in the same DO block, atomic under non-transactional runners).

One judgement call worth eyes: CodeRabbit flagged that the sole-EQL-column fallback can pair unrelated columns (resolve email to a lone ssn_cipher, validate coverage against the wrong ciphertext, drop email). Rather than deleting the fallback — which would re-impose the naming convention whenever the manifest is absent, against the design principle this PR exists to implement — resolution now carries provenance (via: 'hint' | 'convention' | 'sole'), and encrypt drop refuses a 'sole' match with instructions to record the pairing. Non-destructive paths keep convention-free resolution. Shout if you'd prefer the stricter behaviour everywhere.

Also: manifest read failures no longer downgrade to "no hint" on the destructive paths (ENOENT still fine, malformed/permission errors propagate), stash status now derives the quest-ladder version from the domain type like encrypt status does (Toby's suggestion), stale drop.ts JSDoc rewritten version-aware, and the two markdown lint nits fixed. Changeset updated; migrate 36/36 + integration 7/7 (live Postgres) and CLI 513/513 green locally.

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

@coderdan
coderdan merged commit 3a86939 into main Jul 17, 2026
9 of 10 checks passed
@coderdan
coderdan deleted the feat/migrate-eql-v3 branch July 17, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make @cipherstash/migrate (and stash encrypt *) compatible with EQL v3

4 participants