release doctor — cargo/crates.io rejection-coverage matrix
Durable loop state for the nornir release doctor preflight-hardening effort. Every known way a cargo publish / crates.io upload can be REJECTED or go wrong, mapped to COVERED (which check + file:line) or OPEN / RUNTIME-ONLY.
Preflight framework: src/release/preflight.rs (IssueKind + detector fn + run() wiring + unit test each); src/release/registry.rs (crates.io probes: version-live, ownership, published-max). Doctrine: best-effort — a check whose tooling is unavailable degrades to a NOTE, never a false block.
Authoritative sources consulted: Cargo Book (manifest/publishing/registry-web-api), crates.io src/controllers/krate/publish.rs, cargo cargo-util-schemas restricted_names.rs. Key exact rules captured inline below.
Legend: ✅ COVERED · 🟡 PARTIAL (needs refinement) · ⬜ OPEN · 🔵 RUNTIME-ONLY (backend/network-only; can't be preflighted deterministically).
Matrix
| # | Rejection class | crates.io / cargo rule | Status | Check (IssueKind / fn @ file:line) |
| 1 | Non-member -p target (detached [workspace]) |
cargo 101 "did not match any packages" | ✅ | NonMember / run @ preflight.rs:270 |
| 2 | Missing required metadata (description/license/repository) | crates.io: description+license required | ✅ | MissingMetadata / missing_metadata_fields @ preflight.rs:701 |
| 3 | Dep-API skew (edited-without-bump upstream) | verify build uses unpublished API | ✅ | VerifyFailed / verify_one @ preflight.rs:869 |
| 4 | Version already live (immutable clash) | "crate version X is already uploaded" |
✅ | RegistrySkew / registry_skew_issue @ preflight.rs:745 |
| 5 | Local version BEHIND registry (desync) | dependent verifies newer published API | ✅ | RegistryDesync / registry_desync_issue @ preflight.rs:778 |
| 6 | Versioned path dev-dep on sibling | resolved on publish though dev-deps stripped | ✅ | VersionedPathDevDep @ preflight.rs:567 |
| 7 | Path dep without version |
stripped path → version-less dep rejected | ✅ | UnpublishableDep / gate::path_dep_audit @ preflight.rs:367 |
| 8 | Versioned dep on publish=false sibling |
sibling never on crates.io | ✅ | UnpublishableDep / extra_manifest_gaps @ preflight.rs:666 |
| 9 | readme path missing |
cargo can't read declared readme | ✅ | BadManifest / extra_manifest_gaps @ preflight.rs:607 |
| 10 | Keyword count >5 / per-keyword >20 / bad chars / bad start | max 5, ≤20, start alnum, [A-Za-z0-9_-+] |
✅ | BadManifest / extra_manifest_gaps @ preflight.rs:618 (refined: start-alnum + +) |
| 11 | Feature refs dep:foo not in [dependencies] |
cargo "dep:foo not a dependency" | ✅ | BadManifest / extra_manifest_gaps @ preflight.rs:640 |
| 12 | Same-version source drift (edited, not bumped) | dependent resolves stale registry copy | ✅ | StaleSourceDrift / source_drift_issues @ preflight.rs:439 |
| 13 | Crate name owned by someone else (squat) | 403 "crate exists but you don't own it" | ✅ | ownership_blocks @ registry.rs:340 (wired nornir.rs:11408) |
| 14 | Wildcard * dependency constraint |
"wildcard (*) dependency constraints are not allowed on crates.io" |
✅ | BadManifest / extra_manifest_gaps (for_each_published_dep) @ preflight.rs:687 |
| 15 | Git dependency without version |
publish requires version on git/path deps | ✅ | UnpublishableDep / extra_manifest_gaps @ preflight.rs:707 |
| 16 | Invalid / non-SPDX license expression | "unknown or invalid license expression" (spdx crate); / separator invalid |
✅ | BadManifest / spdx_license_problem + license_spdx_issue @ preflight.rs:596 (wired run @ ~305) |
| 17 | Crate name invalid (len>64, charset, first-char, reserved/Windows) | ASCII [A-Za-z0-9_-], start alpha, ≤64, no reserved/Windows-device names |
✅ | InvalidCrateName / crate_name_problem @ preflight.rs:596 (wired run @ ~272) |
| 18 | Category count >5 (hard error) | "expected at most 5 categories per crate" | ✅ | BadManifest / extra_manifest_gaps @ preflight.rs:~645 |
| 19 | license-file path missing (or non-UTF-8) |
cargo can't read declared license-file | ✅ | BadManifest / declared_file_gap @ preflight.rs:~615 |
| 20 | README not valid UTF-8 | cargo reads readme as UTF-8 string | ✅ | BadManifest / declared_file_gap @ preflight.rs:~615 |
| 21 | URL field not http(s):// (repository/homepage/documentation) | "URL for field must begin with http:// or https://" | ✅ | BadManifest / extra_manifest_gaps @ preflight.rs (url block) |
| 22 | Version string not valid semver | "X is an invalid semver version" |
✅ | BadManifest / run() version-parse guard @ preflight.rs |
| 23 | edition not a known value (2015/2018/2021/2024) |
cargo manifest parse error | ✅ | BadManifest / extra_manifest_gaps (edition block) |
| 24 | rust-version malformed |
must be digits/periods only, no ^/pre-release |
✅ | BadManifest / extra_manifest_gaps (rust-version block) |
| 25 | description >1000 chars | "The description is too long" |
✅ | BadManifest / extra_manifest_gaps (description block) |
| 26 | links value collision (two crates same links) |
links must be globally unique | ✅ (intra-release) | BadManifest / duplicate_links_issues @ preflight.rs; cross-registry claim 🔵 |
| 27 | Tarball >10 MiB (upload size) | "max upload size is: N" (default 10 MiB) | ✅ (verify-On) | OversizedTarball / oversized_tarball_issue @ preflight.rs (inspects the dry-run .crate) |
| 28 | Feature refs a missing feature/dep name | cargo "feature a includes b which is neither" |
✅ | BadManifest / extra_manifest_gaps (f) + manifest_all_dep_names |
| 29 | Cross-registry dependency (registry=) |
"Dependency hosted on another registry ... not permitted" | ✅ | BadManifest / extra_manifest_gaps (cross-registry block) |
| 30 | Invalid category SLUG (not in category_slugs) | WARNING only (crate still publishes) | 🔵 | advisory-only; not a hard reject |
| 31 | Too-similar name (differs only by -/_/case) |
crates.io 400 | 🔵 | backend-only; needs live index probe |
| 32 | Reserved-name table (dynamic) | "reserved name" | 🔵 | backend table; Windows-device subset preflightable in #17 |
| 33 | Rate limit (versions/24h, new crates/week) | 429 / "too many versions" | 🔵 | already handled at publish w/ retry (publish.rs) |
| 34 | Unverified email / no token | "verified email required" / 403 | 🔵 | token/login checked in ownership preflight |
| 35 | Max features / max deps per crate | configurable caps (large) | 🔵 | limits are high + server-configurable |
| 36 | Versioned CROSS-REPO path= masks the published crate |
local build resolves the path, never the registry version — a fresh consumer / --verify uses crates.io |
✅ (advisory) | path_masks_published_notes / cross_repo_versioned_path_deps @ preflight.rs (NOTE; drift-in-release-set already blocks via #12) |
| 37 | PROACTIVE dep-API skew (member calls dep::sym absent from the published dep) |
the E0425 cannot find … in crate <dep> a cargo publish --verify finds after a multi-minute build — the release-night skade::ingest_multi (present in local skade 0.5.1, not published 0.5.0) |
✅ (verify-On) | ApiSkew (CORRECTNESS blocker) / api_skew_issues + api_skew::{referenced_symbols,public_api_names,missing_symbols} @ preflight.rs — STATIC form of #3: for each sibling path-dep that resolves to an already-published version (not being republished this run), download the published .crate, extract its public API, diff the member's referenced symbols. Caught in ms, before any verify build. |
| 38 | FORWARD semver hazard (OUR bump under-states a break WE ship) | a removed/renamed/retyped pub item under a patch/minor bump → every downstream compile-breaks on their next resolve; crates.io is immutable so the old version breaks them forever (the cargo-semver-checks/release-plz class) |
✅ (P1) | SemverHazard / gate_a_wiring::merge_semver + semver_checks::{assess_targets,bump_between,understates,parse_required_bump} — SHELLS the cargo-semver-checks dev-tool binary via the SemverProber seam (pure verdict core; lib needs nightly rustdoc + a heavy stack, impractical in a lean forge). Reverse of #37. Tool absent/unparseable ⇒ note, never a false block. GateALegs.semver DEFAULT-ON; --gate-a semver. |
| 39 | Live-but-vulnerable / advisory-yanked TRANSITIVE dep (RUSTSEC/OSV) | a Cargo.lock-pinned dep with a known CVE / unsound / advisory-yank still builds — our resolver-level yank check reads the INDEX and misses it (the "we shipped a CVE" class) |
✅ (P2) | VulnerableDep / gate_a_wiring::merge_advisory + advisory::{classify,assess_lockfile} — REUSES the rustsec library (the engine cargo-audit drives), default-features = false (no reqwest) over a VENDORED advisory-db (Database::open, hermetic). Vulnerable/unsound/yanked = HARD; unmaintained/notice = note. Missing DB/lockfile ⇒ note. GateALegs.advisory DEFAULT-ON; --gate-a advisory|rustsec|audit. |
| 40 | Disallowed license (SPDX allowlist) | a copyleft/unapproved license SPDX expression sneaking in — presence-only metadata (#2/#16) checks it EXISTS, not that it is PERMITTED (the cargo-deny licenses class) |
✅ (P3, opt-in policy) | PolicyViolation / packaging_lint::classify_license — REUSES the spdx crate's id list + boolean evaluator, LAX-parsed (accepts /-as-OR + deprecated ids real manifests carry). Empty allowlist ⇒ OFF (no policy ⇒ no opinion). Rides the DEFAULT-ON packaging leg. |
| 41 | Banned crate in the resolved graph | a deny-listed crate anywhere in Cargo.lock (the cargo-deny bans class) |
✅ (P3, opt-in policy) | PolicyViolation / packaging_lint::classify_lockfile_policy — REUSES rustsec::Lockfile to read the lock; empty banlist ⇒ OFF. Rides the packaging leg. |
| 42 | Duplicate crate versions | one crate at ≥2 versions → tree bloat / wider audit+MSRV surface (cargo-deny bans multiple-versions) |
✅ (P3, advisory) | packaging_lint::classify_lockfile_policy → ADVISORY DuplicateVersion (note, never blocks); DEFAULT-ON. |
| 43 | Dep silently raises MSRV / docs.rs build break | a dependency whose own rust-version exceeds our declared floor lifts our effective MSRV above what we promise (row was 🟡); and an --all-features docs.rs build that fails AFTER the irreversible publish (immutable version, broken docs forever) |
✅ (P4) | edition.rs: dep_msrv_advisories (advisory dep-MSRV-raise, DEFAULT-ON) + verify_msrv_build (cargo +<floor> check, heavy, default-OFF) + docs_rehearsal (cargo doc --all-features, heavy, default-OFF, docs_errors HARD when run) + parse_doc_errors; all behind EditionGateConfig, default keeps is_clean semantics. Toolchain/tool absent ⇒ note. |
Progress log
- seed: matrix created from existing preflight/registry checks (#1–13 COVERED).
- run 1 (this session): added #14 wildcard dep, #15 git-dep-no-version, #16 invalid SPDX license, #17 invalid crate name, #18 category-count, #19 license-file missing/non-UTF-8, #20 readme non-UTF-8, #21 non-http URL fields, #22 version semver, #23 edition, #24 rust-version, #25 description length, #26 intra-release links collision, #27 oversized tarball, #28 dangling feature refs, #29 cross-registry dep; refined #10 keyword (start-alnum +
+). - STATUS: 29 classes COVERED (#1–29, some with in-scope caveats), 6 classes RUNTIME-ONLY/advisory (#30 category-slug is warning-only; #31 too-similar name, #32 dynamic reserved table — backend/index only; #33 rate-limit handled at publish with retry; #34 email/token handled in ownership preflight; #35 max features/deps are large + server-configurable). The practical PREFLIGHTABLE surface is COVERED — no OPEN classes remain that can be checked deterministically before upload.
- run 2 (this session): added #36 — a versioned CROSS-REPO
path=dep (the funnel-core / edda-crates / dwarves class the bencher flagged in batch 23) masks the published crate, so the workspace build never validates the registry copy. Advisory NOTE (never a false block): the cascade still resolves via verify-strips-path, and a drifted crate that is IN the publish set is already a hardStaleSourceDriftblocker (#12). Enumeration is pure + tested (cross_repo_versioned_path_deps), the note carries the fix (droppath=or gate behind[patch.crates-io]). - run 3 (this session): workspace-wide dry-run loop over
workspace_nordisk(holger now green → gets past holger). No NEW rejection class needed — the remaining blockers are the already-covered #5 RegistryDesync (sibling trees like facett behind their published versions) + #6 VersionedPathDevDep. Made the GUIDANCE excellent instead (design §4 "support the user doing a release"): #5's fix now names the concrete lead-the-registry target (one patch past published) + the exact per-crate command (--only <crate>) + thegit pullstale-tree path; andPreflightReport::format()emits a consolidated RELEASE READINESS footer — one copy-pasteable--bump patch --only …command per repo. #6 is now AUTO-HEALABLE viarelease doctor --fix(doctor::fix_versioned_path_dev_deps). - New IssueKind variants this run:
InvalidCrateName,OversizedTarball. All other new checks reuseBadManifest/UnpublishableDep. Every check has a unit test; gated withcargo test --lib --no-default-features "release::"(the default feature set pulls the externally-broken, mid-refactorembed-holgersibling — --no-default-features isolates the release module, which is feature-independent). - run 4 (this session): #37 PROACTIVE dep-API skew as a real Gate-A leg — new
ApiSkewCORRECTNESS blocker +preflight::api_skew_issues, wired intoverify_member_suiteunder verify-On. Turns the release-nightskade::ingest_multitrap (anE0425acargo publish --verifyonly finds after a multi-minute build) into a millisecond static diff: for each sibling path-dep resolving to an already-published version, download the published.crate(reusingsource_drift::CratesIoSources), extract its public API, diff the member's referenced symbols under the manifest rename alias. Download failure ⇒ note, never a false block (verify build backstops). Beyond the preflight matrix, two CRASH-SAFETY legs landed for errors-book ch. XII: (a)promote::runnow finalizes per-repo the instant its publish cascade succeeds (BranchGuard::finalize→ restore base branch + delete ephemeral, lock-churn discarded FIRST so the switch can't fail) so a later repo's fail-stop never strands an already-published repo; (b) a startuprelease_doctor::reconcilesweep detects + idempotently recovers leftoverrelease/promote/release/workspace-release/*branches from a prior crashed run. - run 5 (2026-07-18, task #67 — the CODE/POLICY-ANALYSIS leg): closed the systematic blind spot the release-mega-analysis (
workspace_nordisk/.nornir/release-mega-analysis- 2026-07-18.md) named — the engine was a superb graph/registry prover with essentially no code/policy-analysis leg. P0: flipped the already-builtfeature_soundness+skew(api-skew) Gate-A legs ON by default (GateALegs::default) — they shipped OFF and never ran, the exact checks written for theskade::ingest_multi/holger-server-librelease-night traps. P1 #38 FORWARD semver hazard (semver_checks, shellscargo-semver-checks). P2 #39 advisory/RUSTSEC over the locked graph (advisory, reuses therustseclib over a vendored DB, no reqwest). P3 #40–42 cargo-deny-class license-allowlist (reusesspdx) + banned-crate + duplicate-version inpackaging_lint. P4 #43 real MSRV verify + dep-MSRV-raise detection + acargo doc --all-featuresdocs.rs rehearsal inedition.rs. Every leg: a PURE binary-free core (unit-tested, red-when-broken via a tripping fixture) + a thin best-effort runner that degrades to a NOTE when its tool/DB/toolchain is absent (never a false block); each is a toggle onGateALegs/EditionGateConfig, individually scopable via--gate-a, and defaults sensibly (correctness/cheap legs ON; heavy toolchain/container passes — MSRV-build-verify, docs rehearsal — OFF). Deferred to a follow-up (per the task): the release-bench-in-a-jera-container harness (mega-analysis Part 3 / P4 #11) and threading planned-bump + published-version per crate into the live cascade call sites for the semver/advisory runners (the legs register + lower today via their dispatch seams, same pattern as the skew warehouse-version ingest still owed). - run 6 (2026-07-18): the BINARY-analysis Gate leg —
release_doctor/binary_gate.rs. Every other Gate-A leg reasons over SOURCE; this one reasons over the COMPILED artifact: diff a member's freshly-built binary against its PRIOR released artifact and fail on a size-budget regression (size_regression), an ABI-surface removal (abi_removed), or a known CVE in the binary-derived SBOM (cve) — the bloat/dynamic-ABI class a source gate structurally cannot see. Shell, don't link: the verdict already exists as the agent-callablenornir bin gate <base> <new> --json(nornir'sbinscan), so the leg SHELLSnornirthrough the injectableBinaryGateProberseam and parses the JSON (parse_gate_verdict, pure) rather than pulling a heavy DWARF/object stack into the lean forge.norniroff PATH / no prior artifact / unparseable verdict all degrade to an INCONCLUSIVE note (BinaryGateProbe::Unavailable), never a false block; a real finding blocks (forceable). Default-OFF (opt-in): it needs a built artifact + the prior binary, so it never fires on a plain green source release. Whole leg unit-testable (red-when-broken) with a stub prober — nonornir, no real binary. Also this run: AUTO-RECONCILE (#47) —release_doctor/auto_reconcile.rs, two ship-version-graph legs: (a)ship_past_published_max/reconcile_past_published_maxbump PAST the crates.io max (a plan version already taken on the immutable registry is bumped to published-max + level, so--bump keepafter a partial ship can't collide); (b)api_changed_deps/reconcile_stale_consumer_reqsrewrite API-stale consumer requirements when the api-skew leg flagged a dep's surface as moved. Decision cores PURE + unit-tested; the only I/O is (b)'s formatting-preserving manifest rewrite. - run 7 (2026-07-20): two CRASH-SAFETY defects in the promote's git legs, both of which left an operator's checkout in a lying state. (a)
promote::stage_release_filesstaged the lock with a baregit add -- Cargo.lock. Most constellation library repos GITIGNORECargo.lock(facett, skade, nornir, modgunn, korp, ordning, Skidbladnir, tunnr all do), and git exits 1 on an ignored+untracked pathspec, so the--executecrate-release hard-bailed — AFTER the manifests and the lock had been rewritten and BEFORE anyBranchGuardexisted, leaving every[package].versionbumped and unreverted on the working branch. It now force-adds (-f), exactly as the shooting ramp already did. (b)stage::BranchGuard::capturereadrev-parse --abbrev-ref HEADraw. On a DETACHED HEAD (routine in CI checkouts and tag-based worktrees) that prints the literalHEAD, which was recorded as the "original branch";finalizethen rangit checkout HEADwhile ON the staging branch — a no-op — the branch delete failed, and the restore degraded to a WARN, stranding the tree on the commit that STRIPS[patch.crates-io](so later builds silently resolve the stock registry crate instead of the local fork). The same guard captured NOTHING when the repo was already on the staging branch, sofinalizewas a total no-op.capturenow reusesreconcile::current_branch(which already filters theHEADsentinel), records the concrete commit SHA when detached — preserving a bump committed while detached, which a fallback tomainwould lose — and falls back toreconcile::base_branchwhen already on the staging branch. NOTE: the startup reconciler is NOT a backstop for the crate-release path, whose default branchrelease/crate-releasematches none ofis_release_staging_branch's patterns.