ADR-030: Code-Quality Gating — Enforced Lint Posture and Debt Ratchet
On this page
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
-
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— forcechecked_*/saturating_*/wrapping_*), IO surface (print_stdout,print_stderr— route throughtracing), thepedantic,cargo, andnurserygroups (atdeny, priority −1 so individual sub-lints can be allow-listed), complexity (cognitive_complexity,too_many_lines), and the hygiene lintswildcard_enum_match_arm,partial_pub_fields,allow_attributes_without_reason,let_underscore_must_use,ignored_unit_patterns; plus rust-levelunused_must_use = denyandunsafe_code = deny(deny, notforbid, so a SAFETY-commented[expect]can cover the legitimateenv::set_varsites 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) andmultiple_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. -
clippy.tomlthresholds.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). -
A monotonic
quality-budgetsdebt ratchet (port craig’sxtask quality-budgets). A catalogue of debt counters (oversized modules/functions, untypedserde_json::Value,#[allow]count,.unwrap_or_default(), duplicate dep versions, …) each with athresholdand a.lockfloor captured by--write-lock. The enforced ceiling is lock-authoritative:if locked > 0 { locked } else { threshold }.--fail-on-regressionfails the build if any counter’s live count exceeds its ceiling. The ratchet only descends:--write-lockre-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. -
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) staydenyso newly-stabilized lints surface for triage rather than being silently muted. Deferred sweeps go to named successor plans, never a vague follow-up. -
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. -
Production strictness is absolute; test code carves out narrowly. Tests legitimately use
.unwrap()/.expect()/indexing/panic!as assertion scaffolding.unwrap/expectare covered by theclippy.tomlin-test toggles; the remaining panic/index/print/overflow lints need a#[cfg(test)]-scoped allow (clippy[workspace.lints]cannot expresscfg(test)). Canopy centralizes this carve-out as far as the tooling allows to avoid craig’s ~31×-repeated crate-root header. -
Wiring — the gate is the pre-push
cargo xtask validatebattery, with CI parity.validateruns (in order)fmt --check,cargo machete(unused deps),clippy --workspace --all-targets --locked — -D warnings(which now also enforces the[workspace.lints]denies), thequality-budgets --fail-on-regressionstep (blocking),build, andnextest. CI runs the same battery via--skip-devstack --skip-dockeras 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-crateforbidheaders; 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
denyis 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.