Skip to content

fix(cli): mixed-table encrypt lifecycle, and a stash init scaffold that compiles (#772 review findings 6, 7)#787

Open
tobyhede wants to merge 2 commits into
remove-v2from
fix/cli-lifecycle-and-scaffold
Open

fix(cli): mixed-table encrypt lifecycle, and a stash init scaffold that compiles (#772 review findings 6, 7)#787
tobyhede wants to merge 2 commits into
remove-v2from
fix/cli-lifecycle-and-scaffold

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

Review remediation for #772 — findings 6 and 7, both verified by executing the real commands.

Finding 7 — encrypt cutover/drop acted on a guessed column

On a table holding a legacy EQL v2 pair (ssn / ssn_encrypted) plus one unrelated EQL v3 column, the v2 ciphertext column is not classified as an EQL column at all, so resolution fell through to the sole-EQL-column rule and claimed the unrelated v3 column. Three consequences:

  • cutover reported success for work it never did. No via gate, and its v3 branch returns without setting exitCode, so finally never fires process.exit — it printed "point your application at email_enc" and exited 0 while the v2 rename never ran. drop.ts:106 already gated on via === 'sole' for exactly this reason.
  • The recorded pairing was discarded. backfill writes the true encryptedColumn to the manifest, so the answer was on disk, but a hint that failed to resolve was dropped and the re-pick reached the guess. Now split by cause: a hint naming a column that is gone is stale and still falls back to convention; a hint naming a column that exists but is not EQL v3 is reported by name.
  • drop's remedy prescribed the guess. Following --encrypted-column <guess> recorded it as fact, so the next run resolved via: 'hint', walked past the gate, and passed the coverage check vacuously — an unrelated but legitimately backfilled column is non-NULL on every row — then generated a live DROP COLUMN on the plaintext at exit 0.

Tested at the layer that had none: resolve-eql.test.ts covered only explainUnresolved, and encrypt-v3.test.ts stubs resolveColumnLifecycle outright, so neither could see the hint discard. The new tests keep pickEncryptedColumn real and replace only the two I/O boundaries.

Finding 6 — stash init scaffolded a file that does not compile

Both templates emitted await Encryption({ schemas: [] }), a hard TS2769 against both overloads. Every stash init left a project failing its first tsc.

Relaxing the constraint is not an option — it is S-6 from #778, and catches a real mistake. stash init has no table names in scope by design. So the scaffold declares a sentinel __stash_placeholder__ table, and loadEncryptConfig exits 1 while that is the only table, naming the file.

The gap that let it ship matters more than the fix. Nothing compiled, parsed or executed the generated output: packages/cli has no typecheck step, the codegen tests only toContain-match fragments, and build-schema.test.ts mocks the generator to '// placeholder'. Both templates are now committed as .generated.ts fixtures compiled by a scoped tsconfig in CI, pinned byte-for-byte to the generator by a unit test. Verified the gate reproduces the original TS2769 when reverted.

Verification

pnpm --filter stash test → 884 passed. typecheck:scaffold clean. Biome: 0 errors.

Changesets: encrypt-lifecycle-mixed-table (stash patch), init-scaffold-compiles (stash patch). skills/stash-cli updated for the new scaffold behaviour.

tobyhede added 2 commits July 25, 2026 14:29
On a mixed table — a legacy v2 pair the classifier no longer sees, plus one
unrelated EQL v3 column — pickEncryptedColumn's sole-EQL-column rule claims the
v3 column for the v2 plaintext. Three separate defects turned that guess into
wrong outcomes.

cutover had no `via` gate at all. Its v3 branch returns from inside try with
exitCode untouched, so the finally never fires process.exit: it printed "point
your application at email_enc" and exited 0 while the v2 rename never ran.
drop.ts:106 already gated on `via === 'sole'` for exactly this reason.

The manifest hint was discarded. backfill records the true pairing, so the
answer was on disk, but resolveColumnLifecycle dropped a hint that failed to
resolve and re-picked without it — reaching the sole rule. Recording the
pairing changed nothing. Split the two reasons a hint fails: a column that is
GONE is stale and still falls through to convention; a column that EXISTS but
is not EQL v3 (the legacy eql_v2_encrypted case) is reported by name.

drop's remedy prescribed the guess — `--encrypted-column <guess>`. Recording it
makes the next resolution `via: 'hint'`, which walks past the gate, and the
coverage check passes vacuously because an unrelated backfilled column is
non-NULL on every row. The message was the instruction manual for generating a
live DROP COLUMN on the plaintext at exit 0.

Tested at the layer that had none: resolve-eql.test.ts covered only
explainUnresolved, and encrypt-v3.test.ts stubs resolveColumnLifecycle outright,
so neither could see the hint discard. The new tests keep pickEncryptedColumn
real and replace only the two I/O boundaries.
Both placeholder templates emitted `await Encryption({ schemas: [] })`. An
empty schema set is a hard TS2769 against both overloads — deliberately, per
S-6 — so every `stash init` left a project failing its first tsc, in the one
file the CLI tells the user not to hand-edit. The old scaffold called
EncryptionV3, whose `readonly AnyV3Table[]` bound accepted `[]`; collapsing it
into an alias of Encryption tightened that away.

Relaxing the constraint is not an option (it exists to catch a real mistake),
and `stash init` has no table names in scope by design — it stopped
introspecting, and `build-schema.ts` sets `schemas: []` on its own state. So
the scaffold declares a sentinel table instead, which keeps the file compiling
and keeps the "you haven't declared anything yet" signal: loadEncryptConfig
exits 1 when `__stash_placeholder__` is the only table left, naming the file.

The gap that let this ship is the more important half. packages/cli has no
typecheck step (21 pre-existing errors), utils-codegen*.test.ts only
`toContain`-matches fragments, and build-schema.test.ts mocks
generatePlaceholderClient to '// placeholder' — so nothing anywhere compiled,
parsed or executed the generated output. Both templates are now committed as
`.generated.ts` fixtures compiled by a scoped tsconfig in CI, and pinned
byte-for-byte to the generator by a unit test. Verified the gate reproduces the
original TS2769 when the fixture is reverted.

The `.generated.ts` suffix is load-bearing: biome.json already excludes it, so
formatting cannot rewrite template output and break the byte comparison.
@tobyhede
tobyhede requested a review from a team as a code owner July 25, 2026 04:34
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7fc55b6

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

This PR includes changesets to release 11 packages
Name Type
stash Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/prisma-next Patch
@cipherstash/wizard Patch
@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

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22029e47-6624-4858-89de-65627550909d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cli-lifecycle-and-scaffold

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.

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

Request changes — one blocking regression; the rest of the PR is strong.

The init-scaffold half (finding 6) is excellent: committing the generated output as byte-for-byte-pinned .generated.ts fixtures with tsconfig.scaffold.json and a typecheck:scaffold CI gate is exactly the right systemic fix for "nothing ever compiled the generated output." The cutover via:'sole' guard (finding 7) correctly mirrors drop.

Blocking — pure single-column EQL v2 tables backfilled with this release are now refused by cutover/drop with misleading guidance.

The unresolvedHint branch in resolveColumnLifecycle (resolve-eql.ts) has no candidates.length > 0 guard, and explainUnresolved checks unresolvedHint before the empty-candidates return null. Traced end-to-end against the branch:

  1. backfill.ts records encryptedColumn in .cipherstash/migrations.json unconditionally, v2 included (lines 138-139, 205-213).
  2. resolveColumnLifecycle reads that as hint. For a pure-v2 table, listEncryptedColumns returns [] (classifyEqlDomain recognizes eql_v3_* only), so candidates is empty and hinted is null.
  3. hint && columnExists(hint) is true (the eql_v2_encrypted column exists) → returns { info: null, candidates: [], unresolvedHint }.
  4. explainUnresolved returns the "…legacy eql_v2 column, which this command no longer manages … use a stash release that still supports it" message → cutover/drop exit 1.

Pre-PR this fell through to the v2 rename/config machinery — which is still fully present in cutover.ts/drop.ts in this same release. So the message tells users to downgrade for a lifecycle this build still implements, and the changeset's "Pure-v2 … unaffected" is inaccurate for the backfill-with-this-release path.

The finding-7 protection is aimed at mixed tables (a v2 pair + an unrelated v3 column), which always have candidates. Gating the fail-closed on candidates.length > 0 preserves that protection exactly while restoring the safe pure-v2 fall-through. Please pick one:

  • Preferred: gate the columnExists/explainUnresolved branch on candidates.length > 0; empty candidates → fall through to the v2 ladder as before.
  • Or, if the intent is genuinely to drop v2 lifecycle from stash encrypt (this is on the remove-v2 train, so that's plausible): do it coherently — remove the now-dead v2 code in cutover.ts/drop.ts/backfill.ts, fix the message (don't say "use an older release" while shipping the code), and correct the changeset.
  • Either way, add a test for the untested shape: candidates: [] + a recorded hint. The existing resolveColumnLifecycle tests only cover candidates: [V3_OTHER], which is why this slipped through.

Non-blocking:

  • The placeholder-client guard lives in loadEncryptConfig, hit only by stash db push/validate. stash encrypt backfill (via loadEncryptionContext/requireTable), cutover, and drop aren't gated, so they still emit the "table not found" error the changeset/skill say is eliminated. Either extend the guard or soften the wording.
  • resolve-eql.test.ts spreads await importOriginal<@cipherstash/migrate>(), so it fails on a clean checkout until @cipherstash/migrate is built (CI is green only because turbo runs ^build first). packages/cli/AGENTS.md wants the unit config self-contained — prefer a full explicit mock.
  • packages/cli/package.json re-encodes em-dashes to in description / the //optionalDependencies comment — unrelated editor noise; revert to keep the diff focused.

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.

2 participants