feat(plan): surface create-path shape refusals at plan time - #70
Conversation
A desired file the create path would refuse at admission (PARTITION OF, INHERITS, LIKE, OF, IF NOT EXISTS, CONCURRENTLY, duplicate claimed names) now shows the refusal on the plan the reviewer reads, instead of first appearing when the apply is attempted.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
The plan report has no per-statement cause field and the shape check is pure over the desired schema, so RunDesired's refusal detail and the text diff recompute it instead of falling back to "no safe path". A shape-refused step now still registers its claimed names, so a later statement colliding with it is reported as the collision it is; the --sql script comments out refused statements and the greenfield note no longer promises a create the refusal beneath it withdraws. The two plan refusal helpers share one implementation, and the positional alignment between refusals and statements fails closed on a length mismatch.
aparajon
left a comment
There was a problem hiding this comment.
🤖 Adversarial correctness review, requested by Armand and performed by his agent. Reviewed at head 5a4cf8b3.
Verdict: the central claim holds — plan and apply cannot disagree, because they are the same function. admitCreateSteps and CreateShapeRefusals both reduce to checkCreateSteps, and the positional contract between the plan report and the desired set is real end to end: qualifiedDesired emits one Change per desired statement in order, classifyChanges indexes routed.Statements[i] by that same i, and RefuseUnsupportedCreateShape refuses to mark anything at all if the two lengths ever disagree. I mutation-tested the three properties the summary leans on and all three are pinned (below). Three findings, none of them a hole in the refusal itself.
Findings
1. diff --sql comments only the first line of a refused statement. writeChangeText's new branch emits -- %s; with the whole statement interpolated, so a refused statement containing a newline leaves everything after the first line uncommented in a script whose entire purpose is to be pasted. statement.Canonical renders single-line for ordinary DDL — I checked CREATE TABLE, PARTITION OF, and IF NOT EXISTS, all one line — but it preserves a newline inside a string literal, which is enough. Proved with ... FOR VALUES IN ('a\nb') (real newline):
-- refused — the engine will not run it
-- CREATE TABLE public.events PARTITION OF public.parent FOR VALUES IN ('a
b');
The tail line b'); is live text in the script. In practice it is a syntax error rather than an executed refusal, so it fails loudly — but the function's own comment sets the bar at "must never carry a statement the engine refuses where a reader could run it by accident", and one line of prefixing every line meets it.
2. The refusal-precedence change is real, observable, and undocumented. RunDesired calls admitPlan before runCreate, so a shape refusal is now decided ahead of both the absence check and the privilege check. A greenfield desired file that both states PARTITION OF and runs as a role without CREATE on the schema reported insufficient-privileges before this PR and reports unsupported-statement after it. That is arguably the better order — it needs no connection and it is the author's error — but it moves an outcome between two of the routing classes docs/schemabot-integration.md explicitly tells adapters not to fold together: one is an operator provisioning action, the other is an author fix, and an adapter keying retry policy on the reason changes behavior on bump. docs/plan-report.md already documents apply-path precedence ("table size, then partition…"); the greenfield ordering deserves the same sentence, the way #71 documented its own privilege-vs-collision reordering.
3. The operator-facing detail path is looser than the display path about positional alignment. greenfieldRefusalCauses (CLI) discards the recomputed slice unless len(causes) == len(report.Statements); createShapeCause (migrate) only checks i >= len(refused), so a shorter-but-nonempty slice would let statement i's detail carry another statement's cause. Both are unreachable today — each recomputes from the same DesiredSchema the plan was derived from — but the strict guard is on the throwaway rendering and the loose one is on the durable refusal detail a consumer stores, which is backwards. Same equality check in both makes them agree.
Nit: refuseStatements stamps reason unconditionally, so a greenfield statement the planner had already refused for a more specific reason has it overwritten with unsupported-statement. Harmless while greenfield statements are only CREATE TABLE/CREATE INDEX, but the policy is currently "last mutator wins" rather than "most specific wins", and nothing says so.
Action items
- (Finding 1) Prefix every line of
ps.SQLin the refuse branch (strings.ReplaceAll(ps.SQL, "\n", "\n-- ")), and cover a literal containing a newline indiff_text_test.go. - (Finding 2) State the greenfield precedence where the routing classes are defined (
docs/schemabot-integration.md, and the precedence paragraph indocs/plan-report.md): a decidable shape refusal precedes the absence and privilege checks because it needs no connection. - (Finding 3) Give
createShapeCausethe samelen(refused) != len(report.Statements)guard its CLI twin uses.
Verified (tried to break, couldn't)
Mutation-tested three documented properties, all caught: making a shape-refused step skip claim registration fails TestCreateShapeRefusals/duplicate_of_refused_step; relaxing RefuseUnsupportedCreateShape's length guard to the lenient i < len(refused) form fails TestRefuseUnsupportedCreateShapeRejectsLengthMismatch; deleting the --sql refuse branch so the refused SQL renders uncommented fails the CLI suite. The positional contract is 1:1 at every hop and a mismatch fails the whole plan rather than mismarking it. ST-7's binding to the absence proof survives the signature change from AbsentTarget to (schema, table): executeCreate still rejects ds.Table() != at.Table() and cr.Schema() != at.Schema() before admitCreateSteps runs, so the raw strings on the check path are the proof's own values — and CreateShapeRefusals is pure with no pool, so a fabricated schema can only produce a wrong ST-7 error, never a passing safety check. In the duplicate-name loop a step's non-colliding claims are still registered (the continue is inside the taken branch only), and an already-refused step keeps its shape cause instead of being relabelled a duplicate, as documented. ST-8 is asserted in both qualifiedDesired and checkCreateSteps. Refusing withdraws Backend, ExecSQL, Execution, and every SaferSQL/SaferSQLExecution through the one shared mutator, so the fingerprint of a refused plan cannot hash as executable. The reason vocabulary is unchanged, so verdict.Reasons() and the docs test still pin it; the four doc surfaces and the README all describe plan-time refusal plus apply re-check consistently. diffplan (periphery) importing executor (core) is the allowed direction. Build clean; ./pkg/plan, ./pkg/executor, ./pkg/diffplan, ./pkg/migrate, ./internal/cli all pass locally at head; CI green on PostgreSQL 15/16/17/18.
This review was generated by Claude Code (claude-opus-5).
aparajon
left a comment
There was a problem hiding this comment.
🤖 Second pass on the same head (5a4cf8b3): what an outside adopter experiences, and what SchemaBot does with the new verdict.
Lens 1 — outside adopter
Moving the shape refusal to plan time is the right call, and the rewritten greenfield note ("the plan is the full desired schema, and a statement in it is refused, so nothing would be created") is exactly the sentence a reviewer needs — the old text promised a create the refusal underneath it withdrew.
The gap is that the cause exists in three places and none of them is the machine-readable contract. The human text diff prints it, migrate.RunDesired's detail prints it, and both recompute it — but --json and --sql carry only disposition: refuse / reason: unsupported-statement, so anything that consumes pg-sprite programmatically can say refused and never why. The summary is right that a per-statement cause field is a format_version bump, and the recomputation is a clean interim, but three independent recomputation sites (two in this PR, one in every consumer that wants the text) is the smell that field would remove. Worth scheduling rather than leaving as the shape of the API.
Smaller: diff --sql annotates the statement as refused — the engine will not run it with no cause at all, even though the default text view has one. A reviewer reading the script gets less than a reviewer reading the diff.
Lens 2 — SchemaBot integration
I traced this through the consumer. pkg/engine/postgres/postgres.go executionVerdict already maps router.DispositionRefuse to ExecutionModeBlocked with "statement for table %q is refused: it cannot be executed safely as written". So on bump this PR converts a class of PostgreSQL greenfield failures from an apply-time failure into a check blocked at plan time — no SchemaBot change required, and it closes the greenfield gap I flagged on schemabot#1209, where the plan comment told an operator to apply a change the create path would then refuse. That is a meaningful safety improvement on the SchemaBot side and it lands for free.
The follow-on is the JSON gap above. Because the cause never crosses the contract, SchemaBot renders its own generic sentence and the operator sees a blocked statement without knowing that the fix is "your desired file says PARTITION OF". SchemaBot deliberately does not copy planner explanations into operator-facing text, and rightly so for anything derived from server output — but this cause is pg-sprite's own closed sentence about pg-sprite's own rules, which is the same category schemabot#1242 concluded is safe to render (a reason assembled from our sentences, never provider text). So the cause field has a concrete consumer waiting for it: with it, the PR comment can name the offending clause; without it, every consumer either shows a generic sentence or re-implements CreateShapeRefusals against the desired file.
No blockers here — the wire contract is unchanged and the new verdict is already handled correctly downstream.
This review was generated by Claude Code (claude-opus-5).
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving — the shared-implementation claim holds (plan and apply are the same function, and the positional contract fails closed rather than mismarking), and my three findings are follow-ups: a one-line fix to the --sql comment for multi-line SQL, a precedence sentence owed to the docs, and one alignment guard to match its twin. Details in the two review comments above.
This review was generated by Claude Code (claude-opus-5).
A refused statement containing a newline left live text in the --sql script; the script now names the create path's cause and prefixes every line. Refusal mutators keep an existing reason (first refusal wins) and the durable refusal detail uses the same exact-length guard as the CLI. Greenfield refusal precedence is stated in the contract docs.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.5) — pull/70, follow-up commit Source: review comment 5103671043 (adversarial pass), 5103671796 (second pass), 5103672431 (approval) at head
Verified: |
…me-create-refusals * origin/main: feat(executor): prove claimed relation names free before the create runs (#71) feat(progress): report the statement each step is executing (#72) feat(plan): disclose greenfield steps as plain executable statements (#69) # Conflicts: # docs/capabilities.md # docs/limitations.md # docs/schemabot-integration.md # internal/cli/diff_text_test.go # pkg/diffplan/diffplan.go # pkg/executor/create.go # pkg/migrate/desired_integration_test.go # pkg/plan/plan.go # pkg/plan/plan_test.go
Greenfield plans now carry the create path's shape refusals as per-statement verdicts, so a desired file the executor would refuse is blocked on the plan rather than at apply time.
Why
The create path admits each desired statement by shape before anything runs — refusing
PARTITION OF,INHERITS,LIKE,OF,IF NOT EXISTS, and a set that claims the same relation name twice (CONCURRENTLYandREFERENCESare refused earlier, at desired-file parse). Those checks are connection-free, but they ran only insideExecuteCreate, so the reviewer saw an executable-looking plan and the refusal surfaced later as an apply failure. The plan is where a reviewer decides; the refusal belongs there.What
executor.CreateShapeRefusals(schema, desired)exposes the admission rules positionally (one entry per desired statement, nil when admitted).admitCreateStepsshares the same implementation, so plan and apply cannot disagree. A shape-refused step still registers the names it claims, so a later statement that collides with it is reported as a duplicate rather than admitted.plan.RefuseUnsupportedCreateShapemarks the refused statements withunsupported-statement, sharing one implementation with the partitioned-parent refusal, and fails closed when the refusal list and the plan's statements do not line up. Refusal mutators keep an existing refusal reason — first refusal wins, because the earlier mutator saw the more specific cause.diffplan's greenfield branch calls both; refused statements keep their SQL visible with the verdict attached.migrate.RunDesired's refusal detail and the textdiffrecompute the create path's own cause and name it (is refused by the create path: CREATE TABLE PARTITION OF is not supported …). Both sites require the recomputed causes to line up exactly with the plan's statements. The JSON contract is unchanged.diff --sqlannotates a refused statement with the create path's cause and emits every line of its SQL as a comment, so the copy-pasteable script never carries a statement the engine refuses — even one whose literal contains a newline; the greenfield text note says the plan is refused instead of promising a create.PARTITION OFand runs withoutCREATEon the schema reportsunsupported-statement, notinsufficient-privileges.Before / after