Plan N: Quality-Gate Enforcement

On this page

Status

Step Description Status

1

Plan filing — body lands as a docs-only MR alongside epic &40 + step issues #514–#525. nav.adoc Active list gains a Plan N row; CHANGELOG Unreleased entry under docs(plans, quality-gate-enforcement).

Done (2026-06-06) — !597 / cb1cbcd6

2

Phase A: foundationsxtask/src/cmd/quality_budgets.rs:65 + :149 semantic fix (let ceiling = if locked > 0 { locked } else { threshold }; stops B1+B2 silent masking); comment-skip mechanism for // SILENT-OK, // PARTNER-EDGE-UNTYPED, // STRUCTURAL-VALUE markers in B3a/B3b/B5 counters; 7 unit tests including the masking-bug regression test; quality-budgets.toml header refresh; .claude/docs/coding-conventions.md marker-vocabulary sub-section. Gate stays report-only this MR — Phase D is the flip. Scope correction: B6 cfg_attr-test counter recognition dropped after implementation discovery — the current B4 regex r"\[\s*allow\s*\(" requires allow directly after [, so cfg_attr-wrapped allows aren’t counted today. B4 pay-down at Step 6 sweeps real per-item allows.

Done (2026-06-06) — !598 / 37395b3c. B1 (8→11) + B2 (49→56) now report OVER (was masked).

3

Phase B step 1: B7 untyped test-client (197 → 194; 3 sites) — easiest sweep first. Type the last 3 fn …​ → serde_json::Value methods in crates/craig-test-lib/src/clients/*/.rs with typed DTOs from the matching *-contracts crate; update test callers.

Done (2026-06-06) — !599 / a5ca406a. Three IntakeClient status methods typed to ReportStatus (already pub-re-exported from craig-intake-sdk). 5 callers verified. B7 → LOCKED.

4

Phase B step 2: B3b serde_json::Value in tests (63 → 31; 32 sites) — type 32 test-body Value sites across crates//tests/ + services//tests/. Typed DTO conversion when possible; otherwise // STRUCTURAL-VALUE: <reason> marker (Phase A counter honors it).

Done (2026-06-06) — !600 / ef862010. 28 STRUCTURAL-VALUE markers (OIDC × 11, JDM × 4, EXPLAIN plans × 6, RabbitMQ envelopes × 4, Turnstile × 3) + 3 typed_dto_pilot.rs rewrites (.expect() form drops annotation). B3b → LOCKED.

5

Phase B step 3: B5 unwrap_or_default (219 → 175; 44 sites) — per-site triage. Diagnostic fallback (extracting an error body where empty-on-failure is acceptable) → add // SILENT-OK: <reason> marker. Real value chain → convert to typed error propagation (?, ok_or, map_err, explicit match).

Done (2026-06-06) — !602 / e9dd2eb0. 212 SILENT-OK markers across 90 files; 0 TYPED rewrites (Explore-agent triage of all 221 sites classified ALL as diagnostic fallback). Categories: HTTP body extraction (partner adapters × 20 + service handlers × 18), OAuth2/OIDC body + claim projection (6), cookie JSON encode (3), multipart field extraction (8), query-param Option<String> → "" (~70 BFF list handlers), serde view-model parse fallback (~25), mock-server fixtures (24), reference-data seed (2), residual idempotency/db/template/file-header sites. Lock ratcheted 175 → 10 (far below historical lock; strict-no-grandfather preserves B1/B2/B3a/B4 historical OVER locks). 9 in-string-literal matches in quality_budgets.rs synthetic tests deliberately excluded. B5 → LOCKED.

6

Phase B step 4: B4 #[allow] attributes (332 → 142; 2–3 MRs) — after Step 2’s cfg_attr-test counter recognition, the actual B4 count is likely lower than 332 (~250–280 estimate). Remaining ~110–140 per-item allows audited via Plan M Tier 1 cadence. By-crate batches: (a) foundation crates (craig-common, craig-auth, craig-crypto, craig-db, craig-mq, craig-store), (b) contracts + craig-intake-sdk, (c) services (1 MR per 2–3 services).

Done (2026-06-06) — !603 / 59588bbb. Workspace-wide mass conversion [allow(<lint>, reason = "…​")][expect(<lint>, reason = "…​")] for actively-firing suppressions. 309 sites flipped via Python script; 75 reverted to [allow] after cargo clippy --workspace --all-targets — -D warnings surfaced unfulfilled_lint_expectations (stale allows where the underlying lint no longer fires). Net: 234 self-cleaning [expect] + 97 preserved #[allow]. By-crate batch slicing dropped (single-MR scope made sensible because the conversion is mechanical and clippy-verified atomically). Lock ratcheted 142 → 87. B4 → LOCKED.

7

Phase B step 5: B3a serde_json::Value in src (447 → 236; 3–4 MRs) — largest sweep. By-service batches, smallest-first: (a) craig-rules + craig-reference + small crates (20–40 sites), (b) services/craig-exchange/src/adapters/ partner-edge sites with // PARTNER-EDGE-UNTYPED: #441 markers per Plan L F-064 (60–80), (c) event-subscriber payloads across craig-financial + craig-reporting per Plan I F-037 continuation (40–60), (d) residual craig-authz + craig-crypto + CLI with // STRUCTURAL-VALUE: markers per architectural fit (30–50). Phase B exit gate: cargo xtask quality-budgets --fail-on-regression exits 0 against historical locks.

Done (2026-06-06) — !604 / 89555a45. Single-MR sweep (by-service batching dropped — single mechanical pass + clippy-verify is atomic). Explore-agent triage of all 447 sites: 251 STRUCTURAL-VALUE (JDM rules, authz audit, encryption envelopes, RFC 7662/9457, idempotency cache, JWT signing, outbox/inbox payloads, partner exchange schemas, intake forwarding, service-tier API JSON builders, BFF view-models, CLI/xtask plumbing); 19 PARTNER-EDGE-UNTYPED (Plan L F-064 carve-out across craig-partner-*, craig-exchange/adapters, mock-server); 32 TYPED (store-model JSONB — left for separate refactor); 11 TEST-FIXTURE (xtask synthetic string literals). 270 markers inserted across 107 files. Marker-collision fix in craig-web/src/routes/report.rs (3 sites) — pre-existing B5 SILENT-OK markers collapsed onto same line as new STRUCTURAL-VALUE marker per counter’s substring-contains semantics. Lock ratcheted 236 → 178. B3a → LOCKED. Phase B COMPLETE — all 7 budgets at-or-below lock. Phase D (Step 9) UNBLOCKED.

8

Phase C: coverage tagging convention + 587-test sweep (2 MRs) — Step 8a: extend xtask/src/cmd/coverage_matrix.rs to parse // @axis: <happy|sad|evil|conc|replay|fault> + // @workflow: <slug> comment annotations via raw line-scan beside syn-discovered ItemFn spans (not via syn attrs — // comments are NOT in the AST). New validate step [4j/14] axis-coverage; opt-out file xtask/axis-coverage-opt-out.txt lists the 587 untagged tests, shrinks monotonically. Step 8b: sweep the 587 untagged tests via xtask/src/cmd/axis_sweep.rs one-shot tool; heuristic classification (sad/evil/happy from name patterns); human review of the script-generated diff; remove from opt-out file as each batch ships.

Done (2026-06-07) — Step 8a !625 / 5c47d77f (lint tooling + 1071 baseline) + Step 8b !626 / e1140ddc (heuristic sweep). Step 8a: workspace-wide scanner detects 3 tagging mechanisms (// @axis: comment + name-suffix + dir-based) + [4j/14] axis-coverage validate step (blocking). Step 8b: NEW xtask/src/cmd/axis_sweep.rs heuristic classifier (5-step priority order: skip-prop > evil > sad > happy > skip) + --report dry-run + --apply edit mode. Applied: 322 tagged across 95 files (happy=107 / sad=202 / evil=13); opt-out 1071 → 749 entries (-30.1%). Remaining 749 are conservative-skip cases (generic CRUD verbs, prop_* invariant tests) — case-by-case tagging from here.

9

Phase D: flip enforcement gate ON — PRECONDITION: Steps 3–7 all Done; cargo xtask quality-budgets --fail-on-regression exits 0 against historical locks. Single MR adds validate step [4i/14] running quality_budgets::run(.., fail_on_regression: true, ..); .gitlab-ci.yml ci-tests script gains the same command; quality-budgets.toml header refreshed to "enforced"; .claude/docs/coding-conventions.md gains "Quality-budget enforcement gate" sub-section documenting the lock-raise procedure (cargo xtask quality-budgets --write-lock with MR-description justification). Synthetic regression test confirms gate now blocking.

Done (2026-06-07) — !622 / 64ab6caa. [4i/14] step added via enforce_quality_budgets() helper (extracted to keep phase_doc_lints under the 100-LOC B2 ceiling — a meta-test that the gate would have caught its own MR). .gitlab-ci.yml ci-tests job adds the regression check between cargo clippy and cargo nextest. quality-budgets.toml header flipped to "ENFORCED". .claude/docs/coding-conventions.md § Quality-budget enforcement gate documents the lock-raise procedure + when-the-gate-fires response decision tree + the masking-bug semantic context. Synthetic regression test fail_on_regression_passes_when_actuals_within_lock mirrors the existing fail_on_regression_returns_err_when_count_exceeds_ceiling from Phase A; both pass. Plan Q Step 10 (!620 / 7e701d73) precondition-cleared this MR (B2 49 → 15 LOCKED).

10

Phase E: CI parity — --skip-devstack flag + CI mirrorxtask/src/cmd/validate.rs gains --skip-devstack bool flag mirroring --skip-docker (skips devstack::ensure_ready at line 277, reconcile_ports_env at line 286, [12/14] integration test run at line 316). Parity smoke check via #[cfg(test)] mod tests fingerprint asserts the no-devstack step set matches a hardcoded list. .gitlab-ci.yml ci-tests script replaced with cargo xtask validate --skip-devstack --skip-docker. CI duration ~2 min → ~4–7 min.

Done (2026-06-07) — !623 / f430f7db. --skip-devstack flag gates phase_devstack_and_tests AND check_signing (CI has no GPG key). New phase_ci_unit_tests runs cargo nextest --profile ci --lib --bins as the no-devstack substitute. enabled_phases(args) planner doubles as upfront stdout banner + test fingerprint anchor; 2 new tests (ci_phase_plan_matches_hardcoded_no_devstack_list + local_phase_plan_runs_devstack_and_tests). .gitlab-ci.yml § ci-tests script collapsed to a single cargo xtask validate --skip-devstack --skip-docker — CI now also exercises [3-6c/14] doc/security/template lints + [9b/14] machete + [11/14] build that were previously local-only.

11

Phase F step 1: hooks-exec-check CI job — new .gitlab-ci.yml job verifies .githooks/{pre-commit,commit-msg,pre-push} are tracked at mode 100755. Edge cases: missing file → exit 1 with explicit error; wrong mode → exit 1 with mode + path in error. Triggers on .githooks/** change or main branch push. Catches the "lost exec bit" failure mode documented in known-issues.md.

Done (2026-06-07) — !624 / 2c9892c3. NEW hooks-exec-check job iterates the three tracked hooks, fails with <path> is not tracked (missing) or <path> tracked at mode <actual>, expected 100755 — lost exec bit (wrong mode). Remediation one-liner in stderr. Triggers: MR pipelines touching .githooks/** + every main-branch push. NEW .claude/docs/known-issues.md entry under ## Known Issues documents the failure mode + cites the CI gate as canonical references.

12

Phase F step 2: plan completion audit + archive — standard close-out per delivery-protocol.md. Subagent verifies all 11 prior steps Done with MR citations; cargo xtask docs plan-archive moves plans/quality-gate-enforcement.adocarchive/; nav.adoc Active row removed; archive.adoc new row with final stats; .claude/CLAUDE.md Phase Status row added.

Done (2026-06-07) — this MR. Plan-completion-audit subagent verified all 11 prior steps Done with concrete MR + sha citations (caught 3 stale TBD shas at Steps 5/6/7 → tightened to e9dd2eb0 / 59588bbb / 89555a45). cargo xtask docs plan-archive moves plan body to plans/archive/quality-gate-enforcement.adoc. nav.adoc Active row removed. NEW row in plans/archive.adoc § Code Quality with final stats (12 step MRs across 2026-06-06 → 2026-06-07; B1-B7 LOCKED with strict-no-grandfather floors; new [4i/14] + [4j/14] validate gates; CI parity via --skip-devstack; hooks-exec-check defense in depth; 1071 → 749 axis-coverage opt-out shrinkage). .claude/CLAUDE.md § Phase Status row appended.

Epic: &40 (https://gitlab.com/groups/gadhs/application/ccwis/-/epics/40)
Issues: #514–#525 (one per step)
Branch prefix: docs/plan-n- for filing/archive, refactor/plan-n-step<N>- for foundation + pay-down, feat/plan-n-step<N>- for new functionality, test/plan-n-step8b- for the 587-test sweep
Milestone: TBD

Context

External review of CRAIG’s quality gates (2026-06-06) found that the quality-budget + risk-coverage matrix systems are dashboards, not controls — they report drift but never block. Concretely:

  • xtask/quality-budgets.toml:6 self-declares report-only and "NOT wired into pre-push or CI today."

  • 5 budgets OVER historical lock: B3a 236→447, B3b 31→63, B4 142→332, B5 175→219, B7 194→197.

  • 2 budgets silently masked by actual > r.threshold.max(r.locked) at xtask/src/cmd/quality_budgets.rs:149 — B1 (8→11) + B2 (49→56) regressed without firing because their thresholds (500/100) shadow their locks (8/49).

  • Coverage matrix cargo xtask coverage-matrix --emit-scorecard reports 11/1314 cells covered + 587 untagged API tests; root cause is the suffix _happy/_sad/_evil naming convention being adopted after most tests were written, with no back-tag sweep.

  • .gitlab-ci.yml ci-tests job (line 122) runs only fmt + clippy + nextest --lib --bins. The 6 custom xtask lints (validate steps 4c–4h), cargo machete (9b), workspace nextest, and quality-budgets all live in pre-push only. Lost exec bit or web-edit → ship-without-validation surface.

Operating principle: the gate that lets known-failing code ship is the wrong gate. Pre-push runs everything runnable locally; CI runs everything CI can run; the two stay in lockstep via a single source of truth (cargo xtask validate).

User constraint (2026-06-06): strict no-grandfather. Don’t reset locks to acknowledge current debt. Keep historical baselines, pay down the OVER budgets in dedicated sweep MRs (Steps 3–7), then flip the enforcement gate (Step 9). Phase D blocks until Phase B clears.

Architectural decision on coverage tagging: comment-annotation tags (// @axis: happy) parsed by raw line-scan beside syn-discovered fn spans. Naming-as-encoding is fragile across renames; proc-macros are over-engineered for this need. Comments carry intent at the test site, survive refactors, encode both axis AND workflow, and a lint enforces them on new tests.

Threat Model

Plan N’s threat model is "silent quality drift." Three patterns make the drift invisible until a partner integration or a security review surfaces it:

  1. Report-only dashboards normalize drift. A counter that ratchets up without firing the gate teaches every contributor that "the number went up but nothing happened, so it must be fine." Over time the dashboard’s signal value approaches zero. Plan N converts the dashboard to a control.

  2. Masked semantic bugs in the gate itself. B1 + B2 regressed silently because the regression-check formula actual > max(threshold, locked) shadows the lock when threshold > lock. Plan N Step 2 fixes the semantics so the lock is authoritative when set.

  3. Pre-push / CI divergence. The pre-push hook is the "sole functional-correctness gate" per .githooks/pre-push:26, but a contributor pushing from a web edit OR a hook with a lost exec bit can ship code that pre-push would have rejected. Plan N Step 10 mirrors the no-devstack subset of validate into CI; Step 11 adds a programmatic hook-exec-bit guard.

Cross-cutting Invariants

  1. Lock is authoritative when set. After Step 2: cargo xtask quality-budgets --report and --fail-on-regression both use ceiling = if locked > 0 { locked } else { threshold }. A budget without a lock falls back to the threshold (for new budgets); a budget with a lock ignores the threshold.

  2. Marker comments are documented + counter-honored. Three markers — // SILENT-OK: (B5), // PARTNER-EDGE-UNTYPED: (B3a/B3b), // STRUCTURAL-VALUE: (B3a/B3b) — are defined in quality-budgets.toml header + coding-conventions.md. The counter skips a match site when the immediately-preceding non-empty source line contains the marker. The marker is documentation; the lint is enforcement.

  3. Strict no-grandfather on the OVER budgets. Phase B pays down each OVER budget back to its historical lock. No --write-lock runs during Phase B (counter actuals must reach the existing lock; we don’t move the goalposts).

  4. Pre-push ↔ CI parity via cargo xtask validate. After Step 10: pre-push runs cargo xtask validate; CI runs cargo xtask validate --skip-devstack --skip-docker. The set of checks is identical except [12/14] (devstack integration tests). A parity smoke check (unit test on a hardcoded step fingerprint) catches drift.

  5. Coverage tagging is enforced going forward. After Step 8: any new test in tests/api/** lacking a // @axis: annotation fails the [4j/14] lint. The xtask/axis-coverage-opt-out.txt opt-out file grandfathers existing 587 tests during the Step 8b sweep and shrinks to empty when the sweep completes.

  6. Plan N MRs do not introduce new lint emissions in unrelated lints. Pre-push battery covers this; a sweep MR that touches services/craig-exchange/src/adapters/ to add 60 // PARTNER-EDGE-UNTYPED: markers must not introduce new wildcard_enum_match_arm or unreachable_pub violations elsewhere.

Step DAG

1 (plan filing)
  ↓
  2 (Phase A foundations: semantic fix + comment-skip + counter improvements)
  ↓
  ┌─────────────── Phase B (Steps 3–7) ───────────────┐
  ↓                                                    ↓
  3 (B7) → 4 (B3b) → 5 (B5) → 6 (B4 ×2-3) → 7 (B3a ×3-4)
  ↓                                                    ↓
  └────────────────────── (B clear) ──────────────────┘
                            ↓
                            9 (Phase D: flip enforcement ON)

  ┌── Parallel to B ──┐
  8a (Phase C matcher + lint)
  ↓
  8b (Phase C 587-test sweep)

  ┌── Parallel to B ──┐
  10 (Phase E: --skip-devstack + CI parity)
  ↓
  11 (Phase F: hooks-exec-check)

  ↓ (all converge)
  12 (Phase F: audit + archive)

Phases C + E + F can interleave with B. Phase D blocks on B clear.

Risks

  1. Phase B sweeps surface counter-bug edge cases. Step 2’s comment-skip implementation may mis-handle multi-line attribute spans, doc-comments, or cfg_attr-test nesting. Mitigation: Step 2 ships 7 unit tests including the masking-bug regression case; any edge case surfaced during Phase B becomes a 1-day Step 2 follow-up MR rather than blocking the sweep.

  2. Step 8b 587-test heuristic mis-classifies tests. Default heuristic is returns_X → sad, evil* → evil, else happy. Mitigation: human review of the script-generated diff before pushing each batch; ambiguous cases manually annotated; opt-out file allows partial-completion landings.

  3. Step 10 CI duration regression breaks the 2xlarge runner. Adding workspace nextest + machete + deny + 6 custom lints to CI grows duration from ~2 min to ~4–7 min. The recurring 2xlarge disk-OOM (known-issues.md) gets worse with longer runs. Mitigation: force-merge runbook available; if persistent, split ci-tests into matrix jobs (fmt+clippy in one, nextest in another, lints in a third).

  4. Phase B duration drags beyond 5 days, blocking Phase D indefinitely. Per-step Explore subagent audits + per-MR demo of count delta keep momentum visible. Mitigation: if a step exceeds 1.5× estimate, file a follow-up plan and Deferred (…) that step + open Phase D early with the remaining budgets still OVER (the gate fires on regression vs current actuals; OVER budgets keep the gate green at lock-baseline + the dashboard surfaces the gap).

  5. cargo machete install in CI hits crates.io rate limits. Mitigation: pre-warm via cargo install --offline if cached; fall back to apt/apk package if available; cache the binary via GitLab CI artifacts between runs.

  6. Step 2 B6 cfg_attr-test counter improvement double-counts or under-counts. The current regex r"#\[\s*allow\s*\(" matches both standalone and cfg_attr-wrapped allows; teaching the counter to recognize cfg_attr(test, allow(…​)) as 1 site instead of 1 per inner allow needs careful regex sequencing. Mitigation: Step 2 unit test test_b6_cfg_attr_test_allow_counts_as_one asserts the expected reduction explicitly.

Edit this page · latest