Skip to content

Breaking-change policy ​

A policy nobody can check is not in force. This page states what may change at each version step, defines exactly what counts as public surface, and names the gate that enfor...[truncated]

What counts as public surface ​

Every crate published to crates.io. The facade re-exports leaf crates, so an item is public if a consumer can name it through ANY published path, not only through axiolid. Removing an item from a leaf crate breaks the facade re-export too.

Not public surface:

  • crates with publish = false (xtask, axiolid-benchmark, axiolid-oracle)
  • #[doc(hidden)] items
  • private modules, and anything reachable only through them
  • Cargo.toml feature names are public: a consumer writes them down

What may change at each step ​

The workspace shares one version, so the rules apply to the workspace as a whole. Pre-1.0, Cargo treats the MINOR field as the major: 0.2.x and 0.3.0 are incompatible, which is why a breaking change today lands as a minor bump and not a patch.

ChangePatch 0.2.0 -> 0.2.1Minor 0.2.x -> 0.3.0Major 0.x -> 1.0
Add an item, variant behind #[non_exhaustive], or featureyesyesyes
Fix behaviour without changing a signatureyesyesyes
Remove or rename a public itemnoyesyes
Change a signature, trait bound, or public field typenoyesyes
Add a required trait methodnoyesyes
Add a variant to an enum NOT marked #[non_exhaustive]noyesyes
Change an array length in a public constantnoyesyes
Tighten a refusal so previously-accepted input now errorsnoyesyes

The array-length row is not hypothetical. capability_ids::ALL was [CapabilityId; 9], and adding a tenth capability changed the type to [CapabilityId; 10] -- a breaking change caused by registering a value. It is now &[CapabilityId], so the vocabulary grows additively. A public collection whose LENGTH is part of its type makes every addition breaking; prefer a slice.

The gate ​

scripts/check-semver.py, wired into scripts/gate.sh, runs cargo-semver-checks against the last published release of every publishable crate. It compares the working tree to what is actually on crates.io, so it cannot be fooled by a stale baseline committed in-tree.

The rule it enforces:

A breaking change is allowed only when the workspace version has already been bumped past the published one in a way that admits it.

So a breaking change with no bump fails; the same change with the minor bump applied passes.

Run it directly:

bash
python3 scripts/check-semver.py          # gate mode
python3 scripts/check-semver.py --explain  # show each crate's baseline

A crate with no published release yet is skipped: there is no baseline to break. It joins the gate automatically on its first publish.

A crate whose working version equals a published one is checked against that release: the baseline comes from crates.io, so this compares the tree with what was actually published under that number, and catches an unreleased break before the version moves. (It used to be skipped; a break in axiolid-measure then surfaced only when its patch release was prepared.)

Exceptions ​

docs/architecture/semver-exceptions.toml accepts findings the tool gets wrong, by name: the crate, the lint and the exact item paths, with the reason each still resolves as before, and a test that proves it. The gate prints every accepted finding; anything not listed still fails, and so does a failure whose findings it cannot parse. The one entry today: axiolid_surface::BSplineSurface, re-exported from axiolid-curve (cargo-semver-checks does not follow re-exports of another crate's items).

When a breaking change is the right answer ​

This policy does not forbid breaking changes; it forbids SILENT ones. Fixing a contract that cannot express a correct answer is worth a bump. What is not acceptable is a consumer discovering the break at compile time after a patch upgrade.

Record the change in docs/CHANGELOG.md under a Changed or Removed heading, naming the replacement. A removal with no stated migration is an unfinished change.

Released under the Mozilla Public License 2.0.