Rio
adr/0130-release-version-and-contract-versions.mdRaw Markdown · GitHub ↗

ADR-0130: rio is 0.1.0: semver for releases, integers for contracts

  • Status: Accepted
  • Date: 2026-09-20
  • Deciders: jka
  • Decision: D123

Context

rio had no version of its own. Not an unset one — no constant, no VERSION file, no --version on either entry point. Help ▸ About rio showed the build identity from git describe, which in a repository with no tag is a bare commit hash, and ADR-0076 opened by admitting exactly that: rio has no release version yet, and the first tag is a release gate. A tester who hits a bug has nothing to name, a changelog has nothing to attach an entry to, and "fixed in the next one" cannot be said.

The design work, though, had largely been done already. rio had been using two versioning idioms correctly for a year without naming either: a monotonic integer at each seam that can break — protocol (ADR-0011), provider-api (ADR-0066) — and semver on each independently shipped artefact, which is an extension's version (ADR-0107). What was missing was rio's own number, and, more corrosively, anywhere that said which idiom answers which question. The next person to add a seam could only guess.

Decision

Two kinds of number, named, with rio's own added.

A release version is semver and answers which rio is this? It is one number for rio as a whole — core, GUI, and the bundled syntax definitions and themes — one git tag per release, and nothing branches on it at runtime. The first is 0.1.0.

A contract version is a monotonic integer and answers can these two halves talk? There is one per seam between parties that can be updated independently, and it is compared with a single equality or ceiling test. protocol and provider-api were already this; mode-api is new.

One literal, and for once no guard. rio-core/version.tcl holds the constant and also holds the doctrine above; the file exists as much to be read as to define a value. The GUI sources it from the sibling tree exactly as it already sources the wire and configuration code, so the two halves of a checkout cannot disagree and there is no second home to hold in step. That satisfies ADR-0117 by construction rather than by a test, which is the better outcome wherever it is available.

Where the number surfaces. Both entry points answer --version. The core's greeting reports it beside protocol, which is an additive change and so does not bump the protocol, on the terms ADR-0055 set; that is how a GUI learns which rio is on the far end of a --connect, the one thing the protocol integer cannot tell it. About gains a Version row above Build — Version names the release line, Build names the exact commit under it, and between releases Build is the precise one, so both earn a row. The core's version appears there only when it differs from the GUI's, since a spawned core is always this same tree.

mode-api, the gap that naming the doctrine exposed. A provider declares its contract level and is refused when it needs more than the core implements. A mode declared nothing — yet the mode surface (ADR-0038) is the one ROADMAP says will change, and its failure mode was the worst available: a mode built against a newer rio fails while being sourced, and the frontend can only report that on a stderr that wish on Windows has no console for. The mode is not greyed and not refused; it is silently absent from the list. A mode now declares mode-api, whose level 1 freezes the registration call, the attach and detach duties, the mode bind tag's fixed precedence and the rule that edits reach the core through the group proxy — a surface that was already written down and only needed a number attached.

An absent mode-api reads as 1, unlike provider-api, which is mandatory. The asymmetry is deliberate: the core has required a provider's level since providers became installable, so no provider exists without one, whereas modes have shipped without the key since ADR-0038. Requiring it now would grey the shipped vi and emacs out of the live repository for the whole window between 0.1.0 shipping and every manifest being re-signed, in exchange for nothing — the key only starts carrying weight when there is a level 2. So absence is ADR-0019's forward-compatibility rule applied to rio's own manifest format.

The too-new gate is generalised, not provider-shaped. One table maps an extension kind to its manifest key, the ceiling this rio implements and its default; a kind with no contract sets nothing and can never be too new. The gate moved to the point every install funnels through, which closed a real hole: a too-new provider had been stopped by the core's own refusal to accept it, a check a mode — installed frontend-side with no core in the path — never got.

An extension's version stays its own. ADR-0107 is unchanged and is deliberately not tied to rio's number. That independence is what the *-api integers are for: they, not rio's release version, say what an extension may rely on.

What 1.0.0 will mean, recorded now so it is not decided by drift: the extension and plugin interface stops being one that will change, and the protocol becomes something a third-party client can build against. Until then rio stays on 0.x, which is the band semver reserves for exactly this.

Alternatives considered

  • 0.1.0-alpha, which the release gates had drafted. Semver's 0.y.z band already means initial development, so the suffix restates it and then owes a promotion step that buys nothing. The alpha framing belongs in prose, in the README and the release notes, where a reader actually meets it.
  • Separate core and GUI versions. The two are genuinely different builds only when the GUI reaches a remote core over --connect (ADR-0030), and that case is already governed by protocol, which answers it precisely. A second semver on each half would look like a compatibility statement without being one: core 0.2.1 against gui 0.2.0 tells a reader nothing the protocol integer had not already said, less reliably. One repository, one tag, one changelog, one test run.
  • Semver on the wire protocol or on provider-api. A client asks a contract exactly one question and answers it with one comparison. Semver would offer an ordering nobody reads and a MINOR/PATCH split with no meaning on a wire — and the split would be empty, because the rule since ADR-0011 is that an additive change does not bump at all. The only event these numbers record is a break, which is what an integer is.
  • theme-api and syntax-api too. A theme binds to the additive role table of ADR-0024 and the syntax surface is stable, so both would be numbers nothing ever checks. Only a surface that can break earns one.
  • Making mode-api mandatory. Argued above: a self-inflicted outage in the live repository for no gain until there is a second level.
  • A version field in the configuration and theme formats, or in a repository index. All three are additive-only by construction — unknown keys ignored, unknown kinds listed and greyed — which is ADR-0019 already doing the work a format version would do.
  • A VERSION file, or a literal in each half with a guard holding them equal. Both became unnecessary once it was noticed that the GUI already sources core files directly.

Consequences

  • A bug report, a release note and a changelog entry all have something to name, and the first tag can be cut when the remaining release gates close. About turns that tag into its Build row with no code change, as ADR-0076 promised.
  • Whoever adds the next seam has a rule to apply rather than a precedent to interpret, and the rule lives in the file that defines the version, not only in the decision log.
  • "Additive, no protocol bump" is not free: the core's per-operation encoders are an allow-list, so a key an operation returns and the encoder does not name never reaches the wire. Every additive key has to be added there as well. This was true before and undocumented; the greeting's own test is what catches it.
  • A frontend must tolerate a greeting with no version, because it may be talking to an older core over --connect. That is the same forward-compatibility rule the manifest keys follow.
  • Shipped modes must declare mode-api in their manifests and be re-signed at the next publish, but nothing breaks if a third-party mode never does.
  • The documentation cannot quote a version number, since any literal goes stale at the next tag. What it can be held to is the claim that rio is early, which is checked only while the version starts 0. — an expiry that is real and singular, because 1.0.0 is precisely the release most likely to ship with a paragraph still promising early days.