# Why the stack version splits compatibility from migration A stack version number looks like it answers one question. It actually answers two, and the two are independent of each other. ## Two questions, not one The first question is whether the new version is a drop-in replacement for the old one - whether an existing instance can install it, and can also go back, without anyone doing hand-work. That is what a version number *is*: a promise. The second question is whether the existing corpus in `kb/` needs to change shape to keep working under the new version. These sound like the same question, because most of the time a change that breaks compatibility also happens to touch content, and most of the time a change that leaves content untouched also happens to be compatible. The correlation is real; it just is not a law. `instructions/dev/version-parts.md` carries the actual test for telling them apart and the steps that follow from it - this page is about why the split exists at all. ## Why "kb/ untouched" is not proof of anything The tempting shortcut is: if no page in `kb/` had to change, the bump can't be that serious. This is exactly backwards for a class of changes that live entirely outside the corpus - a renamed release artefact, a Python import path, an environment variable, the URL an instance's own updater points at. None of those touch a single page. All of them can strand an existing instance just as thoroughly as a rewritten type-spec would. The corpus is the part of the stack that looks at itself; the compatibility question is about everything an instance depends on to keep functioning, most of which the corpus never sees. ## Reading compatibility off the leftmost non-zero component Semantic versioning gives every component a job, but only one of them is where an existing instance's tooling actually looks to decide "is this safe." On a `2.x` stack that is MAJOR; on a still-pre-1.0 `0.x` stack, by the same convention, it's MINOR - the leftmost slot that isn't pinned to zero is the one an automated updater treats as the compatibility boundary. Bump anything to its left, or bump that slot itself, and the promise changes. Everything to the right of it can move as freely as the project likes without touching that promise. This is why the question "is it boundary-crossing" always resolves to one specific digit, not to a feeling about how big the change is. ## Downgrade is half the promise It's natural to test compatibility by only asking "does the upgrade work." The other half - "can an instance that upgraded put the old version back and land where it started" - carries equal weight, and it's the half that's easy to forget because forward motion is what everyone is testing for anyway. A state file the old version can no longer parse, a generated index in a new shape, a stamp file that got renamed: none of these have to break the upgrade to break the downgrade. An instance that can go forward but not back has already lost the property a compatible version number is supposed to guarantee. ## A promise made to a machine, not only to a person A human reading a changelog can absorb "this technically isn't compatible but it's fine, just update those two things by hand." An instance's own update mechanism cannot. It reads a version number, decides whether to pull the new release, and has no channel for nuance - which is exactly why the update path itself is one of the sharpest ways to cross the boundary invisibly: if the new version moves where updates come from, the very channel that would have told an instance to adjust is the channel that just broke. The version number isn't documentation aimed at a reader; it's an input consumed by code that has no other way to ask. ## The 2.0.0 story This isn't hypothetical for this stack. The rebranding that produced Chemenu renamed the repo, the release artefact, and the Python package - and left every page in `kb/` untouched. The first instinct was a MINOR bump, on the reasoning that nothing in the corpus needed migrating. That reasoning was correct on its own terms and answered the wrong question. Three things broke underneath it: every existing instance's `update_url` pointed at a repo path that no longer existed and, because it's a machine-written file, couldn't be hand-repaired; the release artefact name changed, breaking every download script and pin against it; and the import name changed, breaking anything importing the package from outside the shipped tree. The corpus had nothing to say about any of this, because none of it lived in the corpus. What caught the mistake was a person looking at the diff and asking whether it really was a drop-in replacement, not a validator. No check in `docs verify` or anywhere else confirms that a version part was chosen correctly - it only confirms that a boundary-crossing bump documents what it breaks. The 2.0.0 entry in `CHANGES.md` carries the corrected reasoning in full, and the version bump that shipped it was `--major --no-migration`: boundary-crossing and untouched corpus, at the same time, which is precisely the combination the two-question split exists to make visible. ## Why a number is only spent by a release Everything above is about what a version number *promises*. A separate question turned out to matter just as much in practice: how many numbers get handed out along the way to making one release. For a while the answer was "one per bump," and that turned out to be the wrong grain entirely. The two things that actually consume a version number are a release and CI's version gate - and they disagree about granularity. The gate wants `VERSION` to move on every push that touches stack-shaped paths, which is a *commit*-level question: has this tree changed since the last push. A release wants to know something else: has *this specific number* been published, ever. Handing out a fresh number per bump answers the gate's question by accident and the release's question wrongly - it treats every bump as if it were about to ship, when most of them are steps toward a release that hasn't happened yet. Four bumps in one session, on the same day, for the same eventual release, produced four numbers that a version-check feed would have reported as four different available upgrades, three of which were never real. The fix is not to slow the gate down - it still wants `VERSION` to move every time, and it still gets that. It's to stop treating every movement as a new number. Between two releases the stack now carries one running candidate, escalating through `-beta.N` as bumps accumulate, and only `version release` spends the number for real by fixing it and closing its changelog entry. A number is proposed by a bump and consumed by a release; conflating the two was the actual defect, not the arithmetic of any single bump. This is also why a candidate never gets to a distributed instance. The promise a released version makes - "install this, and it is exactly what its number says" - has no equivalent for something still being decided during a single dev checkout's session. `release.yml`'s only job with respect to this is refusing to act on a suffixed `VERSION` at all: not because a beta is unsafe, but because there is nothing yet to promise. ## Where the procedure lives The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the steps for a boundary-crossing bump - the `--breaking` line, the migration document or `--no-migration` reason, talking to the user before bumping - are one procedure, kept at one place: [instructions/dev/version-parts.md](../instructions/dev/version-parts.md).