Release invariants for a multi-repo workspace constellation
What the release upholds, what it does not yet uphold, and how we know which is which.
dry-run.md describes the procedure — the steps, the two gates, the formal model of a run. This document describes the properties a run must guarantee, independently of how it is sequenced.
Every claim below about the code was read out of the code. Where something is listed as absent, that absence was measured, not assumed — three invariants first written here as "missing" turned out to be implemented, which is why the distinction is stated for each.
The shape of the problem
We publish to crates.io from a constellation of separate git repositories, each holding its own cargo workspace. Crates depend on each other across repo boundaries:
korp-collectors = { path = "../korp-collectors", version = "0.1.0" }
Locally, cargo resolves through path. For every consumer outside this machine, only version exists. That asymmetry is where release bugs live: the local build proves nothing about the published artefact, and it looks exactly like proof.
The model
R repos, each with a cargo workspace
M_r workspace members of repo r
P = { c ∈ ⋃M_r : publish ≠ false } publishable crates
E dependency edges (c → d, kind, requested_features, default_flag,
target_cfg, optional, weak)
kind ∈ {normal, build, dev}
v_loc(c) version in the manifest
V_pub(c) versions on the registry
F_loc(c) [features] table in the manifest
F_pub(c, v) features of the PUBLISHED version v
S(c → d) features c requests of d
F_loc ≠ F_pub. Every predicate in this document names which one it means. A gate that says "features" without saying which is the bug described in the next section.
The failure this document exists because of
korp-collectors had eight published versions. The manifest declared 14 features; the published versions carried 8. Six had never existed on the registry:
daemon · gribnc · osm-daemon · osm-daemon-download · systemd · znippy
korp — in a different repo — requested two of them. It built green here, because the path key shadowed the registry, and it was unresolvable for everyone else. Nothing was rejected. Nothing was yanked. main simply still carried the version already published, so features added after that publish never shipped.
The gate for this already existed and stated the condition correctly. Its own doc comment demanded the published feature list. The builder feeding it read each member's local [features]. So it asked whether the source tree it was about to publish contained the feature the consumer wanted. It did. Green by construction.
Two independent causes were required for it to survive:
- wrong source —
F_locwhere the predicate meantF_pub - wrong quantification —
Escoped to one workspace, so a cross-repo consumer was never in the set being checked
Fixing either alone changes nothing. That is how eight releases passed.
Invariants upheld today
| invariant | note | |
| I1 | Order. Publish order is a topological sort of E restricted to kind ∈ {normal, build}. |
dev is excluded deliberately: cargo strips path at publish but keeps version, so a dev back-edge is not a cycle. |
| I2 | Version reality. For every c published at v, main carries v. |
Was broken: a ramp branch that published 49 crates was never merged, so main still carried pre-bump versions and the next release would have attempted already-taken ones. |
| I4 | Symbol reality. API-skew measured against published artefacts. | Already wired to the registry. This falsifies the tempting generalisation that "every gate measures the worktree". | | I5 | Drift. Content at v must equal published content at v, else a bump is required. | Caught 61 drifted crates in one repo at an unchanged version. | | I6 | Metadata. |description| in bytes ≤ 1000. | crates.io documents this in characters and enforces it in bytes. A 996-character / 1004-byte description is a permanent HTTP 400. | | I10 | default-features. Do not synthesise an implicit default request from a consumer declaring default-features = false. | Synthesising it produces a false red on exactly the crate that motivated this work. | | I13 | Target-cfg dependencies. [target.<cfg>.dependencies] and [target.<cfg>.build-dependencies] are read. | Reading only the top-level tables lost 45 intra-constellation edges. A lost edge is a wrong wave, not a missing footnote: waves publish in parallel, so a crate placed beside its own dependency fail-stops the cascade. Cfg-gated edges are taken unconditionally — a crate may be ordered later than a host strictly needs (costing a wave), but ignoring the edge risks a failed publish. | | I15 | publish = false members are excluded from the publish set. | |
Implemented, wired, and not in force
An invariant that exists but does not run is not upheld. Listing it beside the ones that do run would repeat this document's own thesis as a mistake, so it gets its own table.
| invariant | state | |
| I3 | Feature reality. ∀(c→d) ∈ E, ∀f ∈ S(c→d): f ∈ F_pub(d, v_ship(d)). |
Implemented as gate_feature_soundness, wired through gate_a_wiring::merge_into, red-probed in both directions. But the Gate-A legs are opt-in and default OFF (release_doctor/mod.rs:448), deliberately, so that no previously-green release turns red without someone choosing it. Until a leg is enabled by default or by the run, a release can still ship the defect this gate was built for. |
The default-off choice is defensible on its own terms — a gate that turns an existing pipeline red without warning is its own kind of damage. What is not defensible is reporting the invariant as upheld while the leg is off. The distinction between implemented, in force, and upheld is the one this table exists to keep.
Invariants measured as absent
| invariant | how the absence was measured | |
| I7 | Resolvability. After publishing, resolve each consumer against the registry with no path shadowing and confirm it builds. | No such step exists. This is the only measurement that answers can anyone outside this machine build this. |
| I8 | Weak features. pkg?/feat activates only if pkg is already enabled. |
Zero occurrences in the codebase. Read as strong, a graph pulls in the wrong set. |
| I11 | required-features on bins/examples/tests. |
The identifier required_features does appear — as an unrelated internal field. A grep says "present" and is wrong. Consequence measured in the same constellation: cargo build --bins built 4 of 9 binaries and exited 0. |
| I12 | Resolver version. resolver = "2" / "3". |
Never read. The word "resolver" appears 39 times in the unrelated sense. Resolver version changes feature unification for build-, dev- and target-dependencies, so a wrong assumption is wrong globally, not locally. |
| I14 | Workspace inheritance. version.workspace = true, features via [workspace.dependencies]. |
Zero occurrences. |
Proposed, not yet implemented
- I3′ —
Equantified over the whole cross-repo graph, not per workspace. - I9 — renamed dependencies: key the graph on the package name, not the alias.
- I16 — removing a feature is breaking, even when all code still compiles.
- I17 — a feature present locally but never activated in any published combination is never compiled. Related observed instance: 94 binary tests that could not compile under the feature set they were assumed to run under.
- I18 — edge loss must be an assertion, not a discovery. The 45 lost edges above were found by a human reading code. The edge count in the graph and the path-dependency count in the manifests are two numbers that must agree; a discrepancy must fail the run. Otherwise the next unread manifest table is found the same way: by a failed cascade.
The pattern worth generalising
The root defect was not a wrong condition. It was a correct condition fed from the wrong source. The useful question is therefore not "is this gate right?" but:
Which gates state the right predicate but read
F_locwhere they meanF_pub, orM_rwhere they mean the whole graph?
A corollary that applies to this document: a specification written by hand and verified against the code afterwards is itself a check that cannot fail. It drifts from the code exactly as F_loc drifted from F_pub. The intent is for the release tool to emit this document from a machine-readable registry of its own gates, so it cannot lie.
Today the gates are individual gate_* functions with no registry, and the run drives them from a hand-written call chain — so a gate can exist without running, or run without being listed. Building that registry, and driving the run from it, is the precondition for this file being generated rather than maintained.
The declaration layer
Least developed, and stated here so the gap is visible.
A workspace declares what may be released through its changelist (.nornir/release-<date>-changelist.md), carrying the bump policy and the ramp branch. Detached (non-root) workspace members are modelled and can be released as a subset.
The open question: when only part of the constellation is released, which invariants still hold globally and which become vacuous? I3′ cannot be enforced by a subtree release that does not own the consumer. Whether that is a hole or a legitimate boundary is undecided — and until it is decided, it should be stated rather than assumed.