fix(cli): make EQL reinstall dependency-safe - #961
Conversation
🦋 Changeset detectedLatest commit: 3867928 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
8d6ada1 to
b723daa
Compare
b723daa to
f98b028
Compare
freshtonic
left a comment
There was a problem hiding this comment.
Requesting changes on one blocker; the core engineering is genuinely good and the adversarial checks below came back clean. But this PR's safety story for a destructive customer-database path rests on live suites that CI silently skips — and the PR body asserts the opposite.
Blocker: the new live suites run in no CI job
I verified both halves directly:
- This PR's own Node 22 job log shows
reinstall.live.test.ts (16 tests | 16 skipped)andupgrade-encrypted-indexes.live.test.ts (1 test | 1 skipped)— the refusal matrix, partitioned trees, lock behaviour, rollback-after-DROP, and the credentialed 3.0.2→current upgrade have run only on the author's machine. - Mechanism: the workflow step sets
STASH_TEST_DATABASE_URL, butpnpm run testgoes through turbo, and turbo runs tasks in strict env mode —turbo.json'stesttask declaresenv: ["STASH_POSTHOG_KEY"]only, noglobalEnv/passThroughEnv, so the variable is stripped before vitest sees it anddescribeLivefalls todescribe.skip. (Corroboration:packages/stack's live suite that reads credentials from a dotenv file did run in the same step — dotenv is immune to env filtering; everyprocess.env.STASH_TEST_DATABASE_URL-gated suite skipped.)
The wiring gap is pre-existing (the same suites skip on main), but this is the PR that ships DROP SCHEMA ... CASCADE orchestration against customer databases with these suites as its only proof, extends the tests.yml comment claiming they run, and states in the body that "its complete execution is owned by the existing credentialed CI job". Fix is small: add STASH_TEST_DATABASE_URL to the test task's env (or passThroughEnv), then confirm from the job log that the suites execute — this also revives the pre-existing verify.live / applied.live / preflight.live / guarded-grants.live suites, whose silent skipping is exactly the "phantom damage on green CI" the workflow comment warns about. They'll add real minutes ×3 matrix legs (~15 installs at 10–30s each); gating the live suites to one leg is a reasonable trade to make deliberately.
Also fix before merge (small)
- PR body staleness: the review guide says "start with
packages/cli/src/installer/reinstall.ts" (no such file — it'sderived-search-index-restoration.ts); the commit-structure description doesn't match the actual two commits; and "disables JIT on its dedicated connection" — the code isSET LOCAL jit = off, transaction-scoped (stricter than the body claims, but say what it does). - Stale lock-budget comment:
reinstall.live.test.tssays "the production default is deliberately a minute";LOCK_WAIT_MSis 300 000 and the changeset correctly says five minutes.
Please split out: the agent-workflow scaffolding
CONTEXT-MAP.md, docs/agents/domain.md / issue-tracker.md / triage-labels.md (wayfinding labels, sub-issue protocols), and the CLAUDE.md conventions section are new repo-wide conventions with no connection to EQL reinstall, introduced inside a CLI bugfix — including docs/adr/ as a new convention mandated by a doc that arrives in the same diff (repo precedent is docs/plans/). If the team wants these, they deserve their own PR and a deliberate decision, not convention-by-fait-accompli. (packages/eql/CONTEXT.md's glossary genuinely supports this fix and can stay; the biome.json schema-string bump and the stack-supabase test existsSync hardening are harmless smuggles — note or drop.)
Comments, no change demanded
- Cross-version refusal risk (plausible): the operator/cast ownership exemption is derived from the pinned bundle, but at upgrade time the database carries the old bundle's operators. A future release that renames a public operator would make upgrade classify the old bundle's own object as customer-owned and refuse a healthy database — fail-closed, so safe, but the message would blame the customer. The 3.0.2-baseline live test is exactly the guard for this, which loops back to the blocker: it currently runs nowhere.
- Local-dev collection coupling:
upgrade-encrypted-indexes.live.test.tsimports@cipherstash/protect-ffiat module top, so even self-skipped, an unbuilt protect-ffi failspnpm --filter stash testcollection locally — same class of couplingpackages/cli/AGENTS.mdalready documents. - The Test EQL sqlx matrix legs (which execute the modified
v3_uninstall_tests.rs) were still queuing at review time — confirm they finish green.
Adversarial checks that came back clean
SQL generation is injection-clean (every executed identifier is server-quoted via format('%I…') or verbatim pg_get_indexdef(); lock key and exemption lists are bind parameters). The advisory lock is transaction-scoped so it releases on every error path including connection death, the polling design avoids both the infinite-hang and lock-queue-deadlock classes, and the bounded-wait/queued trade is tested in both directions. The pg_depend classifier is genuinely fail-closed — matviews, extended statistics, publications, exclusion constraints, generated columns, unknown classes all refuse; the rewrite-rule→view hop is correctly scoped; partitioned parents and attached children both refuse; FK-referencing-expression-index is not a real gap in Postgres. Rebuild fidelity captures and verifies on one session/search_path with opclasses, INCLUDE, storage params, WHERE, UNIQUE and tablespace riding the server-rendered definition, plus clustered/replident/comment restoration. The one-DDL-transaction claim is real and the non-CONCURRENT lock consequence is honestly documented everywhere it matters (changeset, ADR, README, skill), as is the capture→drop TOCTOU window. No command/flag changes, so the manifest check holds; changeset is present, right package, right level, accurate.
Fix the turbo env wiring, show one CI log line of the suites actually executing, tidy the body, and this is an enthusiastic approve.
cipherstash-bot
left a comment
There was a problem hiding this comment.
Test-coverage review
The reinstall/restoration machinery is well covered on its happy paths (installer.test.ts drives the full capture→replace→reconstruct→verify sequence, and the live suites exercise partitioned-tree refusal, lock timeout, and DROP-SCHEMA rollback). Three negative/branch paths in the new code have no unit or live coverage — each is a failure the code explicitly claims to catch, so each deserves a test that watches it fire.
Inline comments below (3). Nothing crypto/security-related flagged.
The RecordingRestorationDatabase helper (__tests__/restoration-scenarios.ts) currently mirrors captured rows back on verify and only emits well-formed index rows on capture, which is why gaps 1 and 2 exist — both need a small option added to that harness to inject a divergent/malformed row.
Review notesReviewed Two things I'd want addressed before merge, then some smaller ones. 1. The refusal path is unhandled at the call site — medium
s.start('Installing EQL v3 extensions (pinned bundle)...')
await installer.install({ supabase })
Concrete case: a Supabase user with an RLS policy calling 2.
|
f98b028 to
544bebf
Compare
cipherstash-bot
left a comment
There was a problem hiding this comment.
Coverage review: the new safe-reinstall tests cover the happy path, unsafe dependency refusal, lock waiting, and reconstruction failure. I found two remaining branch gaps worth adding around refusal on malformed captured index metadata and rollback when post-rebuild verification rejects the rebuilt catalog shape.
d18eb2a to
9280fce
Compare
9280fce to
3c065d2
Compare
3c065d2 to
a9c9ded
Compare
a9c9ded to
3867928
Compare
freshtonic
left a comment
There was a problem hiding this comment.
Approving at 3867928. Every item from my previous review is addressed — I verified the blocker resolution first-hand, and the rest against the head diff and CI logs.
The blocker is properly dead. turbo.json's test task now declares env: ["STASH_TEST_DATABASE_URL"], and the head CI logs show the live suites executing on both matrix legs — reinstall.live.test.ts (20 tests) ✓ on Node 22/PG16 and Node 24/PG17, plus the released-baseline upgrade test, and the revival of the previously-dead verify.live (5), applied.live (10), preflight.live (3) and guarded-grants.live (2) suites. The new meta-test (scripts/__tests__/cli-live-postgres-ci.test.mjs) pinning the turbo env, workflow env, matrix and singleFork contract is exactly the right move — this failure mode is now regression-proof rather than merely fixed. The PG16/PG17 matrix split exercising both attstattarget representations is a thoughtful touch.
The cross-version concern got a better fix than I asked for. Instead of patching the bundle-derived exemption, the classifier now reads the installed catalog — an operator or cast implemented by an eql_v3/eql_v3_internal function is EQL-owned regardless of what the incoming bundle declares — which kills the healthy-database-refused-on-upgrade case, the lower() seq-scan hazard, and the bind-parameter plumbing in one stroke, with live tests covering the legacy-cast and customer-duplicate-signature directions plus a real released-baseline → head upgrade round-tripping genuine ciphertext. One narrow trade to be aware of (fine by me, worth a sentence in the changeset if you're in there): a customer-created public operator implemented by an EQL-schema function is now classified EQL-owned and dropped without refusal — defensible, since such an object cannot survive the schema drop anyway, and a customer operator in their own schema still refuses.
Everything else from the list: scaffolding gone (now #962, as asked); body's review guide and the jit description corrected; the five-minute lock comment fixed; the protect-ffi import is type-only with a dynamic beforeAll import. The delta's new scope (replica identity, index comments, per-column statistics targets, owner verification, typed lock-timeout and refusal errors with clean CLI rendering) stays injection-clean — all server-side format('%I'/'%L') — fail-closed on malformed catalog metadata, inside the same single transaction under the advisory lock, with rollback tests for each verification failure. Changeset correctly re-leveled to minor and accurate; skills/stash-cli in step.
Non-blocking nits for a follow-up or pre-merge tidy: the body's "Commit structure" section is stale again (names two commits that don't exist on an 11-commit branch — if it describes intended squash structure, say so); EqlLifecycleLockTimeoutError isn't special-cased in install.ts/upgrade.ts so a lock timeout renders via the crash path (message is actionable, presentation only); packages/cli/README.md:484 still types getInstalledVersion() as Promise<string | null> though it now returns 'unknown' for installed-but-unversioned (the skill documents the tristate correctly — per our convention, README fixes can ride a follow-up); and AGENTS.md's "one exemption today" count for the EQL-pins linter is now off by one.
This is a substantially stronger PR than the one I first reviewed. Nice work.
Summary
EQL upgrades replace
eql_v3andeql_v3_internalwithDROP SCHEMA ... CASCADE. Encrypted columns and rows survive, but customer-created objects that depend on EQL machinery may not. This PR makesstash eql installandstash eql upgradepreserve reconstructable search indexes and refuse before mutation when a dependency cannot be restored safely.Changes
Dependency-safe reinstall
The advisory lock coordinates
stashlifecycle commands, not arbitrary application DDL, so a maintenance window is still required.Assessment and command behavior
Live verification
STASH_TEST_DATABASE_URLthrough Turbo's strict environment contract.SET LOCAL jit = offapplies only within the lifecycle transaction.Verification
code:checkpass.Scope
The agent-workflow and domain-documentation conventions are extracted to base
PR #962. This PR records the durable safe-reinstall decision in
docs/adr/0001-eql-data-survives-disposable-schema-reinstall.md; the obsoleteSuperpowers specification is not part of the stack.
Commit structure
fix(cli): make EQL reinstall dependency-safetest(cli): upgrade a released EQL bundle with real ciphertextrefactor(cli): deepen EQL index restorationrefactor(cli): deepen verified EQL bundlerefactor(cli): deepen installer test scenariosrefactor(cli): deepen EQL installation assessmentrefactor(cli): deepen EQL surface verificationrefactor(cli): deepen Supabase EQL access policyrefactor(cli): deepen declared schema validationfix(cli): preserve EQL assessment and index statefix(cli): address safe reinstall review feedbackRelated
Closes #918
Closes #959
Review guide
Start with
packages/cli/src/installer/derived-search-index-restoration.ts.LIFECYCLE_DEPENDENCIES_SQLdefines the safety boundary and the module owns capture, replacement, reconstruction, and verification ordering.Then review
packages/cli/src/installer/installation-state.tsfor assessment semantics andturbo.jsonpluspackages/cli/vitest.config.tsfor the live-test execution contract.