Quality Gates & Enforcement (CRAIG)

On this page
Contents

These are CRAIG’s project-specific, mechanically-enforced quality gates — run by cargo xtask validate, cargo xtask quality-budgets, and cargo xtask lints. They cover the project’s DTO-validation rules, workspace lint policy, the nine quality budgets, the function-cohesion gate, context-tier hygiene, and route-role coverage. The universal Rust style rules (function size, newtype pattern, typed errors, no-panic, no-Value, etc.) live in the coding-conventions standard, with the terse agent digest in .claude/rules/coding-conventions.md.

DTO field-length validation (#486)

Every String / Option<String> / Vec<String> field on a deserialize-side DTO (request body, query string) carries a [garde(length(max = <CONST>))] cap plus a mirrored [schema(max_length = <literal>)] (or #[param(max_length = <literal>)] on IntoParams structs). The 13 canonical cap constants live in craig-validation:

Constant Cap Category

NAME_MAX

100

Person / org / court / admin-unit names

PHONE_MAX

32

E.164 + separators + extension

IDENTIFIER_MAX

64

UUIDs, slugs, hex SHA-256 digests

IDENTIFIER_LONG_MAX

1024

S3 / Garage object keys (spec ceiling)

CODE_MAX

64

Ruleset-validated codes, semver+build-metadata

ENUM_STRING_MAX

32

String-typed status / sort fields (Plan D F-022 candidates)

SHORT_TEXT_MAX

500

Query searches, single-line annotations

DESCRIPTION_MAX

2000

Bounded single-concept descriptions

REASON_MAX

1000

Justifications, override reasons

NARRATIVE_MAX

4000

Plaintext multi-paragraph narrative

NARRATIVE_ENCRYPTED_MAX

8000

Encrypted-at-rest narrative; absorbs base64 + AEAD overhead

NAME_ENCRYPTED_MAX

255

Encrypted-at-rest person-name PII envelope

SSN_LAST_FOUR_MAX

256

Encrypted-at-rest envelope; digit-shape via separate validator

utoipa literal requirement. utoipa 5.4’s [schema(max_length = …​)] and [param(max_length = …​)] macros reject const-ident expressions — they require integer literals. The garde side stays canonical ([garde(length(max = NAME_MAX))]); the utoipa side mirrors the literal ([schema(max_length = 100)]). This dup is intentional and bounded; a future garde/utoipa cap-sync xtask lint will guard the pair.

Special-cases. Use the named constant unless concrete evidence (DB column ceiling, RFC reference, real-world data) demands a different cap. When deviating, use a literal and add a // #486 special-case: <reason> comment immediately above the attribute pair. CreateCourtOrderRequest.court_name / UpdateCourtOrderRequest.court_name use literal 200 (compound jurisdictional names) — established precedent.

Cousin-field uniformity. Fields with the same semantic role across cousin DTOs (admin_unit on Create/Update for cases / referrals / reports) MUST share one cap — NAME_MAX for admin_unit cousin fields. The MR 5 workflow caught one cousin asymmetry mid-sweep (CreateReportRequest.admin_unit was a survey miss); cousin reviews are part of every per-file sweep.

Handler wiring. Every handler accepting a Validate-derived DTO calls payload.validate()? (or query.validate()?) immediately after the Json/Query extractor and BEFORE the authz check + parent-resource existence checks. The From<garde::Report> for ApiError impl in craig-common::validation converts validation failures into RFC 9457 ApiError::Validation (HTTP 422) with a per-field errors extension. Note: garde 0.22’s Validate::validate(&self) takes NO context arg under Context = () — use body.validate()?, not body.validate(&())?.

Scope as of MR 1-6 (2026-05-29): all 72 String fields in craig-cases-contracts are length-capped. craig-exchange-contracts surveyed in MR 6 contains zero unbounded String DTOs (the crate’s surface is the ExchangeAdapter trait + ExchangeAdapterKind enum; no request/response DTOs). Inline service api/ DTOs (210 fields across 4 stateful services) + partner-* crates (147 fields) + craig-intake-sdk (52 fields) are tracked as a separate follow-up — they fall outside #486’s craig-*-contracts scope and warrant their own audit ticket.

Partner-crate scope (#1461, 2026-08-15): the cargo xtask lints dto-length walk derives its partner-crate list from a crates/craig-partner-* directory glob — every partner crate present AND every future one is lint-covered automatically (the earlier hardcoded list silently omitted four crates: cprs / stars / doe-slds, whose 41 unbounded fields were annotated when the glob closed the gap, and audit, which carries no DTO structs). A unit test pins the glob against the live crates/ listing, and an unreadable crates/ fails the lint loudly rather than passing it vacuously.

Lint Policy (workspace [lints] table)

Cargo.toml’s `[workspace.lints.clippy] table turns the §Style + §Errors rules above into mechanically-enforced compiler errors. Member crates inherit via [lints] workspace = true. Today’s set (post-Plan-M Tier 1 + Tier 2, 2026-06-05):

  • Pedantic + cargo + complexity (deny): pedantic, cargo, cognitive_complexity, too_many_lines (threshold 40 via clippy.toml)

  • Panicking calls forbidden (deny, Plan H F-030 sweep): unwrap_used, expect_used, panic, todo, unimplemented, unreachable, dbg_macro

  • Wildcard match arms forbidden (deny, post-Plan-H): wildcard_enum_match_arm — forces explicit match exhaustiveness

  • Panic-surface (Plan M Step 7 / #498) (deny): indexing_slicing, string_slice, unwrap_in_result

  • Overflow-surface (Plan M Step 8 / #499) (deny): arithmetic_side_effects — every +/-/// on integer/Decimal/Duration types must use checked_ / saturating_* / wrapping_*

  • IO-surface (Plan M Step 9 / #500) (deny): print_stdout, print_stderr — library + service code routes through tracing::*

  • Struct hygiene (Plan M Step 10 / #501) (deny): partial_pub_fields — either ALL fields pub or NONE

  • Docs required (deny, Plan H Step 3 burn-down): missing_docs_in_private_items

  • Silent-discard forbidden (deny, Plan H F-051 sweep): let_underscore_must_use, ignored_unit_patterns

  • Reasoned-allow required (deny): allow_attributes_without_reason — every #[allow] must carry reason = "…​"

  • Priority=1 allowed (style-orthogonal): module_name_repetitions, must_use_candidate, missing_errors_doc, missing_panics_doc, cargo_common_metadata, multiple_crate_versions (dep-graph artifact)

All deny at workspace level. Test-only suppression for the panicking-call + silent-discard + Plan M Step 7/8/9 lints is scoped via #![cfg_attr(test, allow(…​))] on each lib/main root, leaving production code deny-enforced. xtask validate runs clippy with -D warnings for defense-in-depth.

Plan M Step 6 regression gate (#497 + #867) — cargo xtask lints no-transitional-allows

Wired at the cargo xtask validate no-transitional-allows gate. Bans clippy::pedantic and clippy::cargo (the lint groups) inside any crate-root ![allow(…​)] at the conventional production crate roots: lib/main/src/bin/.rs under crates/, services/, tools/, plugins/*, plus xtask/src/main.rs (each pathspec a separate literal entry — git ls-files performs no brace expansion; manifest-defined custom target paths are recorded out of scope). git ls-files-scoped (untracked paused work transparent to gate). Per-fn [allow(clippy::pedantic)] is unaffected — different surface. Implementation: xtask/src/cmd/lints.rs::run_no_transitional_allows + no_transitional_allows_tests module.

#867 extensions (epic &63):

  • Arithmetic blankets: a crate-root #![allow(clippy::arithmetic_side_effects)] masks every overflow site at once (J7’s default is per-site checked_* + ?). NEW blankets are banned; the eight ratified blankets (financial ×2 — #499/#806→#630; xtask — #815 accept-as-is; cli ×2; test-lib — declared-permanent; seed ×2) are grandfathered in ARITHMETIC_BLANKET_BASELINE, which is bidirectional: removing a blanket without deleting its entry fails as STALE, so the baseline only shrinks. Re-scoping the eight belongs to #630, never to ad-hoc edits.

  • Suppression-equivalent channels: crate-root ![expect(…​)] of a banned group, and ![cfg_attr(PRED, allow|expect(…​))] whose predicate is not PROVABLY test-gated (test required through any all/any nesting — any(test, feature = "x") and not(test) both count as production-reachable), are banned identically.

  • Universe floor: each pathspec is enumerated separately; a glob that matched >0 files at seed time matching 0 fails loudly (TRANSITIONAL_ALLOWS_NONEMPTY_GLOBS) — the scan surface can never collapse silently.

Plan M tier-lift #![allow] vocabulary

When a new workspace lint promotion fires across many sites, the established Plan M-era resolution patterns are:

  1. Per-site #[expect(clippy::<lint>, reason = "<invariant proof>")] — the default for shared/production crates when the lint marks a provably-safe operation. Reason cites the loop invariant, math bound, or shape guarantee. Example: craig-store/validation.rs &body[..size.min(256)] cites size = body.len().

  2. Per-fn #[allow(clippy::<lint>, reason = "§Style §Important-Function …​")] — when a §Important-Function carve-out explains the wide-or-wrapping nature. Example: craig-auth/service_token.rs::current for fused fast-path/write-lock/refresh/grace-window.

  3. Crate-level #![allow(clippy::<lint>, reason = "<crate role>")] — for tool crates whose role legitimizes the entire lint surface. Established vocabulary:

    • BFF / web (craig-web): format_push_string, if_not_else, indexing_slicing for HTML/URL builders + serde_json Index<&str> reads (Null-fallback safe).

    • CLI orchestrator (craig-cli, xtask): too_many_lines, cognitive_complexity, unnecessary_wraps, print_stdout, print_stderr, arithmetic_side_effects for CLI dispatch + stdout-as-UI + bounded report-gen math.

    • xtask CLI long-tail: needless_pass_by_value (clap Args), manual_let_else, single_match_else (error-path context), indexing_slicing, string_slice (cargo-metadata traversal).

    • Tool crates (mock-server, craig-seed): indexing_slicing, string_slice, arithmetic_side_effects, print_stdout, print_stderr — fixed-shape mock-data / datagen invariants, not in CRAIG’s production request path.

    • Domain-specific service (craig-financial): arithmetic_side_effects for Decimal currency math + chrono date math over payment periods (Decimal::MAX ~10^28 vs realistic foster-care amounts; safety story is formula audit + integration tests, not +/- suppression).

    • Test-support permanent (craig-test-lib): panicking-call lints + indexing_slicing, string_slice, unwrap_in_result, arithmetic_side_effects, return_self_not_must_use, unused_async, module_inception — assertion-mechanism crate; permanent per Plan H Step 4 §scope.

    • Macro contracts: Askama filter_fn (inline_always, unused_self, unnecessary_wraps); utoipa OpenApi derive (needless_for_each).

    • Federal-spec wire shapes: HHS/OMB Statistical Policy Directive No. 15 multi-race → struct_excessive_bools on EMPI race flags.

    • PATCH-payload contracts: option_option on update DTOs (placement + security + financial).

    • Helper-layer interface (craig-seed/sql.rs): ref_option, trivially_copy_pass_by_ref on sql_opt_* family.

cfg_attr(test, allow(X)) + crate-wide #![allow(X)] collision (Plan M lesson 2026-06-05)

When a tool crate adds crate-level ![allow(clippy::X)] AND retains ![cfg_attr(test, allow(clippy::X))] from a previous step, clippy emits duplicated_attribute warning in test mode (the crate-level allow already covers the test compile). Fix: remove the redundant lint from the cfg_attr block, leaving only test-only-needed lints there. Pattern surfaced in 4 crates during Step 7 sweep (mock-server, cli bin+lib, web bin, xtask) and again in Step 8 (financial bin+lib, xtask).

Estimating surface for new workspace lint promotions

Plan M’s experience: grep-based surface estimates consistently overcount. Plan body §Step 7 estimated ~928 indexing sites from raw grep; actual workspace-clippy emission after cfg_attr(test, allow(X)) test exemption was 119 production-target sites, collapsing further to ~28 after crate-level allows on tool crates (cli, xtask, seed, mock-server). Future tier estimates should grep-and-discount-by-crate-target: count only production-shaped crates first, then subtract tool-crate-allowable patterns.

Plan N Phase B pay-down sweep methodology (2026-06-06)

Phase D blocks on Phase B clear. All 7 OVER budgets must reach historical lock (B1≤8, B2≤49, B3a≤236, B3b≤31, B4≤142, B5≤175, B7≤194) before Step 9 flips the enforcement gate. Gate is report-only during Phase B; pay-down MRs land without the gate firing.

Per-MR cadence:

  1. Run cargo xtask quality-budgets --report for current actual. Note the delta to lock.

  2. Dispatch one Explore subagent to enumerate sites with file:line citations + classify each as (a) TYPE to existing pub DTO, (b) STRUCTURAL-VALUE marker, (c) PARTNER-EDGE-UNTYPED marker, (d) rewrite to drop the annotation (only B3a/B3b — see below).

  3. Apply changes by batch. For marker insertion across many files, use the Python script pattern below.

  4. Re-run cargo xtask quality-budgets --report to confirm the budget reaches lock.

  5. CHANGELOG entry notes the delta + the per-category breakdown.

Marker placement gotcha (Plan N Step 4 lesson): the comment-skip counter looks back exactly one non-empty line. If a serde_json::Value site has an existing doc comment immediately above it, the marker MUST go BETWEEN the doc comment and the Value line — NOT above the doc comment. Otherwise prev_nonempty is the doc comment, not the marker, and the skip doesn’t fire.

// Per-application discovery URL (sandbox-validated against Authentik 2026.2.2)
// STRUCTURAL-VALUE: OIDC provider response — polymorphic per RFC 8414/7517
let disc: serde_json::Value = client.get(...)...;

Reader-readable: the doc comment explains intent, the marker is the lint exemption.

Python script pattern for batch marker insertion (B3b sweep, !600). The script inserts the marker IMMEDIATELY above each Value line, regardless of what’s above. Skips use serde_json::Value; import statements (those are module-level, not call sites).

import re, pathlib
files_and_markers = {
    "path/to/test.rs": "OIDC provider response — polymorphic per RFC 8414/7517",
    # ...
}
VALUE_RE = re.compile(r"serde_json\s*::\s*Value")
MARKER_RE = re.compile(r"//\s*STRUCTURAL-VALUE:")
USE_RE = re.compile(r"^\s*use\s+serde_json::Value")

for fpath, reason in files_and_markers.items():
    p = pathlib.Path(fpath)
    lines = p.read_text().splitlines(keepends=True)
    out = []
    for line in lines:
        if VALUE_RE.search(line) and not USE_RE.match(line) and not MARKER_RE.search(line):
            indent = re.match(r"^(\s*)", line).group(1)
            out.append(f"{indent}// STRUCTURAL-VALUE: {reason}\n")
        out.append(line)
    p.write_text("".join(out))

Always cargo fmt --all after script-driven edits — the inserter doesn’t preserve all rustfmt subtleties.

Marker reason vocabulary (cite ONE of these architectural categories):

  • OIDC — "OIDC provider response — polymorphic per RFC 8414/7517"

  • JDM/zen-engine — "JDM ruleset content — polymorphic per zen-engine spec"

  • Postgres EXPLAIN — "Postgres EXPLAIN plan — polymorphic by design"

  • RabbitMQ envelopes — "RabbitMQ event envelope — polymorphic by design"

  • Cloudflare Turnstile / external partner shapes — "Turnstile sidecar response — external partner shape" / "external partner — Plan L F-064 typed-schema deferred"

  • Encryption envelopes — "encryption envelope {\"v\": \"<ct>\"} — Plan B F-001 shape"

  • CLI plumbing — "CLI input — operator-provided arbitrary JSON"

B3a/B3b TRIVIAL rewrites (Plan N Step 4 lesson — typed_dto_pilot.rs). For let _: serde_json::Value = serde_json::to_value(&x).unwrap(); round-trip assertions, rewrite to serde_json::to_value(&x).expect("<DTO> serializes to JSON");. Drops the binding, drops the annotation, adds explicit error context. The annotation was load-bearing for the round-trip — .expect() preserves intent without triggering the counter.

B7 TYPE strategy (Plan N Step 3 lesson — type to pub-re-exported DTOs). Test-client methods returning Result<ApiResponse<serde_json::Value>> should be typed against an EXISTING pub DTO from a *-contracts crate or craig-intake-sdk. Don’t introduce new contract-crate types as part of the sweep — that’s its own design work. If no pub DTO exists, defer.

Phase B remaining order (after Steps 3–4):

Step Budget Current Lock Delta Strategy

5 (#518)

B5 unwrap_or_default

222

175

47

// SILENT-OK: markers on diagnostic-fallback sites; typed error propagation on real value chains

6 (#519)

B4 #[allow]

332

142

190

2–3 MRs by crate batch; audit each per-item allow for staleness vs. legit reason = "…​"

7 (#520)

B3a Value src

452

236

216

3–4 MRs by service batch; // PARTNER-EDGE-UNTYPED: #441 for partner-edge, // STRUCTURAL-VALUE: for structural

(B1)

route LOC

11

8

3

Incidental — decompose 3 specific route modules during another step’s sweep

(B2)

function LOC

56

49

7

Incidental — decompose 7 specific functions during another step’s sweep

Plan N (Quality-Gate Enforcement) — quality-budget marker comments (2026-06-06)

Plan N Step 2 (#515) introduced three comment markers that the cargo xtask quality-budgets counters honor as documented exemptions. The marker MUST sit on the immediately-preceding non-empty source line (one-line lookback; empty lines are tolerated). Marker above an enclosing attribute is NOT honored — the marker goes immediately above the call/use site:

// PARTNER-EDGE-UNTYPED: #441
let _: serde_json::Value = ...;  // skipped from B3a

#[allow(unused)]
// PARTNER-EDGE-UNTYPED: #441
let _: serde_json::Value = ...;  // NOT skipped — marker is above the attribute, not the use

// SILENT-OK: <reason> (B5 unwrap_or_default counter). Use when empty-on-failure is a diagnostic fallback and the empty case is acceptable. Canonical example: extracting an error body for a BadStatus error where the status code already carries the failure signal — an unreadable body shouldn’t mask the diagnosis. Established in crates/craig-partner-*/src/adapter.rs body-extraction sites.

// PARTNER-EDGE-UNTYPED: <ticket> (B3a/B3b serde_json::Value counter). Use for serde_json::Value in partner-edge code awaiting Plan L F-064 typed-schema migration. The ticket reference (#441 for Plan L) makes the exemption time-bound and trackable.

// STRUCTURAL-VALUE: <reason> (B3a/B3b serde_json::Value counter). Use for serde_json::Value that’s structurally untyped by design and cannot be replaced with a typed DTO. Canonical sites: zen-engine rule inputs/outputs (JDM inputs are polymorphic per the rule engine’s contract), authz attributes, encryption envelopes ({"v": "<ct>"}), CLI plumbing where the user provides arbitrary JSON.

-- floor-exempt: <reason> (SQL, the #1307 / ADR-063 D3 destructive-migration floor lint — the one BLOCKING class inside validate-migration-constraints; the FK / NUMERIC-CHECK / session-SET trio stays advisory). A destructive statement (DROP TABLE / DROP COLUMN / ALTER TABLE … DROP <col>) in a migration newer than the schema_compat_floor baseline must bump UPDATE schema_compat_floor SET min_required_version = <own version>; in the same file — that is what makes verify-only boot refuse a pre-drop binary. Waive ONLY when no released binary reads the dropped structure (born-and-dropped within one release train — bumping there would foreclose rollback for nothing). DROP CONSTRAINT / DROP DEFAULT are out of scope by decision: removing them widens acceptance; no old binary’s reads break.

Plan N Phase B sweep cadence: each pay-down MR runs cargo xtask quality-budgets --report and notes the delta in CHANGELOG. The gate flips to --fail-on-regression at Plan N Step 9 (the validate quality-budgets gate + CI ci-tests) only after all 7 OVER budgets clear (B1/B2/B3a/B3b/B4/B5/B7 ≤ historical lock).

Validate step numbering and lint carve-outs

cargo xtask validate auto-numbers its steps [N] at runtime via a StepCounter (xtask/src/cmd/validate.rs) — there is no fixed denominator, because the step set varies with --skip-devstack / --skip-docker (any denominator would be wrong for some invocation); the phase roadmap printed at the top of the run gives the coarse overview instead. Refer to a gate by what it does, never by a step number — the numbers renumber whenever a step is added, removed, or skipped. New workspace clippy lints fire via the existing clippy step and need no new validate step; only behavior-distinct gates (custom subcommands like no-transitional-allows, route-role-coverage, quality-budgets) are their own steps.

Static regex expect() exception: Regex::new(r"…​").expect("static regex") is the one acceptable expect_used carve-out. Failure indicates a programmer typo in the pattern, not a runtime concern. Use #[allow(clippy::expect_used, reason = "static regex; failure is a programmer bug")] at the call site. Prefer LazyLock<Regex> for hot paths.

When a rule conflicts with planned work, follow the "When you can’t comply" protocol in the coding-conventions standard — alert BEFORE writing the violating code; justify the carve-out with a subagent-verified #[allow(…​, reason = "…​")].

Plan P (Nursery Promotion Sweep) — workspace state additions (2026-06-06)

Plan P (plans/archive/nursery-promotion-sweep.adoc) closed by flipping 12 nursery lints from allow → deny + ADR-031 documents the triage pattern. The 4 workspace allows + 2 crate-level allows that remain are architectural carve-outs, not deferred work:

  • missing_const_for_fn allow — workspace grep confirmed ZERO callers use these fns in const context. Const-promotion is cosmetic; adds maintenance friction without enabling any real const-context use. Decision is permanent, NOT pending future revisit.

  • option_if_let_else allow — 3 per-fn allows on tools/craig-mock-server/{doe,empi,ies}.rs handler match arms (match wins readability for multi-line Response-building bodies) + 1 nursery false-positive on craig-common::pagination::PageResponse<T> where the lint fires on the Vec<T> type-parameter span. Struct- and field-level #[allow] attributes do NOT suppress the lint (the diagnostic span resolves inside a derive-macro expansion). Permanent.

  • redundant_pub_crate allow — Plan D F-023 (#470) migrated 1,065 items to pub(crate) as a deliberate intra-crate API marker. The lint’s pub suggestion would defeat the visibility-marker intent (defense against accidental cross-crate API promotion during refactors). Permanent.

  • future_not_send allow — 140+ emissions across 5 architectural site classes, ALL intentionally !Send by design:

    1. services/craig-cli/src/clients/** (33) — &Transport across awaits; CLI is single-shot one-binary.

    2. crates/craig-test-lib/src/harness.rs (98) — &self methods yielding borrow futures; tests run single-threaded.

    3. crates/craig-test-lib/src/client.rs (4) — same pattern.

    4. crates/craig-authz/src/eval_thread.rs (1) — JDM evaluator is !Send per zen-engine + Rhai engine constraints (ADR-024 § Architectural Constraints dedicates a !Send eval thread).

    5. services/craig-web/src/routes/mod.rs (3) — BFF route helpers hold &AppState across awaits; AppState already wraps shared state in Arc.

      Refactoring 140 sites to Arc<RwLock<T>> would lose borrow-check guarantees for negative perf value. Permanent.

  • too_long_first_doc_paragraph crate-level allows in craig-auth + craig-api — clippy nursery emits this lint with no source span when the trigger is inside a derive-macro expansion (thiserror::Error / utoipa::OpenApi / utoipa::ToSchema / sqlx). All authored doc paragraphs were swept; the macro-expansion sites cannot be locally suppressed via attribute on the item. Crate-level allow scopes the impact.

Macro-expansion lint false-positive pattern (Plan P lesson) — when a nursery lint fires with no source span (or a span pointing at a type-parameter or struct field that has no expression), it’s emitting from inside a derive-macro expansion. Local [allow] on the offending item OR field WILL NOT suppress it. Two escape hatches: (1) crate-level ![allow] block at the lib root (scopes impact to one crate), (2) workspace allow with documented rationale (when the false-positive is widespread). Plan P encountered this on option_if_let_else (PageResponse field — workspace allow retained) and too_long_first_doc_paragraph (craig-auth + craig-api — crate-level allow).

Resuming pre-Plan-M crate authoring (Plan L Step 3 batch 4 lesson, 2026-06-06)

When resuming feature work authored before Plan M closed (i.e., the branch still carries the Plan H Step 2 transitional 6-lint #![allow(clippy::pedantic | clippy::cargo | …​)] block at the lib root), the Plan M Step 6 no-transitional-allows regression gate (xtask validate) will reject the push. Fix the lib root before pushing:

  1. Strip #![forbid(unsafe_code)] if present (workspace unsafe_code = "deny" from Plan M Step 11 covers it).

  2. Lift the Plan H Step 2 transitional allow block (the 6-lint #![allow(clippy::pedantic, clippy::cargo, clippy::missing_docs_in_private_items, clippy::too_many_lines, clippy::cognitive_complexity, clippy::ignored_unit_patterns)]).

  3. Add the Plan M Tier 1d cfg_attr(test, allow(…​)) vocabulary blocks: Plan M Step 7 (indexing_slicing + string_slice + unwrap_in_result), Step 8 (arithmetic_side_effects), Step 9 (print_stdout + print_stderr). Reference the existing Plan M Tier 1d lift MRs (!536–!567) for the exact reason-string vocabulary.

Production code from pre-Plan-M crates typically passes the post-lift workspace clippy check without further changes (Plan M’s clean-up sweeps were per-emission-driven; new crates authored to the Plan H discipline rarely had problematic patterns). Integration test files at tests/*.rs keep the old test-allow vocabulary because the no-transitional-allows gate only checks lib + main roots.

Quality-budget enforcement gate (Plan N Step 9, #522)

Nine code-quality budgets (B1–B8, with B3 split into B3a/B3b) are tracked by cargo xtask quality-budgets, locked at xtask/quality-budgets.lock, and enforced as a blocking gate at the xtask validate quality-budgets step (pre-push battery) + .gitlab-ci.yml ci-tests job. A budget regression — actual > ceiling, where ceiling is the lock when set (else the threshold; see § Semantic note) — blocks merge.

B8 — duplicate-block budget (copy-paste DRY class, 889). Counts near-duplicate impl/fn bodies in production /src/: each non-test body ≥ 40 normalized tokens is reduced to a structural signature (identifiers + literals erased; control-flow keywords, punctuation, and nesting kept), and the metric is the redundant-copy count — for each group of ≥ 2 identical signatures, group_size − 1. Coarse by design: it caps the *size of the copy-paste class, it does not localize it. Seeded at the current count and shrink-only — a B8 regression is fixed by a real refactor (dedup into a shared helper), never** an autonomous lock-raise. It creates downward pressure on the partner-adapter / CLI-wrapper / registry families the 2026-06-28 prevention-gap analysis flagged. Test modules ([cfg(test)]) and tests/ dirs are excluded so volatile test boilerplate can’t flap a merge-blocking gate.

B6 — redundant dep installs is host-independent (#991, 2026-08-04). The count is parsed from Cargo.lock — total entries minus distinct names (Σ per-name N−1) — NOT from cargo tree, whose default --target filter is the host triple and made the count OS-dependent (a Windows dev box counted the windows- stack a Linux host never saw, tripping the blocking gate on a clean main; ADR-007 names Windows a supported dev platform). The metric definition is therefore *all-targets: the lockfile’s resolved set for every platform, identical text on every host. The re-seed moved the lock 65→59 — lower, because the old tree-output scrape double-counted repeated (name, version) lines across tree sections, so the switch is also a correctness fix for the stated N−1 semantic. A fixture-lockfile unit test pins the parse (b6_counts_redundant_versions_from_a_fixed_lockfile), so host sensitivity cannot silently return.

B4 88→89 + B5 10→11 lock-raise (2026-06-18, template resync). The +1 on each came from adopting the claude-quickstart engine source byte-verbatim: plan_lint.rs carries a reasoned #[allow(clippy::expect_used)] on a static-regex compile (B4), and check_docs.rs uses .unwrap_or_default() on an Option display (B5). The engine is byte-synced from upstream, so rewriting it to satisfy a local budget would fork it — the sanctioned lock-raise applies instead.

When the gate fires

The validate step prints the budget table, then bails with the lock-raise instructions. The OVER row identifies the offending budget. Typical responses:

  1. Fix the regression (default). Drop the new violation back below the locked floor. For most budgets the answer is mechanical: extract a helper to bring B2 back under 100 LOC, replace serde_json::Value with a typed DTO for B3a, swap unwrap_or_default() for explicit error handling for B5, etc. Marker comments (// SILENT-OK, // PARTNER-EDGE-UNTYPED, // STRUCTURAL-VALUE) documented in the xtask/src/cmd/quality_budgets.rs module doc exist for legitimate exemptions per budget (the marker must sit on the immediately-preceding non-empty line of the call site).

B3a scope is path-based, not attribute-based. B3a counts the literal serde_json::Value text on any line under a src/ path — including occurrences inside an inline [cfg(test)] mod tests module (those count as src/B3a, not B3b, which only counts tests/-directory files) and even occurrences inside comments. The counter is a per-line text match, not an AST walk, so it does not skip test modules or commented-out code. Exempt a genuine occurrence only with an immediately-preceding // STRUCTURAL-VALUE: (or // PARTNER-EDGE-UNTYPED:) marker on the prior non-empty line — the marker is the sole way to suppress a match; there is no [cfg(test)] carve-out. Prefer serde_json::json! / typed shapes in test code so the marker is reserved for genuinely-polymorphic sites.

  1. Raise the lock (only with rationale). When the regression is intentional and approved — e.g. shipping a deliberately wide one-screen orchestrator that fits the §Style "decompose unless decomposition makes the codebase worse" carve-out — ratchet the lock UP via:

    cargo xtask quality-budgets --write-lock

    The MR description MUST document (a) which budget changed, (b) why the regression is structurally correct, (c) why the §Style carve-out applies. Without that rationale, reviewers reject the lock-raise; the gate is a tripwire, not a rubber stamp.

Lock-DOWN ratchets

--write-lock always writes current actuals, so it ratchets DOWN automatically when budgets clear. Strict-no-grandfather is the workflow norm: ship the cleanup in a dedicated MR, then run --write-lock to tighten the floor. Lock files commit alongside the cleanup. See Plan Q Step 10 (!620 / 7e701d73) for an example.

Semantic note

BudgetReport::ceiling() at xtask/src/cmd/quality_budgets.rs:90 uses the lock as authoritative whenever set: if locked > 0 { locked } else { threshold }. The pre-Plan-N max(threshold, locked) formula silently masked B1 (8→11) + B2 (49→56) because their thresholds (500/100) shadowed their locks. Plan N Step 2 (!604 / #515) was the fix.

Function cohesion enforcement gate (Plan R Step 4, #543)

cargo xtask lints fn-name-and is enforced as a blocking gate at xtask validate step fn-name-and (pre-push battery) + .gitlab-ci.yml ci-tests job. The lint scans every production fn name in crates/, services/, tools/, xtask/ (excluding tests/ directories and #[cfg(test)] modules, plus target/) for and outside the operand-pattern allowlist (and_or / _and_one / _and_two / _and_three / _and_n / _and_back / _and_friends). Drift in either direction blocks merge: new violations not on the opt-out list, OR stale opt-out entries whose fn has been renamed/decomposed/removed.

This gate is a proxy, not a cohesion proof. It catches only the and naming tell — it cannot catch a single-noun-but-multi-purpose function, e.g. a process_record that secretly validates, persists, AND emits an event. A green gate does not establish that a function does one thing; that remains a human-review concern (the J6 cohesion question in the pre-commit protocol), with the workspace clippy::cognitive_complexity = "deny" lint as a partial multi-branch backstop.

When the gate fires

The lint prints the offending entries + remediation instructions. Two paths forward:

  1. Rename or decompose (default). The "no and" rule says a function should do one thing; the and in the name admits two responsibilities. Typical fixes:

    • Split into two single-purpose helpers; the caller composes (e.g. parse_and_validate_Xparse_X + validate_X).

    • Rename to a single-verb concept that captures the shared abstraction (e.g. log_and_generic_500emit_internal_500).

    • For 2-tx orchestrators, rename to a single noun-phrase that captures the orchestration ("pipeline", "cascade", "round-trip").

  2. Add to opt-out with rationale. Plan R only when decomposition makes the code WORSE — Plan Q’s §Style "decompose unless decomposition makes the codebase worse" doctrine applies. Append the path::fn_name to xtask/fn-name-and-opt-out.txt PRECEDED by a # rationale: comment line citing the specific §Style carve-out:

    # rationale: §Style §Important-Function — fuses (...) so the (...) control flow is reviewable on one screen; splitting fragments (...).
    crates/foo/src/bar.rs::do_X_and_Y

    The MR description must reference the carve-out. Without the inline rationale + MR justification, reviewers reject the opt-out addition. Re-bless with cargo xtask lints fn-name-and --bless AFTER hand-editing — the bless preserves your # rationale: lines.

Stale opt-out (the inverse failure)

When a fn on the opt-out list is renamed, decomposed, or deleted, the entry becomes stale. The lint fails on this too — it’s the same monotonic-shrinkage discipline as the axis-coverage gate (see § Axis-coverage enforcement gate). Remove the obsolete entry from the opt-out file (or re-bless if a sweep cleared multiple entries at once).

Existing rationale categories (Plan R Step 3 baseline)

The 11 surviving baseline entries document the three legitimate carve-out categories:

  1. §Style §Important-Function — fns with existing #[expect(clippy::cognitive_complexity)] rationales that defend a single converge point (uniform-401 boundary, response-buffering ownership thread, two-phase compensation tx).

  2. Lint-internal naming — helpers OWNED by a lint whose name reflects what the lint does; renaming hides the lint’s domain.

  3. Bootstrap with one-screen narrative arc (Plan Q §Style carve-out) — phase orchestrators that run sequential gates in execution order; the and lists the gates in run-order; splitting fragments a phase boundary.

New entries that don’t fit one of these three categories require a NEW rationale category articulated in the MR description.

Axis-coverage enforcement gate (Plan N Step 8, #523)

cargo xtask axis-coverage is enforced as a blocking gate at xtask validate step axis-coverage (pre-push battery) + .gitlab-ci.yml ci-tests job — one of the blocking code-quality gates alongside quality-budgets, fn-name-and, and the boundary lints (route-role-coverage, no-transitional-allows, mq-topology, …; each has its own section on this page). It guarantees every integration test declares which behavioral axis it exercises, so the test corpus can’t silently accrete happy-path-only cases.

Every [test] / [tokio::test] in an integration file must be tagged with a // @axis: <X> comment on one of the lines immediately above the test attribute (or end its fn name in _happy / _sad / _evil), where <X> is exactly one of six tokens:

  • happy — the nominal success path.

  • sad — an expected-failure / rejected-input path.

  • evil — an adversarial / malicious-input path.

  • conc — a concurrency / race path.

  • replay — an idempotency / at-least-once / exactly-once replay path.

  • fault — an injected-fault / recovery path.

chaos is not a valid token. A test under one of the legacy directory conventions (tests/{concurrency,recovery,fault,security}/) inherits its axis from the directory and needs no inline tag.

Scope: integration tests only

The lint scans .rs files whose path contains /tests/ (or starts with tests/) — the integration-test tree. It does not scan src/ [cfg(test)] unit-test modules, so a // @axis: comment inside a src/ inline test module is decorative and enforces nothing. (This is the mirror of the fn-name-and gate, which excludes /tests/ dirs and [cfg(test)] modules — the two gates cover disjoint test surfaces, so neither one covers unit tests in src/, and neither one lets a and-named integration test slip past cohesion review. Do not assume fn-name-and lints test-function names; it does not.)

Monotonic-shrinkage opt-out

Genuinely-uncategorizable untagged tests live on the opt-out file xtask/axis-coverage-opt-out.txt (one <relative_path>::<test_fn_name> per line). The list ratchets DOWN: the gate fails on drift in both directions — a new untagged test that isn’t on the list, OR a stale entry whose test has since been tagged, renamed, or removed. Bless the current untagged set with cargo xtask axis-coverage --bless after a tagging sweep; hand-adding an entry (with justification in the MR) is the exception, not the default. Default response to a firing gate is to tag the cited test, not to grow the opt-out.

SQL data-consistency invariant gate (#860)

cargo xtask invariants runs the zero-rows SQL catalog under crates/craig-test-lib/sql/invariants/<service>/.sql (each file is a SELECT that MUST return zero rows; non-zero rows are a violation) against the seeded + test-exercised devstack DBs. Since #860 it is *wired into the pre-push battery at the xtask validate invariants step (invariants::run_gate), immediately after the integration suite has exercised the DBs. CI is force-merged, so this blocking gate lives in pre-push, not CI.

Curated blocking subset. Only invariant NAMES in BLOCKING_INVARIANTS (in xtask/src/cmd/invariants.rs) fail the gate; every other catalogued invariant is report-only (printed, non-failing). The blocking set holds only invariants that are green by construction against a fresh devstack:

  • ffp_amount_stale — GENERATED-column-enforced (#861).

  • occupancy_mismatch, occupancy_over_capacity — trigger- + CHECK-enforced (#761/#780).

  • day_count_gross_consistent — write-time consistency the seed + handlers satisfy (#760/#773).

Report-only invariants include grace-window/timing checks (retried_never_reterminal, stuck_pending_transactions) and invariants tracking still-open fixes (validated_with_errors → #782, null_rule_output → #785) — a known-open defect must not red-gate the battery. Promote a report-only invariant into BLOCKING_INVARIANTS only once its fix has landed and it is green against a fresh devstack. Preview the gate outcome manually with cargo xtask invariants --gate; a full non-gated report is cargo xtask invariants. The financial↔placement cross-DB "void every payment on an ended placement" invariant (#776) is not expressible in this single-DB sweeper and is covered by integration tests instead.

Field-encryption seed verification gate (epic &66 C6, ADR-048 §D6)

cargo xtask verify-seed --expect {keyless,keyed} verifies the devstack seed’s field-encryption state against the mode the caller asserts (never auto-detected — an auto gate would pass a silent key-mount failure vacuously). It re-checks the craig-seed one-shot completed (a state-aware poll, not the old exit-code-only read that misclassified a still-running container; when the exited container has been pruned — routine docker container prune housekeeping — the durable _seed_marker sql-phase row the seed transaction committed stands in as the completion evidence, #1420) and asserts, on the raw craig_cases DB, that every covered column and crypto_key_lineage.kcv matches the expected mode. It is wired into the pre-push battery at the xtask validate devstack phase (--expect keyed, before the mutating integration suite) and, non-blocking, into the .gitlab-ci.yml pentest job (allow_failure, main/schedule). Both call sites assert --expect keyed since C7 (#987) activated devstack encryption; the CI side receives the key from the repo-committed encrypted store, decrypted by devstack/ci/write-field-key.sh with the masked+protected CRAIG_CI_AGE_KEY identity in the pinned devtools container (ADR-048 §D5 as amended by ADR-064 U7, #1385 — an unset variable is a hard error, never a keyless CI devstack). Run manually against a running devstack with cargo xtask verify-seed --expect keyed (add CRAIG_CASES__DATABASE_URL=… to skip port reconciliation).

Context-tier hygiene gates (#615)

Agent context lives in tiers, each pointing DOWN to the next when it doesn’t answer a question:

  • memory (~/.claude/projects/<project>/memory/) — ONLY machine/user-specific facts for agents on this box (preferences, decisions, shell/path/devstack quirks) + a SHORT active-work pickup pointer.

  • .claude/rules/ + docs/modules/standards/ — the operating rules: terse, always-on agent digests (.claude/rules/, synced from the claude-quickstart template) backed by the canonical full-prose standards (the standards module, also synced). Drift-gated by cargo xtask check-docs.

  • Antora (docs/modules/ROOT/) — all project knowledge (architecture, ADRs, plans, service/data-model/crate catalogs) + this project’s convention complements (this page, Testing Reference (CRAIG)). The canonical home; one fact, one home.

The failure this guards against (the #613 right-sizing): a doc re-grows into a catalog that duplicates Antora and drifts stale. The gates target the structure that lets facts go stale, not the facts themselves (undecidable):

  1. doc-pointer integrity (cargo xtask lints doc-pointer-integrity, blocking). Every markdown link AND every repo-rooted path pointer (e.g. docs/modules/ROOT/pages/services.adoc, .claude/rules/foo.md) in .claude/CLAUDE.md must resolve to a live file. CLAUDE.md is the agent index — a pointer map into Antora + .claude/ — so this catches pointer-rot (a pointer at a moved/archived page). Scope is CLAUDE.md only; glob/template/module/URL tokens + non-rooted bare filenames are illustrative and skipped, and prose docs (this file, Testing Reference (CRAIG), …) are not scanned.

  2. cargo xtask audit-memory (advisory, NOT a gate). Memory is machine-local + outside the repo, so it can’t be CI-gated. This on-demand report flags an oversized MEMORY.md, dated session-snapshot files (delete after the work lands — history lives in git/Antora), orphans, and the largest files. --strict exits non-zero on a breach for an optional local hook.

The doc-pointer-integrity gate keeps CLAUDE.md’s pointers valid so the canonical detail stays in Antora (near the source it describes) where it can’t rot in a second place. (Before the 2026-06 template resync this tier also held .claude/docs/ thin-index docs guarded by a B8 agent-doc-index size budget; that layer was retired when conventions moved to .claude/rules/ + the standards module and the project docs moved into Antora ROOT.) See the delivery-protocol standard § Context Hygiene for the upstream policy.

Tracing PII-field gate (#896)

Wired at the cargo xtask validate tracing-pii-fields step (blocking, phase_code_security_lints). A single tracing field interpolating a child’s name/DOB/address/SSN/phone/email is a reportable PII incident for a CCWIS. The 2026-08-24 three-agent sweep (cases 26 sites / financial+placement 61 / intake 38 + fleet census 556) found ONE real finding — intake’s redact_upstream_error logging the full raw upstream body at WARN (SHINES/cases 4xx bodies can echo submitted SSNs/names) — plus two contingent raw-value echoes in the NCANDS export warns; all fixed with shape-only logging (length + SHA-256 prefix). This gate keeps the fleet clean:

  • Surface: every trace!/debug!/info!/warn!/error!/event! AND *_span! invocation (bare, tracing::- or log::-prefixed) over the workspace-member src trees, PLUS #[instrument(fields(…​))] on free fns, impl methods, and trait methods. Field forms covered: ident = …, bare-ident captures, %/? shorthand (leaf-matched), dotted explicit names (person.name = …), and string-literal names. Syn token-stream parsing, never line-based — ~46% of fleet macro sites are multi-line. Recorded out of scope: message-position interpolation (format text is free prose; the field-ident surface is the machine-checkable one).

  • Rule: FIELD IDENTS (and %/? shorthand captures, matched by their final path segment) against the exact denylist name | first_name | last_name | dob | date_of_birth | address | ssn | phone | email | narrative | rationale | body. Value bindings are deliberately out of scope — rule_set = %name is the CORRECT pattern (the census proved every %name binding sits under a safe ident).

  • Escape: // pii-field-ok: <reason> on the line above the macro (one seed: reporting’s S2S problem-details body log, whose PII-safety rationale predates the gate); empty reasons and stale markers fail.

  • Universe floor: 558 sites at seed (the lint’s own walker — spans + instrument included); below 450 the macro detection is presumed broken and the gate fails loudly.

Implementation: xtask/src/cmd/lints.rs::run_tracing_pii_fields + tracing_pii_tests.

Serialize-credentials + store-model gates (#879)

Two gates wired at cargo xtask validate (blocking, phase_code_security_lints):

Serialize-credentials (cargo xtask lints serialize-credentials) — a heuristic tripwire over the wire-name surface, and its doc says so. Named fields of Serialize-deriving structs AND enum variants (derives seen through cfg_attr) whose ident or serde(rename) wire name contains secret | password | token | api_key | auth_config | signing_key | client_secret | private_key | credential | bearer | verifier | nonce must either #[serde(skip_serializing)] or carry a per-field // serialize-credential: <reason> marker on the line DIRECTLY above (per-field, never per-struct; stale markers fail; empty reasons fail). The 17 seed markers across 10 files are the auditable inventory of every place credential-shaped material intentionally serializes (sweep tokens, idempotency occurrence tokens, the CLI/test-lib token caches, the encrypted WebSession cookie, PkceState’s PKCE verifier + OIDC nonce, the one-time `IssueKeyResponse.api_key, the hash-only PartnerIntentV1.auth_config). Test-only items are excluded by cfg predicate; craig-test-lib is deliberately NOT path-exempt here. Recorded false-negatives: flatten chains, manual impl Serialize, non-use aliasing.

Store-model response baseline (cargo xtask lints store-model-baseline) — a COARSE TEXTUAL per-file ratchet over handlers returning store models directly: xtask/store-model-responses.toml maps each services/*/src file to its count of lines containing both Json< and store::models:: (48 files / 153 sites at seed). Any file above its count — or any new file above zero — fails and is NEVER blessable; below is stale and --bless rewrites DOWNWARD only. The claim is exactly "no per-file growth of the textual pattern": aliased imports and multiline generics under-/over-count, same-file swaps are the recorded residue, and the empty [counts] table is the desired end state.

Advisory-ignore audit gate (#891)

Wired at the cargo xtask validate advisory-ignore-audit step (blocking, phase_doc_lints; pure file parse, no subprocess). cargo-deny/cargo-audit (pre-push hook + CI) remain 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). The gate enforces the structural contract they cannot:

  1. Ordered parity: the deny.toml and .cargo/audit.toml ignore arrays compared as ordered lists (the recorded byte-for-byte convention), duplicates rejected, RUSTSEC-YYYY-NNNN shape checked.

  2. Per-ID structured records (deny.toml only — audit.toml’s header defers rationale there): each ID’s contiguous preceding comment block must carry # crate:, a non-empty # rationale:, # tracked: NNN OR retire: <condition>, and # review-by: YYYY-MM-DD. No shared blocks — a run of IDs after one block leaves the later IDs marker-less, which fails (paired advisories like the quick-xml pair each carry their own record). # lock-only is human documentation of why cargo-deny warns advisory-not-detected (rkyv/#1376); the gate attaches no semantics.

  3. Review-by expiry: today past the date is a HARD failure — every ignore is time-boxed, so staleness is a stop rather than a judgment nobody re-runs. RUSTSEC-2023-0071’s date rides with #764 (the open reachability re-audit; the gate never judges reachability).

The record parser is hand-rolled over the raw array region (TOML parsing discards comments) and proptested: totality over line soup, generated well-formed records accepted, any dropped marker rejected. CI hygiene shipped with the gate: the cargo-audit job’s rules:changes now watches deny.toml + .cargo/audit.toml, so a config-only MR re-runs the advisory scan. Implementation: xtask/src/cmd/lints.rs::run_advisory_ignore_audit + advisory_ignore_audit_tests.

Partner parse-prop coverage gate (#870)

Wired at the cargo xtask validate parse-prop-coverage step (blocking, phase_code_security_lints). Three layers, each closing a gaming vector the wave-1 review proved against weaker designs:

  1. Census — every crates/craig-partner-* workspace member must expose an adapter type Inbound = X; (located by syn inside the impl; src/adapter.rs or src/adapter/mod.rs) or hold a named entry in PARSE_PROP_NON_ADAPTER_EXCEPTIONS (today: craig-partner-audit, the from_value dispatch crate — no adapter file; its decode surface is censused by the #1575 layer below). Deleting an adapter cannot silently deschedule a crate; the 11-adapter seed floor catches census collapse; a listed crate that gains an adapter goes stale.

  2. Property presence — some test file must contain a proptest! invocation whose TOKEN STREAM carries both from_slice and X. Tokens, not text: a doc comment naming both can never satisfy it, and deleting the property body removes the macro. (Presence-tier honesty: this proves a property exercises the decode, not its assertion semantics — the authz-coverage tier.)

  3. Compilation reachability — the file must be a top-level tests/*.rs target or reachable via a mod x; / #[path] chain (caps' nested tests/properties/ layout is the seed case). Orphaned files Cargo never compiles do not count.

The #1575 second census layer covers the erased AUDIT-decode seam the adapter census misses: every partner crate’s src/lib.rs must expose a PUBLIC audit_payload_from_value free fn (dispatch crate: PartnerAuditEvent::decode_jsonb), each needing a compiled proptest! whose tokens call the fn BY NAME — the call sites are type-inferred (serde_json::from_value, no turbofish), so the signature-anchored fn name is the only reliable token. Seed floor 12 (11 per-partner fns + decode_jsonb); same reachability rule; the totality properties live in each crate’s tests/parse_never_panics.rs (arb-JSON documents; ssa-solq’s pre-existing property is string-shaped — same totality tier, same token).

Escape hatch: // allow-no-parse-prop: <reason> on the line above the anchoring declaration (the type Inbound, or the audit-decode fn signature) — the reason MUST cite a #NNN issue. The properties execute in the pre-push full battery — the sole functional-correctness gate; CI’s ci-tests job runs the --lib --bins subset, which never compiles tests/ targets, and its prose says so (#1577 corrected the claim; the selection deliberately stays). Implementation: xtask/src/cmd/lints.rs::run_parse_prop_coverage + parse_prop_coverage_tests.

Workspace dependency-inheritance gate (#869)

Wired at the cargo xtask validate workspace-dep-inheritance step (blocking, phase_doc_lints — pure TOML, no compile). Membership authority is the workspace itself: [workspace].members minus .exclude, expanded from the root manifest (never fixed directory lists), with a 66-member seed floor. Three checks over dependencies/dev-dependencies/build-dependencies and every [target.*] variant:

  1. A dependency whose resolved package name (package = rename honored; git deps compared by name; dotted/inline/expanded spellings normalized by the parser — proptested) exists in [workspace.dependencies] must inherit via { workspace = true } (additive features stay). Exceptionable ONLY through the committed xtask/dep-inherit-exceptions.toml (mandatory reasons; bidirectional — a stale entry fails, so e.g. the craig-web rand 0.8 entry retires itself when #1574 lands).

  2. [package] version/edition/license must each be PRESENT and .workspace = true — omission is a violation, not a pass. rust-version is recorded out of scope (59/66 members omit it).

  3. default-features = true anywhere, and a hardcoded twin of a default-features = false workspace dep that drops the false, fail with NO exception channel.

Seed state: 66 members, ~1,210 dependency declarations, 0 violations, 3 exceptions (rules-client/xtask reqwest feature-subtraction, intake-sdk tokio leaf-slimming; the craig-web rand-divergence entry retired with the #1574 migration). Implementation: xtask/src/cmd/lints.rs::run_workspace_dep_inheritance + workspace_dep_inheritance_tests.

Reqwest construction choke-point gate (#771 + #866)

Wired at the cargo xtask validate reqwest-client-new step (blocking). #866 (epic &63) rebuilt the #771 line scanner as a syn visitor over the workspace-member source surface (membership from [workspace].members minus .exclude — tools/ and plugins/ included):

  • Ad-hoc clients banned outright: reqwest::Client::new() / Client::default() (full-path, use-imported, as-aliased, or glob-imported) — hold the service-wide craig_common::build_shared_client client (ADR-014) instead.

  • The builder path is machine-held: Client::builder() / ClientBuilder::new() / ClientBuilder::default() reconcile against REQWEST_BUILDER_ALLOWLIST(file, expected count) pairs over the five sanctioned bespoke builders (shared builder itself, auth discovery/JWKS bootstrap, intake-sdk standalone transport, exchange-transport egress). A count above the entry is a fresh decision (new rationale + const bump); below or zero is a STALE entry that must bless down. A bare file list would be a wildcard for piggybacking; the count is the ratchet.

  • Test exclusion is by cfg PREDICATE, not line position: items under [cfg(all(test, feature = "…"))] are exempt; [cfg(any(test, feature = "…"))] is production-reachable and scanned; an early #[cfg(test)] mod x; declaration no longer truncates the scan (the retired scanner left ~1,900 production lines of intake’s main.rs unscanned). The predicate detector is proptested against an oracle over generated all/any/not nesting.

  • Recorded residues: crates/craig-test-lib/ stays path-exempt (test infrastructure; outside the production choke-point threat model); reqwest::blocking in xtask is out of scope (dev tooling); non-use type aliasing (type C = reqwest::Client;) is the recorded false-negative — syn has no type information. The shipped db-error lint’s line scanner shared the old truncation blind spot until #1576 fixed it (external mod tests; declarations no longer truncate; only inline trailing test modules stop that scan).

Implementation: xtask/src/cmd/lints.rs::{scan_reqwest_constructions, reconcile_builder_allowlist} + the reqwest_construction_tests module.

Route role coverage (#490)

Every .route(…​) inside the protected_routes block in services/craig-web/src/main.rs MUST carry one of:

  1. Explicit role gate.layer(axum::middleware::from_fn(middleware::require_*)) inside the route’s handler expression. Use this when the route needs a higher tier than caseworker+ (e.g. require_admin_or_supervisor for security/audit, NCANDS approve+transmit; require_admin_only for partner administration).

  2. Default-tier carve-out — a // caseworker+ comment anywhere within the .route(…​) span. Marks the route as intentionally inheriting the caseworker+ default that all protected_routes get via the outer require_auth layer. Use this for read-only views, list pages, and write paths that any worker can use.

Without one of these markers, cargo xtask lints route-role-coverage (wired into xtask validate) bails the build. A new route added under protected_routes without an explicit gate or carve-out is a violation — the lint exists so the caseworker+ default cannot silently absorb a higher-privilege handler.

The carve-out marker may live on the .route( opening line OR on any line inside the call (rustfmt sometimes wraps the comment inside the parens — both placements are equivalent for the lint). For multi-line routes the canonical placement is on a line immediately below the .route( opener.

Background: this lint is the build-time guarantee that #476’s per-route role gates can’t be silently bypassed by a future contributor. See ADR-027 for the underlying role model.

MQ topology choke-point gate (#1198)

cargo xtask lints mq-topology is a blocking xtask validate step (epic &75 C4, ADR-003 §Amendment #1198). Raw AMQP queue/exchange declaration tokens are allowed only in crates/craig-mq/src/, crates/craig-test-lib/src/, or test paths (tests/ at the repo root or any nested /tests/ directory) — a declaration anywhere else would bypass the queue_args choke-point that pins every CRAIG queue x-queue-type=classic, silently falling to the broker/vhost default queue type.

Honest scope: this is a crate-boundary convention gate. It does not prove in-boundary declarations carry the right arguments — that guarantee is queue_args + its full-table equality unit pins inside craig-mq. The scan is uniform-by-token (comments and string literals count — the sibling lints' bluntness trade), enumerates tracked *.rs only, and its two needles exist in xtask source only in split-literal form: an own-source unit test turns red if a contiguous token ever lands in lints.rs. Recorded residuals: declarations made through the RabbitMQ management HTTP API and non-Rust code are out of scope.

Remedy when it fires: declare queues through craig_mq::Subscriber (subscribe/ subscribe_exclusive/subscribe_dlq); exchanges are operator-owned topology (#1202) and are never declared from service code; test-support declares belong in craig-test-lib (declare_core_exchanges) or a tests/ path.

sops-policy gate (#1382, ADR-064)

Blocking xtask validate step (and in CI via ci-tests, whose path rules include .sops.yaml + secrets/** so a store-only rotation MR still runs it): the repo-committed encrypted secrets store must stay structurally sound. Enforced: strict .sops.yaml grammar (exactly creation_rules with compiling path_regex + canonical-bech32 age recipients, plus a parser-differential tripwire — the lint’s YAML view and the store tooling’s own recipient parser must agree or the gate fails closed); encrypted-only ciphertext (every leaf ENC[AES256_GCM,…], diagnostics name keys only — never values); .sops.yaml↔ciphertext recipient parity; a split-form age secret-key needle over both surfaces; and NO non-YAML artifacts under secrets/ (the walk is filesystem-side, so untracked decrypt residue fails). Armed from day one — a missing store is itself a violation (partial-revert guard).

Remedy when it fires: recipient changes go through cargo xtask secrets add-recipient / remove-recipient, store edits through cargo xtask secrets edit; never hand-edit either surface. Run cargo xtask lints sops-policy for the full list, cargo xtask secrets check for the non-disclosing store verification.

release-artifact fault-feature gate (#1502, ADR-067 §D4)

Blocking xtask validate step (cargo xtask release-artifact-gate, folded into the pre-push battery): the contested-environment fault-injection seams (craig-mq/fault-injection, craig-crypto/fault-injection, craig-store/test-util) add hook fields to real production structs but MUST stay out of every release artifact. They ship default-off and activate ONLY through dev-dependencies (a crate’s own integration tests self-dev-dep with the feature; cross-crate consumers dev-dep craig-test-lib with its fault-injection forwarding feature). The gate walks cargo tree -e normal,build (normal + build deps only — dev-deps excluded) over the Dockerfile’s release -p service set (parsed from the cargo build line, the single source of truth) AND xtask, and fails if any forbidden (crate, feature) pair is active. Honest claim (ADR-067 §D4): "absent from release artifacts and every normal-dependency graph, machine-enforced" — NOT "impossible" (the seams compile under --all-features, by design).

Remedy when it fires: a normal (non-dev) dependency enabled a host crate’s fault feature — make that edge a dev-dependency, or drop the feature from it. Run cargo xtask release-artifact-gate for the offending (crate, feature) pairs.

Program-verification gate (C24, #1519, ADR-067 §D9)

Two blocking xtask validate steps that BRACKET the integration battery, plus the standalone cargo xtask program-gate:

  • before nextestprogram_gate::reset purges every executed-fault record (every test-results/fault directory, discovered by walking the checkout) and writes the run stamp test-results/fault/.gate-stamp;

  • after nextestprogram_gate::verify checks that every record postdates the stamp, that no record file was unreadable (the aggregation counts read/parse failures), that every armed fault fired, that the run executed faults AT ALL, that every recording class fired, and that the program’s two self-tests still exist in the nextest inventory; then retains test-results/fault/fault-report.json and consumes the stamp (single-use — only on success, and never from the standalone command).

The fault layer is REQUIRED for the same battery (fault-preflight --required), so a run cannot go green with the fault sidecar absent. validate --skip-devstack never reaches either step (its substitute battery is --lib --bins) and is the one sanctioned fault-optional mode.

Remedy when it fires:

  • "no run stamp" — this verification already passed once (the stamp is consumed on success), or the reset/verify pairing broke mid-run. Either way the accounting cannot be attributed to a run again; re-run the battery.

  • "record(s) predate this run’s reset" — two batteries' evidence is mixed, or the record filesystem’s clock lags this host’s. Delete the test-results/fault directories and re-run.

  • "record file(s) could not be read or parsed" — a test was killed mid-write. The missing record could be the armed-but-unfired fault the gate exists to catch; re-run rather than deleting it.

  • "armed but NEVER fired" — a real finding: the test armed an injection its codepath never reached, so it passed for the wrong reason. Fix the leg, not the gate.

  • "ZERO executed faults" — the fault-gated legs did not run (--run-ignored all missing, or a filter excluded them).

  • "class(es) … reported ZERO this run" — a recording class lost its legs or its recorder.

  • "the gate’s self-test(s) are gone" — a rename updates SELF_TESTS in the same diff; a delete retires a guarantee and needs saying out loud.

Honest limits, recorded in the module doc and the runbook rather than implied away: the per-class floor is enforced only for the classes that emit records today (#1546), the armed⇒fired check is only as sharp as the recorders that observe firing (#1547), and the self-test check pins EXISTENCE, not behavior.

Known Agent Biases

AI agents trend toward recommending older, heavily-documented tools over newer, better alternatives. When making architectural recommendations, always perform web research for current state of the art (see the delivery-protocol standard). Known stale defaults to watch for:

  • OpenSSL over rustls (rustls is mandated — see deny.toml)

  • Selenium/Cypress over Playwright (Playwright is mandated for E2E)

  • reqwest with openssl-sys over reqwest with rustls-tls feature

  • chrono over jiff or time (evaluate current state before choosing)

  • Heavyweight ORMs over lightweight query builders

  • Class-based or inheritance-heavy patterns over composition and traits

  • Older serialization formats over modern alternatives

  • Assuming library APIs from training data instead of reading current docs — methods get renamed, removed, or change signatures between major versions. Always verify against the actual version in Cargo.toml (see the delivery-protocol standard § Library usage)

  • Using deprecated config file formats (e.g., cargo-deny v1 format when v2 is current)

  • Jumping to workarounds instead of diagnosing root causes — when something breaks, agents tend to try alternative approaches rather than reading the source to understand WHY it broke (see the delivery-protocol standard § Debugging)

  • Defending wrong mental models against contradicting evidence — rationalizing instead of re-examining

  • Manufacturing borderline findings to make an audit step "feel productive" — an audit is a measurement, not a mandate. When the discovery honestly surfaces zero action items, the audit documentation itself (clean baseline + evidence the discipline holds) IS the deliverable; forcing a false positive ratchets discipline down

  • Leaving stale placeholders in a plan/close-out Status table — the author knows each step’s real MR + SHA but stays in working memory while the "this MR"/"TBD" cells accumulate. Dispatch a fresh Explore subagent that reads the FILED plan body cold at every archive/close-out MR; it reliably catches the gaps the primary agent’s working memory papers over

  • Charging into a /loop or resumed prompt without verifying the work is still open — sessions accumulate stale queued prompts that may reference already-shipped work. Run a quick 2–3 command state check (git log for the touched file + nav entry + issue-tracker view) BEFORE doing the work; discovering the task is already done is a valid, correct outcome

  • Trusting an audit subagent’s verdict TAG over its own body evidence — the tag and the evidence are generated in different passes and can disagree (a FAIL tag over a body that clearly demonstrates PASS). The body evidence is ground truth; re-read the cited file:line before reacting, and don’t reflexively block on a tag mismatch

  • Blindly implementing an aged backlog item from its original framing — old issues are frequently already resolved by later work, or rest on a premise the implemented design has since contradicted. Verify each against current main FIRST (grep/read the cited files, run the relevant test); closing-as-verified-resolved with a criterion-by-criterion comment is a valid deliverable, and a since-contradicted premise is a domain decision, not a blind implement

  • Building a work master-list from all-state (or unverified) issue inventory — query OPEN-only state (not --all), and before recommending any item sample its body for external-CR / other-team / gateway / stakeholder-filed signals; a top-ranked recommendation drawn from a closed or externally-owned issue is worse than none

  • Recommending a new tool, dep, script, or pattern without first checking whether the project ALREADY has infrastructure for it — the reflex defaults to the lowest common denominator ("add an ad-hoc bash script", "pull in a new crate") instead of the established CRAIG pattern, and quietly builds parallel machinery beside an existing tool. Before proposing anything new, grep cargo xtask --help, scan tools/ and tests/, check Cargo.lock / cargo tree, and skim the plans archive for prior work on the same need. If the existing infrastructure is genuinely wrong-shaped for the new use, say so explicitly with reasoning — never silently pretend it doesn’t exist

This list is a living document. When you catch an outdated recommendation, add it here.

Edit this page · latest