feat(cli): let storage apply converge to a release you name, before it rolls - #1413
Conversation
95191f4 to
10aa468
Compare
28171d0 to
cf405fa
Compare
590ae1f to
fe55852
Compare
28ff40f to
00d0a35
Compare
afe5a8c to
28a2622
Compare
… one answering storage apply took no schema selector: a convergence ran the schema embedded in the binary answering it, so the storage a release needs could not be in place until that release was already deployed. That is backwards for the workflow the command exists for, where an operator applies the next release's storage ahead of the rollout. --release and --schema-dir now work on apply the same way they work on plan, and the files they name are what converges. The schema is resolved once and threaded through the preview, the confirmation, and the convergence, so a moved tag cannot have an operator approve one file set and run another. Two gates sit in front of it. Naming a release is refused with --auto-approve, so a cross-release convergence is never reachable unattended, and the confirmation states the consequence a plan cannot show: until the named release is deployed, every pod that boots the running one converges the difference back, and it does so asymmetrically. A surplus table or column is refused as destructive and survives; a surplus index loses no data, so it is removed without asking. A release read over a plaintext $GITHUB_API_URL is now refused on a convergence rather than warned about. The rewrite that costs a plan its authority costs a convergence the storage database, since whatever DDL arrives is what runs, having parsed exactly as a real schema would. AV-9 is rewritten for this: which schema a convergence runs is the operator's to name. What it still refuses is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four comments and a test assertion still described the retired rule, in the layers underneath rather than in the ones this branch rewrote. "apply is what a boot does" was doing two jobs: an apply runs under the same policy a boot runs under, which is still true and is the only thing the three comments citing it were about, and an apply runs the same schema a boot runs, which it no longer does. Each now names the policy property directly, so none of them rests on half a claim. The converged-plan test asserted the absence of the old hint's wording, which pinned a phrase the tree no longer has anywhere. It asserts the absence of a next step instead, which is what the case is about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts target once Review feedback on the storage schema commands, in three parts. A refused `storage apply --json` printed a human plan on the surface a program parses — or, on the destructive gate, which prints nothing and returns silently, nothing at all. Both gates now stay quiet under `--json` and the refusal comes back as the same response shape a convergence does, both halves carrying the report: nothing ran, so everything the plan found is still outstanding. The storage target is resolved once per run and passed along. Every resolution of a config using `storage.dsn_from` is a fresh read of secret references, so up to three of them meant three audited reads of the storage credential, and a window where a value that changed in between had the convergence run against a database its own preview never looked at. The resolution now sits on the target flags themselves, so the copy an apply hands its preview carries the database it already resolved, and a completeness test over the package pins the single call site. The destructive gate's doc comment had run into the manual gate's, leaving one gate wearing the other's AV-9 rationale and the other with no doc.
00d0a35 to
eadd348
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved critical and moderate findings remain across the API, RPC, source-fetch, CLI, and documentation paths.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 4
Open (6)
Unresolved merge conflict markers remain in operator guide · New Enforce approval for named-schema API convergence · New Reject plaintext redirects during schema release fetches · New Validate schema source and files pairing in adapter · New Validate completeness of PostgreSQL supplied schema sources · New Prevent interactive output from corrupting JSON mode · New
What changed in this PR
Adds named-release schema convergence to storage apply, allowing operators to prepare storage before deployment.
Changes:
- Threads selected schema files through CLI, HTTP, gRPC, and bootstrap paths.
- Adds confirmation gates, source resolution, JSON handling, and target resolution.
- Updates tests, documentation, and AV-9 guidance.
| File | Reviewed changes |
|---|---|
pkg/serve/storage_schema.go |
Applies supplied schemas through RPC; critical (2 votes): source-only requests can fall back to the embedded schema. |
pkg/serve/storage_schema_test.go |
Updates adapter tests. |
pkg/serve/serve_version_test.go |
Updates schema attribution tests. |
pkg/proto/ternv1/tern.pb.go |
Regenerates apply-request fields. |
pkg/proto/tern.proto |
Adds schema fields to the apply RPC. |
pkg/cmd/commands/storage_target.go |
Clarifies target policy behavior. |
pkg/cmd/commands/storage_target_resolution_test.go |
Tests single target resolution. |
pkg/cmd/commands/storage_schema.go |
Implements named convergence and gates; moderate (2 votes): JSON applies can emit human output; moderate (1 vote): direct named-schema application lacks integration coverage. |
pkg/cmd/commands/storage_schema_test.go |
Tests CLI convergence behavior and JSON refusals. |
pkg/cmd/commands/storage_schema_source.go |
Resolves schema sources; critical (2 votes): remote plaintext redirects can bypass the convergence protection. |
pkg/cmd/commands/storage_schema_source_test.go |
Tests source resolution and validation. |
pkg/cmd/commands/storage_schema_render.go |
Adds plan next-step hints. |
pkg/cmd/commands/storage_schema_render_test.go |
Tests plan rendering. |
pkg/apitypes/storage_schema_requests.go |
Adds schema fields to API requests. |
pkg/api/storage_schema.go |
Applies supplied schemas through the bootstrap. |
pkg/api/storage_schema_source.go |
Handles supplied schema files; moderate (2 votes): PostgreSQL completeness validation does not hold for partial sources. |
pkg/api/storage_schema_integration_test.go |
Tests supplied MySQL and PostgreSQL convergence. |
pkg/api/storage_schema_handlers.go |
Validates and forwards apply sources; critical (2 votes): named convergence is not enforced as human-confirmed at the API boundary. |
pkg/api/storage_schema_handlers_test.go |
Tests HTTP request validation. |
pkg/api/ensure_schema.go |
Supports supplied MySQL schema files. |
pkg/api/ensure_schema_postgres.go |
Supports supplied PostgreSQL schema files. |
docs/storage-schema.md |
Documents named convergence; critical (3 votes): unresolved merge conflict; nit (1 vote): inaccurate PostgreSQL safety statement; nit (1 vote): overly broad no-disagreement statement. |
docs/release.md |
Updates release workflow guidance. |
docs/invariants.md |
Rewrites AV-9 enforcement details. |
Files not reviewed (1)
- pkg/proto/ternv1/tern.pb.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
storage apply converge to a release you name, before it rolls
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1413, eadd348. BlockingThe operator runbook ships with unresolved git merge conflict markers. docs/storage-schema.md:361 adds a literal The cross-release notice tells the operator a surplus index is dropped without asking, which the boot path refuses to do. pkg/cmd/commands/storage_schema.go:638 rests its whole rationale on "an index loses no data, so it is removed without asking", but General suggestionsThe notice's only test compares strings against the notice's own source text. pkg/cmd/commands/storage_schema_test.go:701 asserts The one thing that could have broken, verified
Verified correct
This review was generated by Claude Code (claude-opus-5). |
…ose three gaps around it The confirmation's notice predicted that a pre-applied index would be converged away by the deployed release, which is not what a boot does: an index drop is refused with the same verdict a table or a column drop gets, so everything applied ahead of a roll survives. What the operator needs telling instead is that every one of those boots logs a refused destructive change for it, and that only a release from before indexes were protected undoes the work. An integration test now converges a named schema declaring a surplus index and boots the running release over it, so the prediction is pinned against the behavior rather than against the notice's own wording. Alongside it: - A plaintext redirect no longer bypasses the convergence's fail-closed policy. Guarding only the configured base URL left the choice of channel with the remote end, which can redirect an https base to http and supply the DDL that runs. - A schema named with no files to go with it is refused in the adapter both transports reach, rather than defaulting to the embedded schema and converging it successfully for a caller that asked for another release's. - `--json` keeps stdout to itself on the attended path. A named schema cannot be paired with --auto-approve, so the plan and the prompt would otherwise make the only machine-readable form of a cross-release convergence unparseable. AV-9 no longer claims the terminal confirmation as a rule every surface holds to, since it is the CLI's; its Enforced line names what each surface does enforce. Resolves the conflict markers left in the operator guide, whose two sides were the same paragraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1413, 08eca53. Verdict: 4 findings — 1 blocking (unattended BlockingAn interactive Non-blockingDeclining the prompt under The rewritten notice promises a refusal log line that PostgreSQL never emits. storage_schema.go:647 unconditionally says each boot "logs a refused destructive change" for surplus objects, but The same notice denies destructive convergence outright, which is false under General suggestions
The one thing that could have broken, verified
Verified correct
This review was generated by Claude Code (claude-opus-5). |
|
🤖 Both blocking findings confirmed and fixed in 08eca53. Thanks — the second one is the kind of defect this PR was least able to catch on its own. Conflict markers. Mine, from the rebase. Both sides turned out to be the same paragraph — the incoming side was a verbatim duplicate of the The notice's claim about a surplus index. Confirmed exactly as you describe. The drop comes back What went wrong is worth naming, because it is not a reasoning error: this branch predates the clause partition landing on The notice now states the two things an operator cannot see in the plan: everything pre-applied survives the deployed release's boots, and every one of those boots logs a refused destructive change for it until the release ships — a fleet warning about work done on purpose, which is worth predicting before it reads as an incident. The exception you would still want to know about is kept: a release from before indexes were protected does converge a surplus index away. Your suggestion, which is the fix for the other two. You are right that the only test compared the notice against a substring of its own literal, and that a test converging a surplus index would have caught it. That test now exists: Also in the same commit, from the Copilot pass: a plaintext redirect no longer bypasses the convergence's fail-closed fetch, a schema named with no files is refused in the adapter both transports reach rather than falling through to the embedded schema, Thanks also for the verified-correct list — the option-slice aliasing question in This reply was generated by Claude Code (Opus 5). |
…pprove Suppressing the plan under --json kept stdout parseable by taking away the only thing the prompt is for. A named schema cannot be paired with --auto-approve, so that path always ends at a confirmation: an operator typing yes to DDL that was printed nowhere is a worse answer than an unparseable stream. The person and the program are two audiences, so the plan and the prompt go to the terminal and stdout carries the response. A decline answers in that shape too. It exited 0 with an empty stream, which a wrapper cannot tell from a crash, and nothing ran — so both halves of the report are what the plan found, as they are for a refusal. The notice also spoke for every deployment at once, and was wrong for two of them. A deployment permitting destructive storage changes converges them, so what is pre-applied is dropped by the next pod rather than kept, which inverts the advice to converge ahead of the roll. PostgreSQL's bootstrap computes no removal at all, so it keeps the state for a different reason, logs no refusal to grep for, and has no release with an index gap to warn about. Both inputs were already on the report the notice receives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Re-review findings all confirmed and fixed in a117afa. The blocking one is mine and it is the worse half of the trade I made — thanks for catching it in the delta. An interactive A decline under The notice promised a log line PostgreSQL never emits, and denied a destructive convergence that
One note on sequencing: I replied to your first review before seeing this one, so that reply claims the This reply was generated by Claude Code (Opus 5). |
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1413, a117afa. Verdict: 1 finding — 1 blocking (cross-release notice reads the per-request policy, not the deployment's). BlockingThe cross-release notice tells the operator their deployment permits destructive storage changes whenever they pass Concretely: a deployment leaves Verified correct
This review was generated by Claude Code (claude-opus-5). |
… does The cross-release notice predicts what the deployed release does to state converged ahead of it, and read the effective destructive policy to decide. That policy is the deployment's config widened by the caller's own opt-in, so an operator who met the destructive gate and re-ran with --allow-unsafe (the command the gate's rerun hint hands them) was told their deployment permits destructive storage changes and should converge during the deploy instead. Their deployment permits nothing of the kind: those boots still refuse the drops and the pre-applied state survives, so the notice talked them out of the safe pre-deploy convergence the command exists for. A report now carries both facts. DestructiveAllowed stays the effective policy for the call. BootConvergesDestructively is what the next pod to start does, which is the deployment's standing policy and its dialect, and no request moves it — false on PostgreSQL whatever that deployment configured, since an additive convergence computes no removal to permit. The two travel separately from the one place that knows both: WithDestructiveSchemaChangePolicy takes the deployment's policy and the request's opt-in and derives the effective one, replacing an OR that three call sites each did for themselves before handing over the single flag a report then could not take apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Confirmed and fixed in b0eb9cf. The finding is exactly right, including the part about there being no field to branch on. The notice read the effective policy where only the standing one is meaningful. The fix adds the field rather than hedging the sentence. A report now carries two:
Two things fell out of doing it this way rather than with a config-only mirror of the same bool. The first is that the field is dialect-aware at the point that knows the dialect. The second is that a plain second bool would have been the same defect waiting: default false reads as "boots refuse", which is the reassuring answer, so any path that forgot to set it would go quietly wrong in the dangerous direction. So the two facts are now set together or not at all. Tests: Invariants: AV-9 upholds. Nothing about what may run moved — the effective policy is computed from the same two inputs and the destructive gate is unchanged. What changed is that a report can now state what a different process will do, which it previously could only guess at from this one's permissions. Thanks for the verified-correct list on This reply was generated by Claude Code (Opus 5). |
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1413, b0eb9cf. Verdict: 6 findings — 2 blocking (the cross-release notice asserts survival where the field is merely absent), 4 non-blocking. BlockingThe cross-release notice branches on a brand-new proto field, so an older data plane's absent field decodes as false and the CLI positively asserts the pre-applied state survives. storage_schema.go:663 reads The same false value conflates "the deployment forbids destructive changes" with "this code path cannot know", so the survival promise prints on exactly the deployments whose boots will drop the state. On the documented Non-blockingOn the The adapter's type doc still points at The proto round-trip test was not extended with Nothing pins the order of The one thing that could have broken, verifiedCollapsing Verified correct
This review was generated by Claude Code (claude-opus-5). |
Three of the answers this command gives an operator deciding whether to pre-apply storage were reassurance a caller had not earned. boot_converges_destructively was a bool, so a data plane too old to set it and one reporting a deployment that preserves surplus state arrived identically, and the notice told both operators their pre-applied state survives to the deploy. It is now a three-valued enum whose zero is UNSPECIFIED, and the CLI has a branch that says the policy could not be established and names how to settle it. PostgreSQL is read before the policy, because an additive-only bootstrap preserves on every release including the ones too old to say so. A --dsn target has no config to read, so its deployment policy is unknown rather than forbidding. The two permit the same thing — nothing — and claim different things, so the execution gate and the report now take different types: transposing WithDestructiveSchemaChangePolicy's two arguments is a compile error rather than a report describing a caller's own flag as the fleet's policy. --release and --schema-dir travel as request fields a release predating them ignores, converging its own embedded schema and reporting success. The report's schema_source is the evidence, so both routes now refuse an answer that names a schema the caller did not ask about. Upholds AV-9: none of this widens what a convergence may run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The symbol pointer corrected in the previous commit left one line past the width the rest of the block keeps to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1413, 8d3a98f. Verdict: 3 findings — all non-blocking (one over-claiming comment, two dangling doc pointers, one unguarded CLI switch). Non-blockingThe apply-path guard's justifying comment over-claims: Two doc comments still point at
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.
🤖 Reviewed on Morgan's behalf, at 8d3a98f6. Approving. The code is right where it matters: the boot path resolves to the identical old value, the three production call sites of the renamed destructive option are equivalent for what actually runs, the schema is resolved once, and thirteen of fourteen fault injections were caught. The DeploymentDestructivePolicy/bool pair is the right shape and the reason given for it holds — I transposed them by hand and pkg/api failed, which two bools would not have.
The findings are all in the prose, and two of them are load-bearing enough to fix before merge because an operator acts on them.
1. The --schema-dir safety claim is false on PostgreSQL, and this PR's own test is the counterexample
docs/storage-schema.md:546-550:
The convergence gets no more permission than a boot: a statement that would drop a storage table or column is refused as it is at startup, and a PostgreSQL column shape the convergence will not run still stops the whole set. So a hand-assembled
--schema-dirmissing half its tables is refused rather than applied.
True on MySQL. On PostgreSQL it is the opposite, and the PR says so itself sixty lines away in pkg/api/storage_schema_source.go:96-107:
On PostgreSQL the convergence is additive-only and walks only the tables the set supplies, so an omitted table is not reported at all. Nothing is dropped, and the cost is the other way round: a set missing most of a release's files can plan as converged
TestApplyStorageSchemaPostgres_ConvergesASuppliedSchema, added in this PR, is that case executed: it supplies exactly one file, converges an empty storage database, and asserts remaining.Converged() is true while every other storage table is absent. A set missing all but one table converged cleanly and reported converged.
The sentence is a problem rather than a wording nit because of the sentence in front of it. That one names PostgreSQL explicitly, so the unqualified "So a hand-assembled --schema-dir…" reads as covering both dialects — and the paragraph sits inside the new Converging a release before it rolls section, whose whole subject is an operator hand-pointing a selector at a checkout. On PostgreSQL a partial --schema-dir exits 0 with "Nothing is outstanding" for a release whose storage is not ready, which is the exact belief this command exists to make reliable. The source comment already has the right words; the docs want the same split.
2. The CLI does not do what this paragraph says it does
docs/storage-schema.md:539-544:
A target too old to accept a named schema is refused rather than answered.
--releaseand--schema-dirtravel as request fields a release that predates them ignores, converging its own embedded schema instead and reporting success — so the CLI checks the schema the report says it used against the one you asked for, and fails the command when they differ.
The CLI checks nothing. StorageApplyCmd.converge sends request.SchemaSource and returns response.Planned without ever comparing them (pkg/cmd/commands/storage_schema.go:860-875), and readThroughAPI does the same.
The real guard is refuseUnhonoredSchema (pkg/api/storage_schema_handlers.go:406), which runs in the server, on both routes, against the answer from the gRPC hop to the data plane. That is the correct place for it: proto3 drops unknown fields, so an old data plane silently answers about its own schema, which is the failure being described.
The CLI→server hop it does not cover does not need covering, for a different reason worth stating in the doc since it produces a different error: decodeOptionalStorageSchemaBody sets DisallowUnknownFields() (pkg/api/storage_schema_handlers.go:523, present in main too), and schema_files/schema_source are new on StorageSchemaApplyRequest. A SchemaBot server predating this PR therefore 400s the request rather than ignoring the fields. So the hop fails closed, but it fails closed as a body-decode error, not as "the target answered about X, not the schema that was asked for". An operator handed this paragraph and then a 400 will go looking for the comparison it promises.
Both routes really are compared, and the PR body's own wording ("both routes now compare it") is accurate — it is only the doc that attributes the check to the CLI.
3. crossReleaseConsequence falls through to the reassuring message for a policy it does not recognise
pkg/cmd/commands/storage_schema.go:680 switches on report.BootRemovalPolicy with arms for BootRemovalRemoves and BootRemovalUnknown. BootRemovalPreserves and every unrecognised value share the final unguarded return. Measured, with running = "v1.2.3":
policy="" → "…could not be established… Confirm which before relying on this" ✅ correct
policy="preserves" → "…every pod that boots v1.2.3 refuses to drop what it does not declare, so the
tables, columns and indexes applied here survive…"
policy="removes" → "…this deployment permits destructive storage changes… Converge as part of the
deploy rather than ahead of it."
policy="removes_after_grace" → byte-identical to policy="preserves"
The zero value is handled correctly — I got that wrong on a first pass and checked the rendered text rather than a classifier.
What makes this worth a line is that the producer forty lines up is deliberately exhaustive and says why:
mysqlBootRemovalPolicy… Every case is named, so a policy added later falls to the unknown rather than inheriting whichever answer a default happened to be.
The consumer does not hold to that, and it is the consumer that decides what an operator reads. Reachability is one-directional and forward-looking: bootRemovalPolicyFromProto maps unknown proto enums to Unknown, so gRPC is safe; the JSON transport passes the string through verbatim, so a value only arrives from a newer server answering an older CLI — the reverse of this feature's usual skew, but the direction that accumulates as the field grows values. One line fixes it: give BootRemovalPreserves its own case and let the default be the Unknown text, which is the shape the whole design argues for ("Survival is the claim an operator acts on by pre-applying, so it is the claim that has to be earned").
Notes
- A renamed field left its old name in two doc comments.
boot_converges_destructively/BootConvergesDestructivelyappears inpkg/proto/tern.proto:1048("Use boot_converges_destructively to reason about what some other process does"),pkg/apitypes/storage_schema.go:72, and the generatedtern.pb.go.git grep -ifinds no such identifier anywhere. The field isboot_removal_policy/BootRemovalPolicy. The proto one matters more than the Go one:tern.protois the contract another implementation reads, and it points at a field that does not exist. - The one invariant in the PR body that nothing pins is the headline one. "The schema is resolved once, in
Run, and threaded through the preview, the confirmation, and the convergence." Deletingpreview.resolved = desiredleavespkg/cmd/commands,pkg/api,pkg/serveandpkg/apitypesall green —desiredSchemathen falls through toresolve, which memoises nothing, so the tag is fetched twice and the moved-tag window the comment describes is reopened. It is the only injection of fourteen that survived. The asymmetry is what stands out: the target-resolution twin gotTestStorageTargetIsResolvedInOnePlace, an AST walk that will catch a second call site someone adds next year, and the schema resolution — the one whose second read can change the DDL that runs — has nothing. An assertion that a source resolved once is reused would be enough; the AST version would be better and is already written for the sibling. - AV-9's Enforced: line under-cites. "a named schema is refused apart from the files it names on every transport that carries one (
pkg/serve/storage_schema.go)" —validateStorageSchemaSourceinpkg/api/storage_schema_handlers.go:545is the other half of "every transport", and it is the half that was already there. Worth naming both, since the invariant's claim is specifically about the pair. writeToTerminalswaps the process-globalos.Stdout. It is the right call for this codebase — the renderers print throughfmt.Print, so threading anio.Writerthrough all of them is a much larger change — and the doc comment names the mechanism honestly, including the constraint that makes it work ("nothing writes the response until after it is restored"). Noting only that it is not concurrency-safe and only diverts writers that resolveos.Stdoutper call: a renderer added later that captures the writer up front escapes it silently. Not reachable today;-raceon the package is clean, and Go's parallel-test scheduling keeps the serial tests that use it away from the parallel ones in the same package.- The convergence-budget check is correctly hoisted above the plan (
Run, beforepreview.read), so a bad--timeoutdoes not cost a production catalog read first. Small thing, deliberate, and the comment says why.
What I checked rather than took on trust
- The boot path is untouched, verified rather than asserted.
serve.go:649goes fromWithAllowDestructiveSchemaChanges(cfg.Storage.AllowDestructiveSchemaChanges)toWithDestructiveSchemaChangePolicy(ConfiguredDestructivePolicy(cfg.Storage.AllowDestructiveSchemaChanges), false);allowDestructiveresolves toconfig || false, the old value. NoWithStorageSchema, and(*StorageSchemaSource)(nil).isEmbedded()is true, soo.schemaSource.mysqlSchemaFiles()returnsreadEmbeddedSchemaFiles()exactly as before. - All three production call sites of the renamed option, not just the boot.
pkg/serve/storage_schema.go:297isconfig || request, the oldallowsDestructive.pkg/cmd/commands/storage_target.go:71passest.destructive, whose zero value on a--dsntarget isUnknown—Unknown == Permits || requestisrequest, identical to the oldfalse || request. Execution is unchanged on every path; only the report moved.git grep WithAllowDestructiveSchemaChangesreturns nothing, so nothing was left behind. - The "too old to accept a named schema" story on both hops. Read above; the gRPC hop is guarded by
refuseUnhonoredSchemaon both routes, and the HTTP hop fails closed on strict decoding. Finding 2 is what I found by checking it. - The proto is additive.
boot_removal_policy = 11on a message whose previous max was 10;schema_files = 4andschema_source = 5on a request whose previous max was 3. No reuse, no renumbering, and the enum's zero isUNSPECIFIED. - MySQL really does report an omitted table as surplus, which is what the "incomplete file set is safe" argument rests on and what makes finding 1 a PostgreSQL-only problem.
TestEnsureSchema_RemovesObsoleteVitessTasks(pre-existing) is the proof: a table on the database and absent from the files is computed as a removal, run when destructive is permitted and refused when it is not. - Fault injection — thirteen of fourteen.
blockUnattendedNamedSchemanever refusing →pkg/cmd/commands;resolveDesiredalways nil →pkg/cmd/commands;ApplyStorageSchemadroppingWithStorageSchema→ caught bypkg/apiunder-tags integration(green without it, so this one needs the integration job);refuseUnhonoredSchemadisabled →pkg/api; the same on the apply route alone →pkg/api; the adapter letting a name with no files fall through to embedded →pkg/serve;guardReleaseHostPlaintextdowngrading a convergence to a warning →pkg/cmd/commands;refuseInsecureRedirectdoing the same per hop →pkg/cmd/commands; the deployment policy taken from the request flag →pkg/api;mysqlBootRemovalPolicyreporting Unknown as preserves →pkg/api;writeToTerminalnever diverting →pkg/cmd/commands; the prompt left on stdout under--json→pkg/cmd/commands;attributeStorageSchemaConvergenceattributing only the planned half →pkg/cmd/commands; a--dsntarget claimingForbidsinstead ofUnknown→pkg/cmd/commands. Only the single-resolution note above survived. - The two findings in the prose are measured, not read. Finding 1 is the PR's own new PostgreSQL test run against the sentence; finding 3 is
crossReleaseConsequencecalled directly with four policy values and the rendered strings compared, because the difference between the arms is a difference between two paragraphs and that is invisible from the switch. - Base is current and the delta is what GitHub shows.
merge-base origin/main pr1413is2afa3ffe;origin/main...pr1413is 35 files, +2679/−651, matching the PR.go build, and thepkg/cmd/commands,pkg/api,pkg/serveandpkg/apitypessuites, are green at this head, plus-race. CI: 43 checks, 40 SUCCESS, no genuine failures, nothing pending,mergeStateStatus: CLEAN.
…chema The version-skew guard caught the answer, not the convergence. A target too old to accept a named schema still honors allow_destructive on the same request, so it diffs its own older embedded schema, finds a newer release's state surplus, and drops it — removals no boot of that deployment would run, since a boot never sees an operator's opt-in. The guard then 502'd and discarded the reports, so the operator never saw what had executed. A named convergence now costs a diff first. The diff answers the same question and runs nothing, so "would this target work from the schema I sent" is bought for a catalog read instead of for executed DDL. The post-hoc check stays, because a deployment is many pods and a roll makes them different releases: reaching it means the pod that converged is not the pod that answered the diff, and by then DDL has run, so its refusal names what ran and which database to reconcile. crossReleaseConsequence gains a default. The policy crosses the HTTP hop as an open string and the CLI is versioned apart from the server it dials, so a value from a later release fell through to "your pre-applied state survives" — the one claim that has to be earned. Docs: the "--schema-dir missing half its tables is refused" claim was MySQL-only; PostgreSQL's additive-only convergence reports such a set as converged. The guard is also attributed to the server rather than to the CLI, which compares nothing. Pins the single resolution of a named release by counting fetches, the one fault injection of morgo's fourteen that survived. Upholds AV-9; its Enforced line now names both transports and the pre-convergence check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 All six fixed in 2761f6e. The two blocking findings are one defect, and the fix generalises past the case you named. A bool cannot report "I could not say", and every path that could not say was rendered as the reassuring half. You are right that field 11 contradicts the convention field 10 already documents — and the same conflation exists one layer up, before the wire is involved: So the fix is a three-valued policy at both altitudes:
On your The dangling The vacuous round-trip is fixed by naming the field, not by setting it. You are right that The argument order is now pinned by the compiler rather than by a test. Your analysis of why a test would not have caught it is the reason: the effective policy is the disjunction, so a swap leaves every gate correct and only the report wrong, which is the one thing that has no gate. One thing your review did not reach, which the same skew produces more severely. Invariants: AV-9 upholds throughout. Unknown permits nothing, so nothing widened; the destructive gate,
This reply was generated by Claude Code (Opus 5). |
|
🤖 Both reviews confirmed and fixed in 1829a10 — @Kiran01bm's three and @morgo's three, plus two of the notes. Thanks to you both; the overlap on the cross-release default and the dangling proto pointer is what made those two unarguable, and the two you each found alone are the two that changed the shape of the PR. The guard was in the wrong place, and the comment defending it was the tell. Kiran's first finding is right, and right about the part I would have defended: The narrow fix was to correct the comment and write the reports out. That leaves executed DDL on the table and calls it disclosure. What the guard actually needs is to ask the question before it costs anything, and there is already a call that asks exactly it and runs nothing: a named convergence now plans first, and the plan's A data plane still on v1.4.0, storage holding a The check after the convergence stays rather than being replaced, because a deployment is many pods and a roll makes them different releases: reaching it now means the pod that converged is not the pod that answered the plan. By then DDL has run, so that refusal no longer says "upgrade the target" and stops — it names how many statements ran, which database on which host they ran against, and that the database has to be reconciled against the release the target is actually running before anything is retried. The plan's extra round trip is paid for in the deadline — a convergence's write budget already covers the two diffs bracketing it, and a third diff's worth is added only when a schema is named, and a request naming no schema still makes exactly one call — The cross-release notice. Both of you, and morgo measured it: The The skew paragraph credited the CLI with a comparison it does not make. Also right — The dangling On the notes:
Invariants. AV-9 extends the enforcement: the rule is unchanged, and a named schema is now confirmed honored before anything converges rather than only checked on the answer. Nothing weakens. The destructive gate, This reply was generated by Claude Code (Opus 5). |


Why this matters
The storage a release needs has to exist before the first pod of that release starts, and the pod that would converge it cannot start until it is there.
storage applycould not close that gap: a convergence ran the schema embedded in the binary answering the command. A storage index added in a new release otherwise copies the table on every pod in the roll, under a five-minute startup budget.--releaseand--schema-dirnow work onstorage applyas they already do onstorage plan, and the files they name are what converges. The boot path is untouched: it names no schema, and no schema is the embedded one.An operator on CLI v1.2.3, fleet deployed on v1.2.3, converging v1.4.0's storage (which adds
applies.driver_note) ahead of the roll:The schema is resolved once, in
Run, and threaded through the preview, the confirmation, and the convergence, so a tag that moves in between cannot have an operator approve one file set and run another. Two gates sit in front of it:--auto-approveis refused with a named schema, so a cross-release convergence is never unattended. A pre-deploy job converging with-yand no selector is unchanged.A release read from a plaintext
$GITHUB_API_URLis now refused on a convergence where a plan only warns, since whatever DDL arrives is what runs. A mirror on loopback is exempt.storage apply --release v1.4.0 (new)
storage apply --release v1.4.0 (previous)
storage apply --release v1.4.0 --auto-approve (new refusal)
From review
--jsonanswers in JSON on every exit — refused, declined, or converged. The gates printed a human plan on the surface a program parses, the destructive gate printed nothing, and a decline exited 0 with an empty stream. The attended path printed the plan and prompt to stdout too, which a named schema forces, since it cannot be paired with--auto-approve. The human half now goes to the terminal and stdout carries the response.The storage target is resolved once per run. Resolving a
storage.dsn_fromconfig re-reads secret references, so three call sites meant three audited reads and a window where a changed value had the convergence run against a database its preview never saw. A completeness test pins the single call site.A plaintext redirect no longer bypasses the fail-closed fetch. Guarding only the configured base URL left the choice of channel with the remote end, which can redirect an https base to
httpand supply the DDL that runs. Every hop is now held to the rule the base URL was.A schema named with no files is refused where both transports reach it. The HTTP handler validated the pair, but the tern gRPC server reaches the adapter directly, where a name alone fell through to the embedded schema and converged it successfully for a caller that asked for another release's.
The notice reported the fleet's behavior from the operator's own flag. It branched on the effective destructive policy, which is the deployment's config widened by the caller's opt-in — so an operator who met the destructive gate and re-ran with the
--allow-unsafethe gate itself handed them was told their deployment drops what they are applying and should wait for the deploy. Their fleet does no such thing. A report now carries the two facts apart: the effective policy for this call, and what a boot of this deployment does to surplus state, which no request moves and which is preservation on an additive-only dialect whatever it has configured. The two are different types, so transposing them at a call site is a compile error rather than a silent misreport — a gate reads whether either permits, so every gate would have stayed correct while only the notice went wrong.The notice said a pre-applied index would be converged away, and spoke for every deployment at once. A boot refuses an index drop with the same verdict a table or column drop gets, so the pre-applied state survives; what an operator needs warning about is the refusal logged on every boot until the deploy. It now branches on the report's dialect and destructive policy: PostgreSQL computes no removal and logs nothing, and a deployment permitting destructive storage changes drops what was pre-applied — the one case where converging ahead of the roll is the wrong move. An integration test converges a named schema declaring a surplus index and boots the running release over it, so the prediction is pinned against behavior rather than against its own wording.
A boot policy nobody could report was rendered as the reassuring one. The field was a bool, so a data plane too old to set it and a deployment that preserves surplus state arrived identically, and a
--dsntarget — which has no config to read — was indistinguishable from one that had been read and forbids. Both told an operator to pre-apply storage their fleet may drop. It is now a three-valued enum whose zero isUNSPECIFIED, matching the convention field 10 already documents, and the notice has a branch that says the policy could not be established and names how to settle it. Unknown still permits nothing, so what a convergence may run is unchanged. PostgreSQL is read ahead of the policy, because an additive-only bootstrap preserves on every release including the ones too old to say so.An operator converges v1.5.0's storage ahead of the roll with
--dsn, against a MySQL deployment whose config setsallow_destructive_schema_changes: true— a config this path never reads:A target too old to accept a named schema is refused rather than answered.
schema_filesandschema_sourceare new request fields, and proto3 drops what it does not recognize: an older data plane converges its own embedded schema and answers success, which is indistinguishable from having honored the request. The report's ownschema_sourceis the evidence, since the answering side sets it from the schema it diffed, so both routes now compare it against what was asked and fail with a 502 naming the schema that actually ran.The version-skew guard caught the answer, not the convergence. A target too old to accept a named schema did not ignore the rest of the request along with it: it still honors
allow_destructive, so it diffs its own older embedded schema, finds a newer release's state surplus, and drops it — removals no boot of that deployment would run, since a boot never sees an operator's opt-in. The 502 then discarded the reports, so the operator never saw what had executed. A named convergence now costs a diff first: the diff answers the same question and runs nothing, so the answer is bought for a catalog read rather than for executed DDL. The check after the convergence stays, because a deployment is many pods and a roll makes them different releases — reaching it means the pod that converged is not the pod that answered the diff, and by then DDL has run, so its refusal names what ran and which database to reconcile.A data plane still on v1.4.0, storage holding a
driver_notecolumn pre-applied earlier, and an operator converging v1.5.0 with the--allow-unsafethe destructive gate handed them:The cross-release notice fell through to the reassuring text for a policy it did not recognize. The policy crosses the HTTP hop as an open string and the CLI is versioned apart from the server it dials, so the values arriving grow without it — and the producer's own comment says every case is named so a later policy falls to the unknown. The consumer did not hold to that, and the consumer is the half an operator reads.
BootRemovalPreserveshas its own branch now and the default is the unknown text.Two docs claims were wrong, in the section an operator hand-pointing a selector reads. "A hand-assembled
--schema-dirmissing half its tables is refused rather than applied" is MySQL-only: PostgreSQL's convergence is additive-only and walks just the files it was given, so a set missing most of a release's tables converges cleanly and reports converged — this PR's ownTestApplyStorageSchemaPostgres_ConvergesASuppliedSchemais that case executed. And the skew guard was attributed to the CLI, which compares nothing; it is the server that compares, on the gRPC hop, while the CLI-to-server hop fails closed separately as a strict-decode 400.Single schema resolution is pinned by counting fetches. It was the one fault injection of fourteen that survived: deleting the memo left every package green while the tag was fetched twice, reopening the moved-tag window. Asserting on the resulting DDL cannot catch it — the two reads sit next to each other in one call, with no seam to change the files through — so the test counts the release fetches instead.
Invariants
storage apply --release v1.5.0converges v1.5.0's files from whatever binary you run. The entry keeps: a convergence never destroys SchemaBot's own storage, and a named schema supplies files and nothing else — every gate applies to it unchanged. The terminal confirmation is not stated as a rule, because it is the CLI's; the Enforced: line names what each surface enforces.Note
Rebased onto
main, which has since gained the convergence budget, per-table progress reporting, and the clause partition; the operator guide this branch originally carried landed separately. Neither command has shipped, so theApplyStorageSchema/PlanStorageSchemasignature change breaks no importer.Opened by Claude Code (Opus 5).