Plan: Prevention-Gate Lints (epic &63 wave 1)

On this page

Status

Step Description Status

1

Tracker preflight: follow-ups #1574/#1575/#1576/#1577 filed + related; Plan::PREVENT-LINTS label applied; re-weights set

Done (2026-08-24) — follow-up numbers exist before any commit cites them

2

Gate 1 (#867): crate-root arithmetic-blanket ban with the 8-entry grandfathered ratchet

Done (2026-08-24) — seed targets=77, baseline 8/8, ghost-blanket negative proof

3

Gate 2 (#866): syn rebuild of the reqwest choke-point lint + (file, count) builder allowlist

Done (2026-08-24) — files=1055, allowlist 5/5 at exact counts, both classes ghost-proven

4

Gate 3 (#869): workspace dependency-inheritance lint + mechanical flips + exception baseline

Done (2026-08-24) — members=66, deps=1210, 0 violations, exceptions 4/4, lockfile-identical flips

5

Gate 4 (#870): partner parse-never-panics coverage lint (census / token-stream / reachability)

Done (2026-08-24) — adapters=11, covered=11; orphaned-registration + gutted-macro negative proofs

6

Gate 5 (#891): advisory-ignore audit gate (parity / per-ID records / review-by expiry)

Done (2026-08-24) — 5 records reformatted, parity 5/5, live EXPIRED negative proof; CI rules:changes fixed

7

Gate 6 (#879): serialize-credentials lint + store-model per-file site baseline

Done (2026-08-24) — 17 markers/10 files inventory; baseline 48 files/153 sites; ghost + bless-refusal proofs

8

Close-out: CLAUDE.md &83 bullet, quality-gates.adoc catalog stitches, battery, MR

Done (2026-08-24) — battery green twice (standalone + pre-push hook); !1475 merged 558f8815; all six issues closed with records

Epic: &63
Issues: #867, #866, #869, #870, #891, #879 (batched — same subsystem; the batch-small-issues authorization is cited in the MR)
Branch: feature/prevention-gate-lints

Context

Epic &63 (the 2026-06-28 shift-left roadmap) converts one-time fixes into self-enforcing floors so a defect class cannot regress. The 2026-08-23 triage found five of these six issues half-landed — the underlying defects were fixed in earlier MRs — and narrowed each to its enforcement residue (recorded in per-issue comments). This wave ships the six gates that all live in xtask/src/cmd/lints.rs + xtask/src/cmd/validate.rs, plus the seed-green fixes each needs. No runtime behavior changes; the only production-code edits are Cargo.toml inheritance flips and per-field marker comments.

The design went through an external stop-ship review (40 findings) whose core verdict — several drafted gates could pass while the prohibited condition remained — drove four structural choices now baked in below: syn visitors instead of line scanners (Gate 2), committed baseline files instead of comment carve-outs wherever parsing discards comments (Gates 3, 6b), a per-file site baseline instead of an aggregate budget (Gate 6b), and time-boxed review-by expiry instead of an unsound Cargo.lock liveness check (Gate 5).

Ratified constraints honored:

  • The 8 existing crate-root arithmetic_side_effects blankets are ratified decisions (#815 accept-as-is with measured ~195-site evidence; #806 folded into the #630 pedantic sweep; craig-test-lib declared-permanent). Gate 1 grandfathers exactly these 8 and bans NEW ones — it never forces ad-hoc migration of a recorded lint-policy override.

  • run_cargo_deny_soft() no longer exists (#1402); cargo deny check runs in the synced pre-push hook and CI. Gate 5 does not re-implement advisory-DB awareness — it enforces the structural contract cargo-deny cannot (parity, per-ID rationale, review-by expiry).

  • Pre-push cargo xtask validate is the sole functional-correctness gate; every gate lands there. CI trigger hygiene is still fixed where the review caught gaps (Gate 5).

Scope

In scope:

  • Six blocking gates in the xtask lint subsystem, each with fixture tests, validate wiring, a quality-gates.adoc section, and a CHANGELOG entry.

  • The seed-green fixes each gate needs: ~19 Cargo.toml inheritance flips (#869), per-field // serialize-credential: markers (#879), the deny.toml/audit.toml structured-record reformat (#891).

  • Two new committed baseline files: xtask/dep-inherit-exceptions.toml, xtask/store-model-responses.toml.

  • CI rules:changes hygiene for the advisory jobs (#891).

Out of scope (recorded):

  • Migrating the 8 ratified arithmetic blankets (#630 owns re-scoping); manifest-defined custom target paths (Gate 1 covers conventional crate roots, stated in its doc).

  • The ~150 store-model handler migrations (the site baseline ratchets them down over time).

  • from_value partner properties + craig-partner-audit coverage (#1575), the db-error scanner’s external-declaration blind spot (#1576), the craig-web rand 0.9 migration (#1574), the CI partner-property accuracy gap (#1577) — filed follow-ups, never fold-ins.

  • rust-version inheritance legislation (59 of 66 members omit it; no defect); promoting single-crate deps (askama etc.) into [workspace.dependencies] — no violation class.

  • reqwest::blocking in xtask (dev tooling; recorded in the lint doc).

  • Full type-resolution for the Gate 2/6a scanners — recorded-false-negative tier.

Design

Shared architecture

Every gate is a LintsAction variant (doc comment citing its issue) + pub run_<x>_blocking() + inline #[cfg(test)] fixture tests, registered via an enforce_<x>(steps) helper in validate.rs (steps.mark(…​) + .context(…​), fail-fast). Gate 1 stays a per-file scan_<x>(path, source) pure function; Gates 2-6 are workspace-level collect-then-reconcile (the no_transitional_allows + authz-coverage shape).

Cross-cutting disciplines:

  • Universe ≠ findings. Every gate separately reports its scan UNIVERSE (files/crates/ entries enumerated, per root) and its FINDINGS. Zero findings is a desirable state; a zero or implausibly-shrunken universe fails loudly, per root. Each gate’s floor is stated below.

  • Parsers get property tests (testing-discipline mandate): Gate 5’s record parser, Gate 3’s manifest interpretation, Gate 2’s cfg-test predicate detector.

  • Escape hatches are pinned: baseline files are bidirectional (new violation AND stale entry both fail; --bless shrinks only); comment markers require a non-empty reason and are stale-checked (a marker adjacent to nothing flaggable = violation).

  • B8 containment: the new run_<x>() bodies share ONE report-and-bail helper so the B8 duplicate-blocks budget (locked 204) does not trip; no --write-lock happens in this MR.

Gate 1 — #867: ban new crate-root arithmetic blankets

Extend run_no_transitional_allows():

  • clippy_group_lint additionally matches clippy::arithmetic_side_effects;

  • const ARITHMETIC_BLANKET_BASELINE: &[&str] names exactly the 8 ratified files (services/craig-financial/src/lib.rs, services/craig-financial/src/main.rs, xtask/src/main.rs, services/craig-cli/src/lib.rs, services/craig-cli/src/main.rs, crates/craig-test-lib/src/lib.rs, tools/craig-seed/src/lib.rs, tools/craig-seed/src/main.rs), each entry commented with its ratification ref. Blanket present + file not in baseline → violation; baseline file with no blanket → stale entry → violation (shrink-only ratchet);

  • suppression-equivalent forms banned: inner cfg_attr(…​) whose predicate is not provably test-gated (test required through any all/any nesting) with a banned-group payload, and inner expect(…​) of the banned groups. Test-gated cfg_attr(test, allow(…​)) roots stay exempt;

  • glob set widened with brace-free pathspecs (git ls-files performs no brace expansion): plugins//src/lib.rs, plugins//src/main.rs, crates//src/main.rs, and /src/bin/*.rs variants for crates/services/tools/plugins/xtask. Coverage claim = conventional production crate roots only.

Universe floor: every glob group nonzero at seed time stays nonzero (plugins: 2). Seeds green with zero source changes.

Gate 2 — #866: syn rebuild of the reqwest choke-point lint

Rewrite run_reqwest_client_new as a syn visitor (line scanners mishandle #[cfg(all(test, feature = "…​"))] modules and hide production items after inline test modules; substring needles miss ClientBuilder::new() / Client::default() / aliased imports and false-trigger inside comments and strings):

  • the visitor skips items whose attrs carry a cfg predicate that provably requires test (recursive through all/any; proptested); external #[cfg(test)] mod x; declarations are skipped, never scan-terminating;

  • call detection is path-based with an import table (grouped imports and as aliases tracked): paths resolving to reqwest::Client::{new,builder,default} or reqwest::ClientBuilder::{new,default} are flagged. Recorded false-negative: non-use aliasing (type C = reqwest::Client;);

  • the allowlist is (file, expected builder-site count) pairs over the 5 production files (crates/craig-common/src/http.rs, crates/craig-auth/src/oidc_discovery.rs, crates/craig-auth/src/jwks.rs, crates/craig-intake-sdk/src/client.rs, crates/craig-exchange-transport/src/egress.rs), rationale one-liners taken from each file’s own doc comments. Count above baseline = new decision; below = stale = bless down;

  • craig-test-lib keeps its path exemption for this gate only (test infrastructure — the production choke-point threat model does not cover it; recorded residue);

  • walker = the workspace-member surface (crates, services, tools, plugins, xtask src trees minus [workspace].exclude);

  • xtask’s reqwest::blocking builders out of scope (recorded).

The shipped db-error lint’s line scanner shares the external-declaration blind spot — #1576, separate branch. Universe floor: allowlisted files all present with nonzero counts. Seeds green with zero source changes.

Gate 3 — #869: workspace dependency-inheritance lint + flips

New run_workspace_dep_inheritance():

  • membership authority = the workspace itself: expand [workspace].members globs minus [workspace].exclude (never fixed directory globs);

  • manifests parsed with toml for structure; exceptions live in the committed xtask/dep-inherit-exceptions.toml, keyed (manifest path, table, dependency key) with a mandatory reason — exceptions apply ONLY to check 1; checks 2-3 have no exception channel;

  • check 1: a dependency whose resolved package name (package = rename honored; git deps compared by name; dotted/inline/expanded forms normalized by the parser) exists in [workspace.dependencies] but is declared hardcoded → violation;

  • check 2: [package] version/edition/license each PRESENT and .workspace = true (omission is a violation too); rust-version out of scope (recorded);

  • check 3: default-features = true anywhere, or a hardcoded twin of a default-features = false workspace dep dropping the false → violation (zero today).

Exception seed (4 entries): rules-client reqwest (feature subtraction, −multipart); intake-sdk tokio (leaf-slimming to ["time"]); xtask reqwest (+blocking/−multipart); craig-web rand = "0.8" (divergence vs workspace 0.9 — retirement = #1574).

Seed-green flips in the same commit: ~12 mechanical workspace = true flips (craig-test-lib toml; craig-reference strum; craig-web uuid/tempfile; xtask anyhow/clap/serde/ serde_json/proc-macro2/quote/syn/toml); 2 path-alias flips (craig-cli dev-dep craig-test-lib; craig-seed craig-reference); 4 self-dev-dependency flips (craig-mq/craig-store/craig-api/craig-crypto { path = ".", …​ }{ workspace = true, features = […​] }, resolving identically); the craig-matching workspace alias + the craig-cases flip; xtask [package] fields → .workspace = true.

Proptest invariant: classification stable across equivalent TOML spellings. Universe floor: member count == the workspace’s own count (66).

Gate 4 — #870: partner parse-never-panics coverage lint

New run_parse_prop_coverage(), three layers (string co-occurrence proves nothing; a tests/** file is not necessarily compiled; keying on "crates that have adapter.rs" lets deletion remove a crate from enforcement):

  1. Census: enumerate ALL crates/craig-partner-* workspace members. Each must contain an adapter implementation (type Inbound = X; located by syn inside the adapter’s impl block — src/adapter.rs or src/adapter/mod.rs) or appear on the named exception list (craig-partner-audit — dispatch crate, no adapter; extension tracked as #1575). Neither → violation. Census shrink below the seed (11 adapters + 1 exception) → violation.

  2. Property presence: for each inbound type X, some file under the crate’s tests/ must contain a proptest! macro invocation whose TOKEN STREAM contains both from_slice and X (comments cannot satisfy it; deleting the property body removes the macro → fail).

  3. Compilation reachability: the containing file must be a top-level tests/*.rs target or reachable from one via a mod/#[path] chain (caps' nested layout is the seed case).

Escape hatch: // allow-no-parse-prop: <reason> above the type Inbound declaration; the reason MUST contain a #NNN issue ref. Day-one green: all 11 inbound types covered (#858). The properties execute in the pre-push full battery; the CI --lib --bins accuracy gap is #1577.

Gate 5 — #891: advisory-ignore audit gate

New run_advisory_ignore_audit() (cargo-deny/cargo-audit stay the advisory-DB layer; a Cargo.lock "liveness" check was reviewed and rejected as unsound — a crate name persists in the lock after upgrading past an advisory):

  1. Parity: the two ignore arrays (deny.toml, .cargo/audit.toml) compared as ORDERED lists, duplicates rejected, IDs matching RUSTSEC-\d{4}-\d{4,}.

  2. Per-ID structured record (no shared blocks): each ID’s contiguous preceding comment block in deny.toml must contain # crate: <name>, non-empty # rationale:, # tracked: NNN OR retire: <condition>, and # review-by: YYYY-MM-DD. The quick-xml pair splits into two records; RUSTSEC-2023-0071’s above-the-array prose moves inside the array. # lock-only stays as human documentation (the gate attaches no semantics).

  3. Review-by expiry: today > review-by → FAIL. Every ignore is time-boxed; staleness is a hard stop. Dates seeded ≤ 180 days out; 0071’s aligned with #764 (which stays open — the gate never judges reachability).

  4. The record parser is hand-rolled over the raw array region (TOML parsing discards comments) and proptested: totality, well-formed acceptance, missing-marker rejection.

CI hygiene in the same commit: deny.toml + .cargo/audit.toml added to the cargo-audit job’s rules:changes in .gitlab-ci.yml (the cargo-deny job already watched deny.toml; .cargo/audit.toml does not affect cargo-deny). Universe floor: both files parse (an empty parity-equal list is legal).

Gate 6 — #879: serialize-credentials lint + store-model site baseline

(a) run_no_serialize_credentials() — syn-based, sharing Gate 2’s cfg-test-aware visitor, over the workspace-member walker:

  • flags named fields of Serialize-deriving structs AND enum variants (derives detected through cfg_attr) whose field ident OR serde(rename) wire name contains one of: secret, password, token, api_key, auth_config, signing_key, client_secret, private_key, credential, bearer, verifier, nonce;

  • pass conditions: [serde(skip_serializing)]/[serde(skip)] on the field, or a // serialize-credential: <reason> marker on the line DIRECTLY above that field (per-field, never per-struct; association via proc-macro2 span lines). Stale markers = violation;

  • scope stated honestly: direct named fields only — flatten chains, manual impl Serialize (one exists, a test helper), and non-use aliasing are recorded false-negatives;

  • NO whole-crate test-lib exemption: FileCacheEntry.access_token / FileCache.tokens (#1163 test infra) get per-field markers and stay visible in the inventory.

Day-one seed: the 11 censused structs (financial-contracts ImportWarning / ImportBlocker / PreviewSweepResponse / ExecuteSweepRequest; intake-contracts SubmitReportRequest; craig-mq ParkWrapper; craig-security DeadLetterAudit + IssueKeyResponse; craig-cli CachedToken; craig-exchange PartnerIntentV1; craig-web WebSession) + craig-web PkceState (PKCE verifier
OIDC nonce in a signed cookie) + the two test-lib structs — re-censused at implementation with the final vocabulary and walker; every new hit gets a marker with an honest reason or a fix, never a silent skip.

(b) run_store_model_response_baseline() — a committed per-file site baseline xtask/store-model-responses.toml mapping each services/*/src file to its count of Json<…store::models::…> occurrences (including PageResponse<…> forms). Any file above its baselined count (or any new file with count > 0) → violation; below → stale → --bless rewrites DOWNWARD only. Same-file swaps are the recorded residue; documented as a coarse textual metric (aliased imports / multiline generics under- or over-count). The baseline IS the reproducible site definition (~150 sites at census). Zero-count end state is legal (empty baseline).

Both halves wired in phase_code_security_lints.

Steps

Commit sequence (one branch; each lint commit carries its fixture tests, validate wiring, quality-gates.adoc section, and CHANGELOG entry — independently green):

  1. docs(plans): prevention-gate lints wave plan (&63) — this page, nav-linked Active.

  2. chore(xtask): ban new crate-root arithmetic blankets (#867)

  3. chore(xtask): syn rebuild of the reqwest choke-point lint (#866)

  4. chore(xtask): workspace dep-inheritance lint + flips (#869)

  5. test(xtask): partner parse-prop coverage lint (#870)

  6. chore(deps): advisory-ignore audit gate (#891)

  7. chore(xtask): serialize-credentials lint + site baseline (#879)

  8. Close-out: CLAUDE.md &83-closure bullet, quality-gates.adoc catalog/overview stitches (named edits — the gate inventory and counts must match), plan step statuses updated.

The plan page stayed Active through !1475 (merged 558f8815, 2026-08-24); this archive move + the Plan Completion Audit ride the next main-touching MR per the recorded vehicle rule. Audit result (2026-08-24): plan↔code diff zero — every design deviation discovered during implementation (audit.toml deliberately unchanged; the cargo-audit-only CI edit; B2/B3a/B5 budget fixes; the marker block-stacking rule) was folded back into this page before merge. Post-merge records: closing comment per issue with impl + merge SHAs, epic &63 updated, branch deleted.

Files Touched

File Change

xtask/src/cmd/lints.rs

Gate 1 extension; Gates 2-6 new lints + fixture tests + shared report helper

xtask/src/cmd/validate.rs

enforce_<x> wiring: Gates 1/3/5 in phase_doc_lints, Gates 4/6 in phase_code_security_lints

xtask/dep-inherit-exceptions.toml, xtask/store-model-responses.toml

New committed baselines (bidirectional, bless-down-only)

root Cargo.toml + 11 member manifests

Inheritance flips + the craig-matching alias (#869)

deny.toml, .gitlab-ci.yml

Structured ignore records + review-by dates; the cargo-audit job’s rules:changes (#891). .cargo/audit.toml is deliberately UNCHANGED — it already sits in exact ordered parity and its header defers rationale to deny.toml; the gate enforces the parity at every validate

~10 production files

Per-field // serialize-credential: markers (#879)

docs/modules/ROOT/pages/quality-gates.adoc, CHANGELOG.adoc, .claude/CLAUDE.md

Gate sections + catalog stitches; per-issue entries; the deferred &83 bullet

Verification

  1. Fixture tests per gate: violation / pass / escape-hatch / empty-or-refless-reason / stale-baseline / universe-floor; Gate 4 additionally: comment-only co-occurrence does NOT pass, orphaned-uncompiled file does NOT pass.

  2. Proptests: Gate 5 record parser, Gate 3 spelling-equivalence, Gate 2 cfg-predicate detector.

  3. cargo xtask validate end-to-end green with plausible printed universe counts — then the negative proof per gate (synthetic violation → fail → revert; not committed).

  4. Full battery + reseed + contextless J1-J8 review before push (standing rules).

Documentation Updates

  • docs/modules/ROOT/pages/quality-gates.adoc — one section per gate + catalog stitches

  • CHANGELOG.adoc — per-issue entries under == Unreleased

  • .claude/CLAUDE.md — the deferred &83-closure bullet (close-out commit)

  • docs/modules/ROOT/nav.adoc — this page under Plans › Active (Archive move is post-merge)

Edit this page · latest