ADR 004 Version directory encoding - nekrozis/qtogo GitHub Wiki

ADR-004: Encoding a version into a repository directory name

Status: Accepted (2026-10-07).

Context

ADR-001 says a version's directory spelling belongs to the repository-facing layer, but does not define it. Decoding is many-to-one, so encoding needs rules of its own. What the repositories actually contain is the constraint, and the sample is not what a first reading would suggest.

The observed corpus — 269 captured directory names, one per line — is listed and measured in the version directory corpus. It shows:

  • releases of Qt 5 with a one-digit minor and no patch drop the patch, and 5.3.0 through 5.8.0 appear only with the _src_doc_examples extension — no bare qt5_53, no qt5_530;
  • 5.10.0, 5.12.0 and 5.15.0 keep every digit, and Qt 6 releases always do;
  • a preview carries major and minor only, so the suffix is the only thing separating qt6_620 from qt6_62_preview;
  • the extension vocabulary drifts between releases, and nesting is not stable.

The counts, the method and the live-page checks behind those statements live in the corpus page, so this record does not have to be reopened when the sample grows.

A directory name is an index, not a version. For qt6_6110 the truth is in the Updates.xml beside it, whose <Version> reads 6.11.0-0-202603180535; what the name does is identify a path. Upstream publishes no rule for reading it back, and the reference tool — which does read it back, positionally: one digit of major, two of minor, the rest patch, so qt6_6810 is 6.81.0 — documents that reading as an assumption: "As of May 2021, the version strings at https://download.qt.io/online/qtsdkrepository conform to this pattern; they are not guaranteed to do so in the future."

So the upstream rule is not a version specification this project can lean on, and the identifiers upstream leaves ambiguous are ours to settle or to refuse. Both the measurement behind these statements and what the reference tool does with the rest of the layout are in the corpus page.

Decision

  1. One encoder, in the repository-facing package, symmetric with the decoder. EncodeVersionDirectory(version, extension) returns (string, error): qt<major>_<token>[_<extension>]. It is the only place that spells a version into a directory segment.

  2. The token follows the corpus and one family extrapolation. A preview spells major and minor only. A release of major 5 with a zero patch and a one-digit minor spells major and minor (qt5_53 … qt5_59 — which also replaces the earlier "exactly 5.9.0" special case, since 5.3.0–5.8.0 behave the same wherever they appear). Everything else writes every digit: the minor takes two digits when it has them, and the patch is always written (qt5_5100, qt6_680, qt6_6110).

    The observed family is 5.3.0–5.9.0; 5.0.0–5.2.0 lie below the corpus and are extrapolated from it (qt5_50, qt5_51, qt5_52), with no directory to confirm them. The failure mode of that extrapolation is benign — a name that does not exist surfaces as "not found" during discovery, not as a silent misinstall — but it is the one rule here not read directly from evidence.

  3. The extension is the caller's, taken verbatim. The encoder never derives an architecture name: the vocabulary changes between releases, so a table here would be wrong within a release or two. Whoever knows the architecture — a listing, or a future map — supplies it.

  4. The encoder checks its own output by decoding it. If ParseVersionDirectory of the produced name does not return the input's numbers, suffix and extension, encoding fails. The guarantee is one-way: names the encoder produces always decode back.

  5. A repository-derived minor bound settles an ambiguous identifier. Reading a name is best-effort: it does not accept every reading a token could structurally be. A token's digits often admit more than one split — qt5_5152 is 5.15.2 or 5.1.52, qt6_6810 is 6.81.0 or 6.8.10 — and no rule over the digits separates them; a rule that refused every token with two legal splits would refuse 95 of the corpus's four-digit names. A candidate is therefore checked against a per-major bound derived from the repository corpus: a reading whose minor is above the highest minor the repositories spell for that major is refused.

    The bound guards discovery. It is neither a version ceiling nor an upstream format rule — nothing in the layout requires it and the reference tool has no equivalent — so it is maintained here and nowhere else. maxMinor records that Qt 6 spells minors up to 12: a fact about the directory names the repositories publish, not a claim about which releases exist or ever will. The patch is not bounded, because a patch is a plain count with no competing reading, so qt6_695 is 6.9.5.

    The consequence is deliberate, and it is the reason for the rule: the minor domain is a snapshot, so qt6_6130 is refused while Qt 6 is bounded at 12, and a release with a new minor needs the bound widened — visibly — before its directory can be read. Without the rule, qt6_6810 would mean 6.81.0 today and 6.8.10 the day that release exists, so one name would answer two questions.

    The acceptance criterion stays mechanical: every captured directory name must re-encode to itself.

  6. The encoder returns one segment and no path. EncodeVersionDirectory answers "how is this version spelled"; where that segment sits in the repository is discovery's question, and a filesystem path is the installer's. Candidate paths belong to the discovery layer as an ordered probe list — at least the three shapes the fixtures already cover (<ver>/Updates.xml, <ver>/<ver>/Updates.xml, <ver>_<ext>/Updates.xml), growing as upstream evolves, with the listing and Updates.xml as the authority. Shapes are probed, never frozen; that belongs to its own record and work item.

    The order of questions is therefore fixed: version → spelling → repository path → filesystem path. Each layer answers one, and none answers the next one's.

Consequences

  • The three-digit form of 5.3.0–5.8.0 differs from the reference tool, which drops the patch only for 5.9.0 and would write qt5_530_src_doc_examples — a directory that exists nowhere. The divergence gets its row in docs/compatibility.md in the same change that implements this.
  • Versions the spelling cannot carry fail loudly rather than producing a wrong name: a two-digit major, or a one-digit minor with a two-digit patch (6.8.12, whose digits could decode as 6.81.2).
  • Callers compose a path from discovered segments plus host and target; they never assemble a version's spelling themselves. ADR-001's "the layout spells a version back into a path" is amended to "into a directory segment" to match decision 6.
  • A name the encoding domain does not cover is refused rather than read, so a new upstream spelling arrives as a visible failure instead of a plausible-looking wrong version. Widening it is then a corpus change: add the name, raise the bound, and the tests say what else moves.
  • The reference tool reads qt6_6810 as 6.81.0; this build refuses it. That is a deliberate difference and gets its row in docs/compatibility.md.
  • The bound is a constant, so it can go stale in the other direction too: a live directory whose minor is above it is a release to notice, not merely a name to refuse. A monitoring probe over the live listings — flag a directory the decoder rejects — is how that gets noticed, and stays a follow-up.
  • Disambiguating from the metadata instead of from a bound is the way out of that upkeep, and is deliberately not taken here. Every Updates.xml carries the authoritative <Version>, so a candidate can be confirmed rather than trusted — but confirming every candidate while listing would turn a local parse into a crawl of the repository. It belongs behind an explicit request: a resolution step with a heuristic half and an authoritative half, the way a name server has one.
  • The encoder is not the decoder read backwards, and is not defined by it. It generates the path a request is made from, by this project's rules; decoding its own output is a check on those rules, not the source of them. Parse(Encode(v)) == v is a property worth having, not the specification — and upstream offers no such guarantee for its own spelling either.

Alternatives considered

  • A per-version special case for 5.9.0. Rejected by the corpus: the two-digit spelling is a 5.x family behaviour with a two-digit minor, and a special case for one member of the family mis-encodes the rest.
  • A hardcoded architecture table. Rejected: the vocabulary drifts between releases, so the table would silently produce names no repository has.
  • Freezing the path shapes. Rejected: nesting varies by host, target and version; discovery already answers it authoritatively, and the decision to probe rather than assume predates this record.
  • Writing every version as major+minor+patch. Rejected: it writes qt5_590, which exists nowhere, and cannot produce qt5_53_src_doc_examples or any preview at all.
  • A permissive decoder, choosing the reading that fits. Rejected: qt6_6810 would decode as 6.81.0 today and as 6.8.10 the day that release exists, so one directory name would mean two versions over time.
  • Refusing every token with two legal splits. Rejected by the corpus: qt5_5152 and qt6_6110 are ambiguous in exactly the same way as qt6_6810, so the rule would refuse 95 of the 269 captured names. Structure cannot decide this; a numeric bound can.
⚠️ **GitHub.com Fallback** ⚠️