feat: apply desired schemas through migrate - #126
Conversation
Signed-off-by: Armand Parajon <armand@squareup.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Destructive previews emit inconsistent plan metadata, and RLS refusal output conflicts with the established verdict contract.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 3
Open (5)
Add tests for migrate and schema command validation · New Use refusal helper and recompute fingerprint after mutations · New Avoid assigning failure codes to refused outcomes · New Align exit-code documentation with semantic error handling · New Track required fixture image updates in a separate PR · New
What changed in this PR
Adds CLI support for applying desired-state schemas, including atomic RLS changes, through migrate --desired.
Changes:
- Adds desired-schema CLI validation, planning, execution, and result rendering.
- Routes explicit RLS declarations through the atomic executor.
- Adds integration coverage, demo fixtures, and capability documentation.
| File | Description |
|---|---|
README.md |
Documents CLI-based RLS execution. |
pkg/capabilities/capabilities.yaml |
Marks declarative RLS migration as supported. |
internal/cli/migrate.go |
Dispatches desired-state requests. |
internal/cli/migrate_row_security.go |
Connects RLS declarations to the atomic executor. |
internal/cli/migrate_row_security_integration_test.go |
Tests RLS failures and policy removal. |
internal/cli/migrate_desired.go |
Implements desired-state migration and dry runs. |
internal/cli/migrate_desired_test.go |
Tests validation and RLS verdict handling. |
internal/cli/migrate_desired_integration_test.go |
Tests table and RLS convergence paths. |
internal/cli/cli.go |
Adds --desired and --schema flags. |
docs/supabase.md |
Adds the Supabase RLS application workflow. |
docs/limitations.md |
Updates RLS limitations. |
docs/declarative-row-security.md |
Documents execution behavior and output. |
docs/capabilities.md |
Updates the rendered capability matrix. |
docs/atomic-row-security.md |
Documents CLI availability. |
demo/tour.sh |
Adds built-binary RLS smoke coverage. |
demo/seed.sql |
Adds the demo RLS table. |
demo/desired-rls.sql |
Defines the demo’s desired RLS state. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Signed-off-by: Armand Parajon <armand@squareup.com>
|
🤖 Review findings - created by Kiran's code review agent - for pg-sprite/pull/126, f38f1d3. Verdict: 5 findings — 1 blocking (RLS privilege refusal misclassified), 4 non-blocking (refusal registry, dry-run class, stale docs). BlockingRLS privilege refusals are reported as Non-blockingThe two new RLS refusal sites pick their class in
The docs update missed the non-RLS text: README and three other docs still say desired-state execution has no CLI verb. The one thing that could have broken, verified
Verified correct
This review was generated by Claude Code (claude-opus-5). |
morgo
left a comment
There was a problem hiding this comment.
🤖 Automated adversarial review, posted on Morgan Tocker's behalf.
The wiring is careful and most of what I went looking for is already right. Specifically checked:
- Routing on parse failure is sound, not fragile as it first looks.
runDesiredonly reaches the RLS executor whenParseDesirederrors, which reads like it could misroute — but it cannot.ParseDesired'sadmitDesiredStatementadmitsCreateStmtandIndexStmtonly, so any RLS declaration makes it fail; a file carrying RLS can never silently take the ordinary path. And a genuine syntax error does not get swallowed, becauseHasRowSecurityDeclarationre-parses withpgquery.Parseand returnsdetectErr, so the originalParseDesirederror is what surfaces. Table-level diagnoses survive the reroute too:ParseDesiredWithRowSecurityre-runsParseDesiredover theCreateStmt/IndexStmtsubset, so aREFERENCESclause still reportsErrForeignKeyrather than an RLS-flavoured error. - Re-fingerprinting in
RefuseDestructiveis correct and matches the convention. The other two mutators (RefuseUnsupportedPartitionedParent,RefuseUnsupportedCreateShape) do not rehash, which initially looked inconsistent — but they run insidediffplan.Plan, which setsreport.Fingerprintas its last step.RefuseDestructiveruns afterPlanreturns, so it must rehash, and does.Backend,DispositionandExecSQLare all fingerprint inputs, so skipping it would have left a stale identity. - The load-bearing claim in the dry-run comment holds. "which RunDesired refuses before execution" is true:
admitPlanwalksreport.Statementsand refuses on the firstps.Destructive.migrate --desiredcannot execute a destructive step. - Exit codes are preserved rather than reinvented —
writeDiffReportreturnsverdict.ErrRefusedon a refused plan, so--desired --dry-rungates in CI the same way--alter --dry-rundoes.
One finding, and three notes.
The destructive preview disagrees with the admission it says it mirrors
// Preview uses desired-state admission: a routed native step can still
// discard live structure, which RunDesired refuses before execution.
plan.RefuseDestructive(&report)RefuseDestructive refuses per statement:
if report.Statements[i].Destructive {
return verdict.ByDesign(verdict.ReasonDestructiveChange), ""
}
return verdict.Refusal{}, ""admitPlan refuses the whole plan — it returns at the first destructive statement and nothing runs. Its own skippedRestDetail says so out loud: "admission is all-or-nothing, so the plan's other statement, even if non-destructive, was not run".
So the preview and the execution describe different outcomes for the same file.
Failure scenario. A desired file drops a column and adds an index. migrate --desired --dry-run --json renders the drop with disposition: refuse and the index with disposition: execute and its exec_sql intact. The top-level disposition is refuse and the exit code is 2, so a CI gate is fine — but an operator reading the statement list concludes the index build will proceed and the drop will be skipped. They run without --dry-run and get a whole-plan refusal with zero statements executed and the index not created.
The direction is the safe one (the preview over-promises; execution under-delivers), which is why I am not calling this data loss. But it is still a preview that discloses exec_sql for a statement that cannot run, and this codebase's whole refuseStatements mechanism — "withdraws every piece of execution advice" — exists to prevent exactly that.
This is pinned by a test rather than incidental, so it needs a deliberate answer rather than a one-line patch. TestRefuseDestructiveWithdrawsExecutionAndRehashes asserts assert.Equal(t, untouched, report.Statements[1]), i.e. that the non-destructive sibling keeps its execute disposition. And the integration coverage does not reach the case: TestMigrateDesiredTableRefusesDestructiveChange asserts require.Len(t, preview.Statements, 1), so no test compares a mixed plan's preview against what executing it actually does.
Matching admitPlan means refusing every statement once any is destructive — a first pass to detect, then a selector that returns the refusal for all indices — and the sibling would carry the by-design destructive reason as the explanation for why it, too, will not run. If per-statement marking is instead the intent, then the comment should not say the preview uses desired-state admission, and the report needs to say somewhere that admission is all-or-nothing, or the operator has no way to learn it before running.
Notes
An RLS file without its CREATE TABLE reports "empty desired schema". ParseDesiredWithRowSecurity builds tableSQL from CreateStmt/IndexStmt nodes only, then calls ParseDesired on it; a policies-and-settings-only file yields an empty string and ErrEmptyDesired. The docs say to apply the file pull produced, which always carries the table, so this is not on the documented path — but "apply an RLS-only declaration to an existing supported table" reads like an invitation to hand-write exactly that file, and empty desired schema is a misleading answer to it. A dedicated error naming the missing CREATE TABLE would cost one branch.
runDesiredRowSecurity parses, then discards the result on the dry-run path. ParseDesiredWithRowSecurity runs before the c.DryRun check, and runRowSecurityDiff(ctx, out, sql) re-parses the same string. It works as an admission check, but a reader will wonder whether desired was meant to be threaded through. A comment saying the first parse is there to fail admission before any preview, or moving it under the non-dry-run branch and letting the diff path report its own parse error, would settle it.
Inconsistent DiffCmd construction. The RLS path passes Schema: c.Schema; the ordinary dry-run path omits it. writeDiffReport does not read Schema, so nothing is broken today — but the two lines sit ten lines apart and differ, which invites the reader to hunt for the significance. Worth making them identical.
Signed-off-by: Armand Parajon <armand@squareup.com>
|
🤖 Addressed all five findings from this review in d099e71.
Real PostgreSQL CLI tests cover a non-owner with database CREATE and an owner without it, asserting the refusal classification and unchanged catalog. The SQLSTATE cause, preview class, and registry mappings also have regression coverage. Focused integration tests, unit/safety tests, race checks, lint, and the built-binary demo passed. CI is running on the new commit. Generated with Codex (GPT-6) |
|
🤖 Addressed the destructive-preview finding and all three notes in d099e71.
A settings/policies-only file now names the missing Focused integration tests, unit/safety tests, race checks, lint, and the built-binary demo passed. CI is running on the new commit. Generated with Codex (GPT-6) |
|
🤖 Review findings - created by Kiran's code review agent - for pg-sprite/pull/126, d099e71. Re-review of the delta only: Verdict: 4 findings — 3 non-blocking (preview/apply refusal drift, GRANT contract), 1 suggestion. Non-blocking
General suggestions
The one thing that could have broken, verifiedSentinel ordering in Verified correct
This review was generated by Claude Code (claude-opus-5). |
Signed-off-by: Armand Parajon <armand@squareup.com>
|
🤖 Addressed all four points from the re-review in c4e94a7.
Focused PostgreSQL tests, unit/safety checks, race checks, lint, and the built-binary smoke tour passed. The new commit is running through CI; the preceding commit was all green. Generated with Codex (GPT-6) |


Why
The desired-state library and atomic row level security (RLS) executor already exist, but CLI users cannot apply those declarations. This connects them to
migrate --desired schema.sql.What
--desiredand its target--schema, mutually exclusive with--alter; reject force and blocking overrides for desired filesRunDesiredand explicit RLS declarations through the atomic executorHow
Ordinary changes retain the existing plan and per-statement verdicts. RLS changes return one verdict with only committed SQL. The executor derives the change under its lock and verifies convergence before commit; mixed table/RLS changes remain refused.
--dry-runnever applies changes. If any table-only step is destructive, the entire preview is refused, all execution advice is withdrawn, and the final preview is fingerprinted. The aggregate refusal takes the same precedence as apply, even if routing already refused a step. RLS previews retain the existing review-only response and exit 2 when definitions differ, even when an explicit apply can execute the change. No approval fingerprints or RLS-specific flags are introduced.Risk
This exposes existing executors through a new CLI input mode. Executor changes only preserve typed privilege causes; execution and locking behavior stay the same. RLS declarations own the complete policy set and can deliberately widen access. Failures preserve executor codes, including an unknown commit outcome. Registry-backed refusals distinguish missing privileges from unsupported changes. A missing RLS table is environmental in both preview and apply. Hosted Supabase support is not established by these tests.
Testing
No manual testing. Automated coverage includes PostgreSQL command integration tests and the built-binary smoke tour in CI.
Bigger picture
Follows #123 and #125: atomic RLS execution and local Supabase API validation now have a CLI workflow. Ordinary desired changes keep committed-prefix semantics; RLS changes commit together. Hosted validation remains a follow-up.
Generated with Codex (GPT-6)