ADR-030: Code-Quality Gating — Enforced Lint Posture and Debt Ratchet

On this page

Status

Accepted (2026-06-09)

Relates to

ADR-013 — this ADR applies the same enforce-don’t-assert, deny-by-default philosophy ADR-013 brought to plan lifecycle to code quality. It ports the gating model proven in the sibling CCWIS project (craig), whose exact lint posture this matches or exceeds.

Context

A ten-agent code-quality review (five readers per repo, 2026-06-09) graded canopy and craig both at A− with the same engineering DNA, but found craig consistently ahead on the enforced-gating dimensions: a 40-line function ceiling, the whole panic class denied workspace-wide, every lint suppression carrying a reason, all backstopped by a debt ratchet that fails the build on regression.

The difference is mechanism, not discipline. Canopy’s quality is conventional — achieved by careful authors, review, per-crate #![forbid(unsafe_code)] (51 crates), and a CI cargo clippy --workspace --all-targets — -D warnings. That -D warnings hardens default-level clippy + rustc warnings to errors, but it does not enable the allow-by-default restriction lints that catch panics and overflow (unwrap_used, expect_used, panic, indexing_slicing, arithmetic_side_effects, …), nor the pedantic/nursery groups, nor a function-size ceiling. Canopy has no root [workspace.lints] table and no clippy.toml. So a regression — a new .unwrap() in a request path, a 600-line handler — passes the gate today.

Two forces make now the moment to fix this. First, canopy is ATO-bound (SNAP UAT, September 2026); for eligibility software carrying legal weight, enforced panic-free and injection-free is an audit asset, not a nicety. Second, canopy is about to write its largest body of new code in a single arc — epics &56 (worker fact-authoring) and &58 (provable policy completeness), on the order of 100k LOC. It is far cheaper to write to a gate than to retrofit one onto finished code, and the worst time to lack the gate is during the highest-volume coding period. The gate must precede that work so every new line is born clean.

Decision

  1. Centralized strict lint posture via root [workspace.lints] — at least as strict as craig. Every member crate carries [lints] workspace = true; strictness lives in one place (this also retires the 51 scattered ![forbid(unsafe_code)] headers and avoids the per-crate-header smell). The denied set covers: the panic class (unwrap_used, expect_used, panic, todo, unimplemented, unreachable, unwrap_in_result, dbg_macro), index/slice panics (indexing_slicing, string_slice), overflow (arithmetic_side_effects — force checked_*/saturating_*/wrapping_*), IO surface (print_stdout, print_stderr — route through tracing), the pedantic, cargo, and nursery groups (at deny, priority −1 so individual sub-lints can be allow-listed), complexity (cognitive_complexity, too_many_lines), and the hygiene lints wildcard_enum_match_arm, partial_pub_fields, allow_attributes_without_reason, let_underscore_must_use, ignored_unit_patterns; plus rust-level unused_must_use = deny and unsafe_code = deny (deny, not forbid, so a SAFETY-commented [expect] can cover the legitimate env::set_var sites in xtask under Rust 2024). The exact lint table — including the priority-1 escape-list and its required rationale comments — is the implementation artifact of the plan; the policy is this posture is the floor, and it may only ratchet stricter. Per the 2026-06-09 calibration sign-off canopy adopts the exceed-craig stance: the pedantic-noise sub-lints (module_name_repetitions, must_use_candidate, missing_errors_doc, missing_panics_doc) and the nursery escape-hatches that craig allow-lists for transition-noise are instead denied here. Only structural exceptions escape — cargo_common_metadata (an unpublished workspace would otherwise demand fake crate metadata) and multiple_crate_versions (transitive-dep skew, tracked in the ratchet instead) — plus any individual lint that measurement shows has >10 genuinely-low-value emissions, allow-listed with a rationale comment per §4.

  2. clippy.toml thresholds. too-many-lines-threshold = 40 (match craig’s ceiling); allow-unwrap-in-tests = true + allow-expect-in-tests = true (so tests need no per-crate carve-out for those two).

  3. A monotonic quality-budgets debt ratchet (port craig’s xtask quality-budgets). A catalogue of debt counters (oversized modules/functions, untyped serde_json::Value, #[allow] count, .unwrap_or_default(), duplicate dep versions, …) each with a threshold and a .lock floor captured by --write-lock. The enforced ceiling is lock-authoritative: if locked > 0 { locked } else { threshold }. --fail-on-regression fails the build if any counter’s live count exceeds its ceiling. The ratchet only descends: --write-lock re-snapshots current counts (lowering the floor after a cleanup), and any change to the lock — up or down — must be justified in the MR. This is what makes the burndown continuous and non-regressing rather than a deferred cleanup phase.

  4. A lint-promotion / triage pattern (ports craig’s ADR-031). When a new lint or group is promoted toward deny: < 10 emissions and clear value → keep denied, sweep the fixes in the same MR; > 10 emissions and clear value → allow-list at priority 1 with a documented rationale and a filed successor-plan sweep; known-noisy / case-by-case → allow-list at priority 1. Every { level = "allow", priority = 1 } entry MUST carry a comment recording (1) the surface measurement, (2) the rationale, (3) the successor-plan pointer. Group defaults (pedantic/cargo/nursery) stay deny so newly-stabilized lints surface for triage rather than being silently muted. Deferred sweeps go to named successor plans, never a vague follow-up.

  5. Grandfather at status quo. Turning the posture on annotates each existing violation with [expect(clippy::…, reason = "…")] (or fixes it inline where cheap), and seeds the ratchet at canopy’s current debt counts. The gate goes live without a big-bang refactor: existing debt is frozen (cannot grow), all new code meets the full standard, and the grandfathered `[expect]`s + ratchet floors are burned down monotonically — partly as a side effect of touching files during feature work, partly via dedicated burndown MRs.

  6. Production strictness is absolute; test code carves out narrowly. Tests legitimately use .unwrap()/.expect()/indexing/panic! as assertion scaffolding. unwrap/expect are covered by the clippy.toml in-test toggles; the remaining panic/index/print/overflow lints need a #[cfg(test)]-scoped allow (clippy [workspace.lints] cannot express cfg(test)). Canopy centralizes this carve-out as far as the tooling allows to avoid craig’s ~31×-repeated crate-root header.

  7. Wiring — the gate is the pre-push cargo xtask validate battery, with CI parity. validate runs (in order) fmt --check, cargo machete (unused deps), clippy --workspace --all-targets --locked — -D warnings (which now also enforces the [workspace.lints] denies), the quality-budgets --fail-on-regression step (blocking), build, and nextest. CI runs the same battery via --skip-devstack --skip-docker as the single source of truth; the pre-push hook’s executable bit is guarded so a silently-neutered gate is caught.

Consequences

  • New code is born clean and regression-proof. The ~100k LOC of &56/&58 is written to the gate from the first line; the build refuses to accrete a new panic, oversized function, or un-reasoned allow.

  • One source of truth. The [workspace.lints] table replaces 51 per-crate forbid headers; no crate can silently relax the posture.

  • An ATO asset. "Production code is enforced panic-free, overflow-checked, and injection-safe" becomes a property the build proves on every push, not a claim.

  • A genuinely-needed exception is reasoned and reviewable[expect(…, reason = "…")] / priority-1 allow-with-comment — never a silent [allow].

  • Lowering the ratchet is an explicit, justified act (--write-lock + MR rationale); the floor cannot drift up.

  • Strictness is one-directional. This ADR is the decision-of-record; the code-quality-gating plan sequences the rollout. The posture may only ratchet stricter; relaxing any deny is an ADR-amending decision, not a config edit.

  • Cost paid up front: standing up the regime requires a grandfather sweep (annotate/fix existing violations) and porting the ratchet tooling before the feature work begins — a bounded, mechanical cost that the plan front-loads as its first slice.

Edit this page · default