Plan: Code-Quality Gating

On this page
NOTE

ADR-030 is Accepted (2026-06-09). This plan is the implementation-of-record for epic &62. Strictness was signed off on 2026-06-09: exceed-craig (deny the pedantic-noise sub-lints + nursery escape-hatches craig allow-lists; keep only the structural exceptions) with a tiered M1–M6 rollout — see Design — strictness calibration (DECIDED 2026-06-09). Every Status cell is Not started.

Status

MR Description Status

Phase A — stand up the gate at status quo (before &56/&58 feature work)

M1 (foundation)

Root [workspace.lints] skeleton + clippy.toml (40-line ceiling, in-test toggles) + replace the 51 per-crate ![forbid(unsafe_code)] with unsafe_code = deny + [lints] workspace = true in every member crate. Enable pedantic + cargo groups at deny; measure emissions, allow-list >10-count lints with rationale per ADR-030 §4, grandfather the rest with [expect(reason)].

Done (2026-06-09) — 54 ![forbid(unsafe_code)] headers centralized to unsafe_code = deny + [lints] workspace = true across 56 members; pedantic + cargo enabled at deny. Measured surface = 5,639 emissions / 65 lints; cargo clippy --fix cleared ~5,567 mechanically, 34 high-count lints (>10) transition-allowed with per-lint counts + Phase-B burn-down note (NOT craig’s permanent allow-list), 72 residual sites [expect]-grandfathered (38 reason-bearing attributes), 1 unsafe_code test allow (Rust-2024 env::set_var). Gate green: cargo clippy --all-targets --profile test — -D warnings exits 0.

M2 (panic class)

Enable unwrap_used, expect_used, panic, todo, unimplemented, unreachable, unwrap_in_result, dbg_macro. Production already ~6 unwrap`s; grandfather the startup `.expect()`s (#[expect]` or convert). Add the cfg(test) carve-out where clippy.toml toggles don’t cover.

Done (2026-06-09) — all 8 enabled at deny (todo/unimplemented/dbg_macro were already clean → enforced free). Measured surface confirmed the plan’s prediction: exactly 6 prod unwrap`s. Carve-outs (documented, reason-bearing): `tools/canopy-seed (3 bin/lib roots — demo-data generator unwraps known-good literals) + crates/canopy-test-lib (panic/expect IS the fixture/assertion surface) at crate level; 62 integration-test/bench crates get a root ![allow(unwrap/expect/panic/unwrap_in_result)] (they compile without cfg(test), so the clippy.toml in-test toggles don’t reach helper fns). The 226 remaining prod sites are function-level [expect] with honest per-site reasons (startup-fatal config / compile-time-known invariant / test-assertion / Phase-B Result-conversion candidate) — the panic class stays enforced for all new prod code. Gate green: -D warnings exits 0.

M3 (index / overflow / IO)

Enable indexing_slicing, string_slice, arithmetic_side_effects, print_stdout, print_stderr. Convert/#[expect] production sites; extend the cfg(test) carve-out.

Done (2026-06-09) — split by surface size. The slice/index/overflow class (indexing_slicing 989, arithmetic_side_effects 403, string_slice 47 prod) is a large mechanical bulk-convert surface (arr.get(i) / checked_*), so it is enabled but transition-allowed workspace-wide with counts + a B4 burn-down note (like M1’s high-count lints). The IO-hygiene lints (print_stdout/print_stderr) are enforced: crate-level carve-outs for the CLI (canopy-cli), dev tooling (xtask, canopy-seed), and test-support (canopy-test-lib) which write CLI/test output by design; per-crate ![allow] on the integration-test crates that emit (they compile without cfg(test)); and function-level [expect] on the ~47 remaining sites (almost all inline #[cfg(test)] diagnostics; the one genuine prod site is telemetry.rs’s shutdown `eprintln, where the tracing subscriber is being torn down). Gate green: -D warnings exits 0.

M4 (complexity + hygiene)

Enable too_many_lines (40), cognitive_complexity, wildcard_enum_match_arm, partial_pub_fields, let_underscore_must_use, ignored_unit_patterns, allow_attributes_without_reason, missing_docs_in_private_items. The oversized files (case_detail.rs 3,792 / canopy-medicaid/src/main.rs 935) get #[expect] here and are split in Phase B. missing_docs_in_private_items is the largest expected emission surface — measure + grandfather.

Done (2026-06-09) — too_many_lines + ignored_unit_patterns were already transition-allowed (M1 pedantic group). Of the six added: partial_pub_fields (1 prod struct + 5 test-support) is small → enforced (the prod Provenance struct gets a documented [expect]author is intentionally private per ADR-027; test-lib crate-allow). The five high-count / refactor / documentation lints are transition-allowed workspace-wide with measured counts + a B-phase flip-to-enforced note: missing_docs_in_private_items (1321 — the expected giant), allow_attributes_without_reason (159 pre-existing bare [allow]), cognitive_complexity (89), wildcard_enum_match_arm (61), let_underscore_must_use (64). The oversized-file split + the doc/wildcard/complexity burn-down are B-phase (B1/B4). Gate green: -D warnings exits 0.

M5 (nursery)

Enable the clippy::nursery group at deny (priority −1). Exceed-craig: do not inherit craig’s nursery allow-list — deny by default and sweep; allow-list an individual nursery lint only if measurement shows >10 genuinely-low-value emissions, with rationale per ADR-030 §4. Group default stays deny.

Done (2026-06-09) — nursery enabled at deny (priority −1). Measured 2,096 emissions / 21 lints, triaged by the §4 rule (not inherited wholesale). Permanent low-value allows (each >10 + genuinely low-value, per-lint rationale): missing_const_for_fn (544), derive_partial_eq_without_eq (397), option_if_let_else (133), future_not_send (24), significant_drop_tightening (18). Transition allows (high-value, high-count → Phase-B sweep then flip-to-enforced): too_long_first_doc_paragraph (448), use_self (244), literal_string_with_formatting_args (175), redundant_clone (40), redundant_pub_crate (34), or_fun_call (12); plus trait_duplication_in_bounds (8) which is workspace-allowed not because of count but because every emission is inside [derive(Serialize/ToSchema)]-expanded code (the duplicate bound isn’t in our source, so an item-level [expect] can’t fulfill against it). The small lints (≤10) are otherwise enforced: cargo clippy --fix cleared string_lit_as_bytes/suboptimal_flops, and the remaining 16 sites across 7 lints (needless_collect, useless_let_if_seq, tuple_array_conversions, collection_is_never_read, unused_peekable, volatile_composites, unnecessary_struct_initialization) carry function-level #[expect] (Phase-B sweep). Gate green: -D warnings exits 0.

M6 (ratchet)

Port craig’s xtask quality-budgets — the debt counters + --write-lock/--fail-on-regression with lock-authoritative ceiling() — seed quality-budgets.lock at current canopy counts, wire the blocking step into cargo xtask validate, add CI parity, and guard the pre-push hook’s exec bit.

Done (2026-06-09) — cargo xtask quality-budgets ported with the lock-authoritative ceiling() + --write-lock/--fail-on-regression + the syn-based function-LOC visitor + the // SILENT-OK: skip-marker. Recalibrated craig→canopy: B1 globs services//src/api/.rs, B3a excludes the crates/canopy-contracts-* DTO layer, B7 targets crates/canopy-test-lib/src/clients/. Seeded xtask/quality-budgets.lock at canopy’s current counts (B1 18, B2 124, B3a 725, B3b 196, B4 137, B5 298, B6 125, B7 16) — all 8 read LOCKED. Wired blocking into cargo xtask validate [13i/15] + a discrete quality-budgets CI job; lock-raise procedure documented in Coding Conventions § Quality-budget enforcement gate. 31 ported counter tests pass (incl. dedicated B6 parse + B7 fixture tests). Phase A complete — the gate is live and the M1–M5 transition-allowed debt can now only shrink.

Phase B — burndown (ratchet down; rides during/after &56/&58, "finish the ratchet")

B1 (oversized files)

Split services/canopy-web/src/api/case_detail.rs (3,792) by sub-domain; move canopy-medicaid/src/main.rs’s inline `handle_ele_case_closed ELE handler into events.rs. Remove the M4 too_many_lines `#[expect]`s; lower the ratchet.

Not started

B2 (authz-gate DRY + security)

Hoist the inlined worker.in_program_scope(…​) + render_program_scope_denied(…​) gate (~39× across 9 api/actions*.rs files) into one extractor/middleware — DRY and removes the "a new handler can silently forget the gate" footgun. Retire the fail-open empty-program-claim default (session.rs) → deny-by-default (pull forward if treated as a security item).

Not started

B3 (error model + boilerplate)

Replace the blanket From<sqlx::Error> → Internal/500 so unique/FK violations surface as 409/422; dedup the per-service signer (EcdsaSigner/NoopSigner) + event-publisher boilerplate into shared canopy-signing/helper. Doc fix: CLAUDE.md "compile-time verified queries (sqlx)" → accurate (runtime query_as + .bind()), or adopt query! where a build-time DB is acceptable.

Not started

B4 (expect/budget sweep)

Burn down the grandfathered [expect]`s + ratchet floors opportunistically (boy-scout on touched files) + dedicated sweeps; `--write-lock to lower after each. Target: zero grandfathered [expect], budgets at floor.

Not started

B5 (property testing — stretch)

Expand proptest coverage on calculation/validation paths (a shared canopy+craig weakness, ~17 blocks today) — eligibility math, deduction/proration, validators.

Not started

Design — strictness calibration (DECIDED 2026-06-09)

Signed off 2026-06-09: exceed-craig, tiered. Every lint craig denies, canopy denies; plus the noise/nursery lints craig allow-listed for transition are denied here.

Dial Decision

Function-size ceiling

too-many-lines-threshold = 40 (craig’s value).

Nursery escape-hatches

Denied. Do not inherit craig’s 5-lint nursery allow-list (significant_drop_tightening, missing_const_for_fn, future_not_send, redundant_pub_crate, option_if_let_else). Deny by default + sweep; allow-list an individual nursery lint only if M5 measurement shows >10 genuinely-low-value emissions, with a rationale comment per ADR-030 §4.

Pedantic noise sub-lints

Denied. module_name_repetitions, must_use_candidate, missing_errors_doc, missing_panics_doc (craig allows these). Expect a real grandfather surface on missing_errors_doc/missing_panics_doc (every public Result/panicking fn wants an # Errors/# Panics section) — #[expect]-grandfather in M4, burn down in B4.

missing_docs_in_private_items

Denied (every private item documented). The single largest expected grandfather surface; M4 #[expect]-grandfathers existing gaps (self-removing as docs are written), deny going forward.

Structural allow-lists (KEPT)

cargo_common_metadata (unpublished workspace would otherwise demand fake crate metadata) and multiple_crate_versions (transitive-dep skew; tracked in ratchet B6) stay priority-1 allow — context-correct, not noise-tolerance.

Rollout

Tiered M1→M6; each lint family lands as its own reviewable, independently-grandfathered MR.

restriction lints craig omits (as_conversions, float_arithmetic, expect_fun_call, …)

Out of scope for now (beyond the agreed "exceed craig" scope); revisit post-M6 via the ADR-030 §4 promotion pattern if wanted.

Design — the exact posture (ported from craig, verbatim)

The [workspace.lints] table and clippy.toml are ported from craig (Cargo.toml lines 218–553 / clippy.toml). Denied families: panic class (unwrap_used/expect_used/panic/todo/unimplemented/unreachable/unwrap_in_result/dbg_macro), index/slice (indexing_slicing/string_slice), overflow (arithmetic_side_effects), IO (print_stdout/print_stderr), groups (pedantic/cargo/nursery at deny priority −1), complexity (cognitive_complexity/too_many_lines), hygiene (wildcard_enum_match_arm/partial_pub_fields/allow_attributes_without_reason/let_underscore_must_use/ignored_unit_patterns/missing_docs_in_private_items); rust-level unused_must_use = deny, unsafe_code = deny. Priority-1 allow-list (each with a rationale comment per ADR-030 §4) — under the exceed-craig calibration this is minimal: only the structural cargo_common_metadata (unpublished workspace) and multiple_crate_versions (tracked in the ratchet instead), plus any individual lint that M-tier measurement shows has >10 genuinely-low-value emissions. The pedantic-noise sub-lints and nursery escape-hatches craig allow-lists are denied, not inherited. clippy.toml: too-many-lines-threshold = 40, allow-unwrap-in-tests = true, allow-expect-in-tests = true.

Design — the grandfather mechanism

Turning a lint family on does not require fixing every violation first. Each existing violation is either fixed inline (cheap cases) or annotated [expect(clippy::…, reason = "grandfathered M<n>; see &62 / plans/code-quality-gating §B4")]. [expect] (not [allow]) is used so the annotation self-removes: when the underlying violation is later fixed, [expect] itself fires unfulfilled_lint_expectations, forcing the now-dead annotation to be deleted. The burndown (Phase B) is therefore self-policing — you cannot fix a violation and leave its grandfather marker behind.

Design — the ratchet counters (port)

Port craig’s 8 counters, recalibrated to canopy paths: route-module LOC (>500, services//src/api/.rs), function LOC (>100, syn-parsed, test-fns skipped), untyped serde_json::Value (src=0 / tests=budget), #[allow] count, .unwrap_or_default() (>budget, // SILENT-OK: skip-marker), duplicate dep versions (cargo tree -d), and untyped test-client methods. Enforcement is lock-authoritative ceiling() = if locked > 0 { locked } else { threshold }; --fail-on-regression bails if actual > ceiling(); --write-lock lowers the floor after a cleanup (justified in the MR). M6 seeds the .lock at canopy’s current counts.

Design — test carve-out

unwrap/expect in tests are permitted by the clippy.toml toggles (no per-crate header). The remaining panic/index/print/overflow lints need a [cfg(test)]-scoped allow because [workspace.lints] cannot express cfg(test). Canopy centralizes this as far as the tooling allows (a single shared header include! or the minimal per-root ![cfg_attr(test, allow(…​))] block) to avoid craig’s ~31×-repeated header.

Notes

  • Phase A is &62 workstreams 1+2; Phase B is workstreams 3+4 (the user’s "finish the ratchet that wasn’t addressed"). Phase A lands before the first &56/&58 implementation MR so all feature code is born to the gate.

  • Issues are cut from this plan per ADR-013 (one per M*/B* row) once the calibration is signed off.

  • B2’s fail-open-default retirement is the one Phase-B item with a security dimension — pull forward into Phase A if treated as a security fix rather than hygiene.

Edit this page · default