Skip to content

feat(cli): converge storage to the release an operator names, not the one answering - #1413

Draft
aparajon wants to merge 3 commits into
armand/storage-schema-guidefrom
armand/storage-apply-named-release
Draft

aparajon wants to merge 3 commits into
armand/storage-schema-guidefrom
armand/storage-apply-named-release

Conversation

@aparajon

@aparajon aparajon commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

This rewrites AV-9, deliberately. The invariant said a convergence runs the schema of the binary running it. It no longer does: storage apply --release v1.5.0 converges v1.5.0's files from whatever binary you run the command from. What AV-9 still says, and what this change leaves untouched, is that a convergence never destroys SchemaBot's own storage to start — a named schema supplies the files the bootstrap computes from and gets no more permission than a boot. Two gates are new around it: a named schema is confirmed by a person and refused with --auto-approve. The entry is rewritten in this PR.

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 is the one that cannot start until it is there. That is the whole reason storage apply exists, and until now it could not do it: a convergence ran the schema embedded in the binary answering the command, so an operator holding a CLI on the deployed release could only ever converge the deployed release's schema. Pre-applying the next release's storage, which is the workflow the command was built for, was the one thing it refused.

On a long-lived database this is not a convenience. A storage index added in a new release runs as a table copy on every pod in the roll, under a five-minute startup budget; converging it once ahead of time is the difference between a deploy and an outage.

What it does

--release and --schema-dir now work on storage apply exactly as they work on storage plan, and the files they name are what converges. An operator runs schemabot storage apply --release v1.4.0 from whatever CLI they have to hand.

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:

Before                                   After

┌───────────────────────────────┐        ┌───────────────────────────────┐
│ storage apply                 │        │ storage apply                 │
│ --release v1.4.0              │        │ --release v1.4.0              │
└───────────────┬───────────────┘        └───────────────┬───────────────┘
                │                                        │ fetch v1.4.0's
                ▼                                        ▼ schema files
┌───────────────────────────────┐        ┌───────────────────────────────┐
│ refused: "--release cannot    │        │ plan, then the notice:        │
│ be used with a convergence"   │ ✗      │ "v1.2.3 converges the         │
│                               │        │  difference back"             │
└───────────────┬───────────────┘        └───────────────┬───────────────┘
                │ only way forward                       │ yes
                ▼                                        ▼
┌───────────────────────────────┐        ┌───────────────────────────────┐
│ storage apply, no selector    │        │ converged to v1.4.0           │
│ converges v1.2.3's schema     │        │ driver_note created, before   │
│ driver_note NOT created       │ ✗      │ the first v1.4.0 pod starts   │
└───────────────────────────────┘        └───────────────────────────────┘

The schema is resolved once, in Run, and threaded through the preview, the confirmation, and the convergence. A tag that moves between the plan and the apply cannot have an operator approve one file set and run another.

Two gates sit in front of it:

  • --auto-approve is refused with a named schema. A cross-release convergence is never reachable unattended. A pre-deploy job can still converge the answering binary's own schema with -y and no selector — that path is unchanged.
  • 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, but a surplus index loses no data, so the older release's own bootstrap removes it without asking. A pre-applied index has a shelf life measured in pod restarts. That is invisible in a plan, which shows what the storage needs, and its failure mode is silent.

A named schema gets no more permission than a boot: a DROP TABLE or DROP COLUMN is refused exactly as it is at startup, which is also what protects the storage from a hand-assembled --schema-dir that is missing half its tables — the missing tables diff as destructive statements and the whole set is refused.

One tightening comes with it: a release read from a plaintext $GITHUB_API_URL is now refused on a convergence, where a plan still only warns. The rewrite that costs a plan its authority costs a convergence the storage database, since whatever DDL arrives is what runs — and it arrives having parsed exactly as a real schema would, so nothing downstream has anything to catch. A mirror on loopback is exempt, as everywhere else in this file.

storage apply --release v1.4.0 (new)
$ schemabot storage apply --release v1.4.0
╭─────────────────────────────────────────────────────────────────╮
│  MySQL Schema Change Apply                                      │
│                                                                 │
│  Database: schemabot on db-1.example                            │
│  Schema: the schema files of release v1.4.0 in block/schemabot  │
╰─────────────────────────────────────────────────────────────────╯

     ~ applies
       ALTER TABLE `applies` ADD COLUMN `driver_note` varchar(255) NOT NULL DEFAULT '' AFTER `lease_owner`;

📋 Plan: 1 table to alter

Converging schemabot on db-1.example (mysql) to the schema files of release v1.4.0 in block/schemabot.

  This is not the schema v1.2.3 converges on boot, so until that release is
  deployed, every pod that boots v1.2.3 converges the difference back. A table or a
  column it finds surplus is refused as destructive and stays; an index loses no
  data, so it is removed without asking. Converge close to the deploy, and re-run
  `storage plan` just before it to confirm what you applied is still there.

Do you want to apply these changes to schemabot on db-1.example (mysql)? Only 'yes' will be accepted: yes
✓ Ran 1 statement against schemabot on db-1.example. Nothing is outstanding.
storage apply --release v1.4.0 (previous)
$ schemabot storage apply --release v1.4.0
Error: --release cannot be used with a convergence: an apply runs the schema embedded in the binary running it, so that it converges exactly what that binary's next boot would. To converge a release's schema, run that release's binary — its container image is that release — or let the release's own first boot converge it. To see what it would do, use the same flag on `storage plan`
storage apply --release v1.4.0 --auto-approve (new refusal)
$ schemabot storage apply --release v1.4.0 --auto-approve
Error: --release cannot be combined with --auto-approve: converging storage to a schema this binary does not carry is confirmed at a terminal, because until that release is deployed the running one will not converge the same schema. Run it without --auto-approve and answer the prompt, or drop --release to converge what this binary's own next boot would

Two fixes on the same commands, from review

  • A refused --json convergence answers in JSON. Both gates printed a human plan on the surface a program parses — and the destructive gate, which prints nothing and returns silently, printed nothing at all, so a caller could not tell a refusal from a crash. They stay quiet under --json now, and the refusal comes back as the response shape a convergence uses, both halves carrying the report: nothing ran, so everything the plan found is still outstanding.
  • The storage target is resolved once per run. Every resolution of a config using storage.dsn_from is a fresh read of secret references, so the three call sites 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, 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.

Invariants

  • AV-9, rewritten. The clause "converges the schema of the binary running it" is gone; naming a schema is now the operator's, so that the storage a release needs can be in place before the first pod of it starts. What the entry keeps is the part that makes it an invariant: a convergence never destroys SchemaBot's own storage, a named schema supplies the files the bootstrap computes from and nothing else, and it is confirmed by a person, never unattended. The Enforced: line now also names the confirmation and its refusal to run unattended.
  • AV-9, upholds (the destructive gate). A named schema produces a diff like any other, and a statement that would drop a storage table or column is refused on the same path a boot's is. This is what makes an incomplete supplied file set safe: it reports the storage's own tables as surplus, which is a set of destructive statements, which is the disposition that is refused. Pinned by an integration test that converges a supplied schema, plans the same database against the embedded one, and asserts the surplus column lands in Destructive and survives an EnsureSchema.

Note

storage plan and storage apply have not shipped, so the ApplyStorageSchema / PlanStorageSchema signature change breaks no importer.

Opened by Claude Code (Opus 5).

aparajon and others added 3 commits September 17, 2026 14:28
… 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.
@aparajon
aparajon force-pushed the armand/storage-apply-named-release branch from 28ff40f to 00d0a35 Compare September 17, 2026 18:37
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.

1 participant