Skip to content

feat(migrate): EQL v3 support#649

Open
coderdan wants to merge 5 commits into
mainfrom
feat/migrate-eql-v3
Open

feat(migrate): EQL v3 support#649
coderdan wants to merge 5 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

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 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/migrate/src/__tests__/version.test.ts
Adds domain-based version detection, encrypted-column resolution, coverage counting, manifest metadata, public exports, and unit tests.
EQL v3 backfill validation
packages/cli/src/commands/encrypt/backfill.ts, packages/migrate/src/__tests__/backfill-v3.integration.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
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

  • #534: Related to the domain-based EQL v3 schema and lifecycle detection used by this PR.
  • #409: Touches the same cutover command, although this PR addresses EQL v3 branching and exit handling rather than pending-config bookkeeping.

Possibly related PRs

Suggested reviewers: calvinbrewer

🚥 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: 8153419

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 on lines +73 to +80
const { info } = await resolveColumnLifecycle(
client,
options.table,
options.column,
)
const state = await progress(client, options.table, options.column)

if (info?.version === 3) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail closed when EQL lifecycle resolution returns no match. Both commands currently interpret info === null as v2, even though it also represents ambiguity or a missing encrypted column.

  • packages/cli/src/commands/encrypt/cutover.ts#L73-L80: reject unresolved or ambiguous targets before entering the v2 configuration transaction.
  • packages/cli/src/commands/encrypt/drop.ts#L70-L80: reject unresolved targets and explicitly recognize the valid post-cutover v2 same-name-domain case.
📍 Affects 2 files
  • packages/cli/src/commands/encrypt/cutover.ts#L73-L80 (this comment)
  • packages/cli/src/commands/encrypt/drop.ts#L70-L80
🤖 Prompt for 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.

In `@packages/cli/src/commands/encrypt/cutover.ts` around lines 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.

Comment on lines +91 to +123
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 = `-- Generated by stash encrypt drop\n-- Drops the plaintext column now that ${options.table}.${isV3 ? encryptedColumn : options.column} is encrypted.\n\nALTER TABLE ${qualifiedTable} DROP COLUMN "${plaintextToDrop}";\n`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Revalidate ciphertext coverage inside the generated drop migration.

This check becomes stale immediately after generation. A later plaintext-only write can occur before migration application, after which the generated SQL drops the plaintext without rechecking. Make the migration lock the table, verify coverage, and perform the drop atomically.

🤖 Prompt for 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.

In `@packages/cli/src/commands/encrypt/drop.ts` around lines 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.

Comment on lines +44 to +47
const manifest = await readManifest().catch(() => null)
const hint = manifest?.tables[table]?.find(
(entry) => entry.column === column,
)?.encryptedColumn

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not silently discard manifest validation or I/O failures.

readManifest already returns null for ENOENT. This catch also hides malformed JSON, schema failures, and permission errors, allowing destructive commands to continue without the recorded column hint.

Proposed fix
-  const manifest = await readManifest().catch(() => null)
+  const manifest = await readManifest()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const manifest = await readManifest().catch(() => null)
const hint = manifest?.tables[table]?.find(
(entry) => entry.column === column,
)?.encryptedColumn
const manifest = await readManifest()
const hint = manifest?.tables[table]?.find(
(entry) => entry.column === column,
)?.encryptedColumn
🤖 Prompt for 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.

In `@packages/cli/src/commands/encrypt/lib/resolve-eql.ts` around lines 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.

Each column walks through these phases:
Each column walks through these phases — the ladder depends on the column's EQL version (auto-detected from its Postgres domain type via `detectColumnEqlVersion`):

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced-block language.

Add a language identifier such as text to satisfy the reported MD040 lint warning.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 11-11: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for 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.

In `@packages/migrate/README.md` at 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.

Source: Linters/SAST tools

Comment on lines +10 to +12
function mockClient(rows: Array<Record<string, unknown>>) {
const query = vi.fn().mockResolvedValue({ rows })
return { client: { query } as unknown as ClientBase, query }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

printf '\n== version.test.ts ==\n'
sed -n '1,120p' packages/migrate/src/__tests__/version.test.ts

printf '\n== backfill-v3.integration.test.ts (relevant slices) ==\n'
sed -n '220,290p' packages/migrate/src/__tests__/backfill-v3.integration.test.ts

printf '\n== search for query-client helpers/types ==\n'
rg -n "ClientBase|query.*mock|as unknown as pg\\.ClientBase|as unknown as ClientBase|Pool|Client" packages/migrate/src/__tests__ packages/migrate/src -g '!**/dist/**'

Repository: cipherstash/stack

Length of output: 21970


🏁 Script executed:

set -euo pipefail
printf '\n== version.test.ts ==\n'
cat -n packages/migrate/src/__tests__/version.test.ts | sed -n '1,120p'

printf '\n== backfill-v3.integration.test.ts (relevant slices) ==\n'
cat -n packages/migrate/src/__tests__/backfill-v3.integration.test.ts | sed -n '220,290p'

printf '\n== search for query-client helpers/types ==\n'
rg -n "ClientBase|query.*mock|as unknown as pg\\.ClientBase|as unknown as ClientBase|Pool|Client" packages/migrate/src/__tests__ packages/migrate/src -g '!**/dist/**'

Repository: cipherstash/stack

Length of output: 23244


🏁 Script executed:

set -euo pipefail

printf '\n== state.test.ts helper ==\n'
cat -n packages/migrate/src/__tests__/state.test.ts | sed -n '1,90p'

printf '\n== version.ts signatures ==\n'
cat -n packages/migrate/src/version.ts | sed -n '1,220p'

Repository: cipherstash/stack

Length of output: 10720


Replace the as unknown as test client casts with typed fixtures.

  • packages/migrate/src/__tests__/version.test.ts#L10-L12: return a minimal typed { query } client instead of casting through unknown.
  • packages/migrate/src/__tests__/state.test.ts#L18-L40: apply the same pattern in createMockClient.
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts#L238-L274: pass a small query-capable adapter instead of casting pool at each call site.
📍 Affects 2 files
  • packages/migrate/src/__tests__/version.test.ts#L10-L12 (this comment)
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts#L238-L240
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts#L248-L250
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts#L258-L260
  • packages/migrate/src/__tests__/backfill-v3.integration.test.ts#L272-L274
🤖 Prompt for 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.

In `@packages/migrate/src/__tests__/version.test.ts` around lines 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.

Source: Coding guidelines

Comment on lines +159 to +166
const conventional = candidates.find(
(c) => c.column === `${plaintextColumn}_encrypted`,
)
if (conventional) return conventional

// The plaintext column itself can't be its own encrypted counterpart.
const others = candidates.filter((c) => c.column !== plaintextColumn)
return others.length === 1 ? (others[0] ?? null) : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not infer a plaintext relationship from table-wide uniqueness.

A sole EQL column may encrypt an unrelated field. Resolving email to the only EQL column, such as ssn_cipher, lets dropCommand validate the wrong ciphertext and generate a migration that destroys email. Require a manifest/hint or validated naming match; otherwise return null.

Proposed safe fallback
-  // The plaintext column itself can't be its own encrypted counterpart.
-  const others = candidates.filter((c) => c.column !== plaintextColumn)
-  return others.length === 1 ? (others[0] ?? null) : null
+  return null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const conventional = candidates.find(
(c) => c.column === `${plaintextColumn}_encrypted`,
)
if (conventional) return conventional
// The plaintext column itself can't be its own encrypted counterpart.
const others = candidates.filter((c) => c.column !== plaintextColumn)
return others.length === 1 ? (others[0] ?? null) : null
const conventional = candidates.find(
(c) => c.column === `${plaintextColumn}_encrypted`,
)
if (conventional) return conventional
return null
🤖 Prompt for 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.

In `@packages/migrate/src/version.ts` around lines 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.

Comment thread skills/stash-cli/SKILL.md Outdated
Comment on lines +467 to +473
**EQL v2 only** — v3 has no cut-over: the application switches to `<col>_encrypted` by name, and running this command on a v3 column reports "not applicable" (exit 0) with the next step. For v2, the preconditions are: the column is in the `backfilled` phase, **and** a pending EQL configuration exists.

In one transaction it renames `<col>` → `<col>_plaintext` and `<col>_encrypted` → `<col>`, advances the pending config to `encrypting`, activates it, and appends a `cut_over` event. With a Proxy URL configured (`--proxy-url` or `CIPHERSTASH_PROXY_URL`) it then calls `eql_v2.reload_config()` so Proxy picks up the new shape.

> **After cutover, `<col>` holds ciphertext — the read path is not automatic.** Wire reads through the encryption client (`decryptModel(row, table)` for Drizzle, the `encryptedSupabaseV3` wrapper for Supabase — `encryptedSupabase` on the legacy v2 surface, otherwise `decrypt` / `bulkDecryptModels`) before returning values to callers. Skip this and your read paths hand raw EQL payloads to end users. The integration skill has the exact API. **CipherStash Proxy is the one exception** — it decrypts on the wire, so Proxy users need no application change. The cutover plan written by `stash plan` includes this read-path switch as an explicit step.

> **Known gap.** The pending-configuration precondition is satisfied by `stash db push`. SDK-only users (who otherwise never need `db push`) must therefore run it once before `encrypt cutover`. Decoupling this — under EQL v3 there is no configuration table at all — is tracked in [issue #585](https://github.com/cipherstash/stack/issues/585).
> **Known gap (v2).** The pending-configuration precondition is satisfied by `stash db push`. SDK-only users (who otherwise never need `db push`) must therefore run it once before `encrypt cutover`. (EQL v3 columns sidestep this entirely — no configuration table, no cutover; see above.) Tracked in [issue #585](https://github.com/cipherstash/stack/issues/585).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the blank line inside the blockquote.

The blank line between the v2 cutover guidance and the “Known gap” paragraph triggers the reported MD028 Markdown lint warning. Keep the related blockquote content contiguous or close and reopen the quote intentionally.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 472-472: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🪛 SkillSpector (2.3.11)

[warning] 35: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 36: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 43: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 44: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 45: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 51: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 52: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 64: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 65: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 73: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 163: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 165: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 166: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 328: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 347: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 560: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))


[warning] 293: [MP2] Context Window Stuffing: Skill attempts to fill the context window with filler content, displacing legitimate instructions and safety constraints. This can degrade agent performance or bypass safety boundaries.

Remediation: Implement context-window management that detects and rejects padding or stuffing attempts. Prioritize system instructions over user-injected content.

(Memory Poisoning (MP2))


[warning] 12: [RA2] Session Persistence: Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Remediation: Remove any persistence mechanisms (cron jobs, startup scripts, state files). Skills should not maintain state across sessions without explicit user consent.

(Rogue Agent (RA2))

🤖 Prompt for 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.

In `@skills/stash-cli/SKILL.md` around lines 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.

Source: Linters/SAST tools

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
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

2 participants