# 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`](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:
```toml
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:
1. **wrong source** — `F_loc` where the predicate meant `F_pub`
2. **wrong quantification** — `E` scoped 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′** — `E` quantified 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_loc` where they mean `F_pub`, or
> `M_r` where 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.