Plan H: Idiomatic Rust + Clippy Strictness
On this page
- Status
- Context
- Cross-cutting invariants
- Scope
- Steps
- Step 2: F-031 workspace lint expansion
- Step 3: F-031 followup — burn down the warning flood
- Step 4: F-030 panicking-call audit + conversion
- Step 5: F-032 error-handling policy execution
- Step 6: F-050 sync/async mixing audit (NEW; splits into 6a/6b/6c)
- Step 7: F-051 silent-discard audit (NEW)
- Step 8: F-052 std::sync::Mutex → parking_lot migration
- Step 9: F-053 interior-mutability audit (NEW)
- Step 10: Plan completion audit + archive
- Plan H completion audit (2026-05-21)
- Files Touched
- Verification
- Risks
- After this plan lands
- Appendix: Conversion patterns reference (Steps 4/6/7)
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan filing — body lands in the docs-only Plan D refresh MR alongside Plans D/G/I/J/K. nav.adoc + CHANGELOG. No code changes. |
Not started |
2 |
F-031 implementation: expand |
Done (2026-05-15) |
3 |
F-031 followup: burn down the warning flood from Step 2. Likely 1 MR per lint family. Common candidates: |
Done (2026-05-21) — first-pass via !362 (2026-05-19); test-code warning sweep + missing-docs blanket-allow removal completed during the Step 9 Phase A + Phase B flip (!370 + !376). First-pass burn-down done 2026-05-19 — production lib targets now pass * |
b |
std::cmp::Reverse(b.1.counted_methods)) Remaining work for Step 3 close:** * Test-code warning burn-down ( |
4 |
F-030 implementation: convert ALL panicking calls in non- |
Done (2026-05-18) — all 17 in-scope crates clear: craig-rules + craig-exchange + craig-financial + craig-cli + craig-auth + craig-authz + craig-common + craig-crypto + craig-mq + craig-signing + craig-web + craig-intake + craig-cases + craig-api + craig-mock-server + xtask + craig-seed across 7 MRs |
5 |
F-032 implementation: lock the error-handling policy to typed-everywhere (per coding-conventions.md §Style "Error types cannot be strings", "Do not return Box<dyn std::error::Error>"). Three sub-deliverables: (a) |
Done (2026-05-21) — (a) per-crate sweeps + upstream finale via !364/!365/!366/!368/!371/!372/!373/!377; (b) Subscriber parameterization !361; (c)/(d) zero-violation audits 2026-05-18. Step 5(b) — Subscriber parameterization (done 2026-05-18): Deviation from spec: the spec mandates Call sites updated: 1 ( == Plan H Step 9 Phase B (done 2026-05-21) The remaining 8 * Pre-existing priority=1 allows for 5 known-noise sublints are
preserved: Plan H Step 9 → Done. The lint-enforcement track of the
Idiomatic-Rust plan is complete; every clippy lint called out in
coding-conventions.md §Style is now Verification: * |
6 |
F-050 implementation: sync/async mixing audit (NEW per coding-conventions.md §Style — no mixing sync/blocking code with async code). Grep targets: |
Done (2026-05-18) Audit done 2026-05-18 across all non-test code in * Step 6b (1 site converted): |
7 |
F-051 implementation: silent-discard audit (NEW per coding-conventions.md §Style — no silent runtime failures). Grep targets: |
Done (2026-05-18) Audit done 2026-05-18 via Manual grep for * Not real silent discards ( * Manual grep for |
8 |
F-052 implementation: |
Done (2026-05-17) |
9 |
F-053 implementation: interior-mutability audit in CRAIG-authored types (NEW per coding-conventions.md §Style — interior mutability for |
Done (2026-05-18) Audit done 2026-05-18 across all non-test code in Zero CRAIG-authored interior-mutability types found. 9 hits surfaced, all use the approved third-party / std types per the §Step 9 carve-out: * * All 9 sites are cache / lazy-init patterns using approved primitives. No CRAIG-authored type uses interior mutability for |
10 |
Epic: &30 (epic: Idiomatic Rust + Clippy Strictness (Plan H))
Issues: #421 (Step 2) · #422 (Step 3) · #423 (Step 4) · #424 (Step 5) · #449 (Step 6 — F-050 sync/async) · #450 (Step 7 — F-051 silent-discard) · #451 (Step 8 — F-052 parking_lot) · #452 (Step 9 — F-053 interior-mutability) · #425 (Step 10 — plan completion)
Branch prefix: chore/idiomatic-rust- / refactor/idiomatic-rust-
Milestone: TBD
Context
Seven idiomatic-Rust gaps, three from the 2026-05-15 post-Plan-C/F audit and four added 2026-05-15-pm when the project-wide style doctrine was tightened (see .claude/docs/coding-conventions.md §Style):
-
F-030 (P0): 1210 baseline (re-anchored 2026-05-18) = 1007
.unwrap()+ 138.expect()+ 62panic!+ 2unreachable!+ 1todo!in non-test code workspace-wide. The 2026-05-15 figure (~501) was correct at the time; growth comes from Plan I (handler-module-decomposition; new test surfaces + new domain client splits) and Plan G (DRY+YAGNI helpers + new xtask infrastructure). Each per-service batch should re-count at branch time viarg -n '\.unwrap\(\)|\.expect\(|panic!|unreachable!|unimplemented!|todo!' <crate>/src/ | rg -v '#\[cfg\(test\)\]'. Scope expanded 2026-05-15 per coding-conventions.md §Style "Use of unwrap, expect, panic, unimplemented!(), unreachable!(), todo!() … is not allowed. All errors and options must be propagated or handled." Previously, bootstrap-time.expect("documented invariant")was accepted; now every panicking call propagates viaResulttomain. Onlymainmay panic — and only at the OS exit boundary viaeprintln!+std::process::exit(1), not a literalpanic!. -
F-031 (P1): workspace
[workspace.lints.clippy]enforces ONE lint (allow_attributes_without_reason = "warn"). Missing the full curated set:pedantic,cargo,cognitive_complexity,too_many_lines, plus the panicking-call lints (unwrap_used,expect_used,panic,todo,unimplemented,unreachable), plusdbg_macro, plusmissing_docs_in_private_items(coding-conventions.md §Style requires all functions documented). Function-size threshold = 40 lines per coding-conventions.md §Style ("Almost no function should be more than 40 lines… Less than 10% of total functions should be more than 40 lines"). Subagent triages "important function" overrides per-site. -
F-032 (P1): 221
anyhow::Erroroccurrences vs 19thiserror::Errorderives.craig-mq::Subscriberhardcodesanyhow::Errorin 6 trait bounds; downstream subscribers can’t surface typed errors. Policy locked 2026-05-15 to typed-everywhere per coding-conventions.md §Style "Error types cannot be strings", "Do not returnBox<dyn std::error::Error>. Return concrete rust error types." Subscriber trait-bound parameterization is mandatory, not optional. Also includesString-payload variant audit +std::io::ErrorKind::Otheraudit. -
F-050 (P0) NEW: sync/async mixing audit. Per coding-conventions.md §Style: sync/blocking code is never mixed with async code — no nuance. Grep targets per Step 6 row.
-
F-051 (P1) NEW: silent-discard audit. Per coding-conventions.md §Style: no silent runtime failures. Every
let _ = resultor.ok()on aResulttriages to propagate / warn / explicit// SILENT-OK: <reason>. -
F-052 (P1) NEW:
std::sync::Mutex→parking_lot::Mutexmigration. Thelock().expect("not poisoned")idiom is no longer acceptable; parking_lot doesn’t poison so the call becomes directlock(). Per Q2 of style review, parking_lot is canonical for CRAIG sync mutexes (tokio::sync::Mutex remains for async-aware paths). -
F-053 (P2) NEW: interior-mutability audit in CRAIG-authored types. Per coding-conventions.md §Style: interior mutability (
RefCell,Cell,Mutexfor mutation behind&self) is not allowed without explicit approval. Third-party types (DashMap, governor, parking_lot, tokio sync) are pre-approved per the §Style concurrency-primitives section; this audit scope is CRAIG-authored types only.
Cross-cutting invariants
-
Step 2 lights up the warning flood deliberately. Expect a large initial warning count after adding
pedantic+ panicking-call lints +missing_docs_in_private_items. The CI policy for Step 2 specifically: set top-levelwarnings = "warn"(notdeny) until Step 3 drains the flood. After Step 3 lands, flip towarnings = "deny". -
Step 4 is per-handler audit, not bulk sed. No site is automatically "correct" anymore — even
mutex.lock().expect("not poisoned")is out (Step 8 swaps to parking_lot which doesn’t poison). Bootstrap-time.expect("documented invariant")converts toResultpropagation tomain. Each conversion to?requires adding aFrom<X> for ApiErrorimpl if not already present — that’s part of the work. -
Step 5 is policy-locked. Per coding-conventions.md §Style the policy is typed-everywhere. The MR’s job is to execute, not re-litigate. Anyhow at API boundaries is forbidden; the question is which typed error type per crate.
-
mainis the only legal panic site, and even then viaprocess::exit. Every servicemainbecomes: parse config, init tracing, build app,.awaitthe server, log + exit non-zero on error. Nopanic!, nounwrap. This is the Application Termination Boundary — the one placeResult<(), E>resolves. -
Step 6 (sync/async) blocks no other steps. Sync/async mixing is an audit — converting a
block_oncall to spawn_blocking might be a one-line change or might force restructuring a whole handler. Per-site analysis. -
Step 8 (parking_lot) ships before Step 4 finishes.
mutex.lock().expect("not poisoned")sites stay broken until parking_lot lands; otherwise Step 4 would have to convert them to verboseunwrap_or_else(|e| e.into_inner())only to be re-simplified by Step 8. -
Order matters. Step 2 (lints, warn level) → Step 8 (parking_lot — clears the
expectidiom) → {Step 3 (burn-down) || Step 4 (panicking-call sweep)} → Step 5 (error policy execution) → Step 6 (sync/async audit) → Step 7 (silent-discard audit) → Step 9 (interior-mutability audit) → flip lints to deny. Step 3 and Step 4 commute: Step 3’s burn-down explicitly defers_usedlints to Step 4 (line in §Step 3 below), so the only binding precedence is Step 8 before Step 4. Steps 6/7/9 may run in any order; they don’t depend on 2-5.
Scope
In scope (7 findings):
-
F-030 panicking-call audit + conversion (ALL unwrap/expect/panic/unimplemented/unreachable/todo)
-
F-031 workspace clippy lint expansion (panicking-call lints +
missing_docs_in_private_items+too_many_lines.max_lines = 40) + burn-down -
F-032 error-handling policy execution (typed-everywhere; mandatory Subscriber trait-bound parameterization; String-payload variant audit; ErrorKind::Other audit)
-
F-050 sync/async mixing audit
-
F-051 silent-discard audit
-
F-052 std::sync::Mutex → parking_lot migration
-
F-053 interior-mutability audit in CRAIG-authored types
Out of scope:
-
DTO type discipline (strum continuation) — Plan D
-
DRY refactors / tx-boilerplate extraction — Plan G
-
Handler / module decomposition — Plan I
-
Env-var sprawl — Plan J
-
Canopy xtask + hook backports — Plan K
-
Partner typed schemas — Plan L
Steps
Step 2: F-031 workspace lint expansion
Files:
-
Cargo.toml— workspace[workspace.lints.clippy]section grows from 1 lint to the full curated set:[workspace.lints.clippy] allow_attributes_without_reason = "warn" pedantic = "warn" cargo = "warn" cognitive_complexity = "warn" too_many_lines = "warn" # Panicking calls forbidden per coding-conventions.md §Style unwrap_used = "warn" expect_used = "warn" panic = "warn" todo = "warn" unimplemented = "warn" unreachable = "warn" dbg_macro = "warn" # All functions documented per coding-conventions.md §Style missing_docs_in_private_items = "warn" # No silent runtime failures per coding-conventions.md §Style let_underscore_must_use = "warn" ignored_unit_patterns = "warn" # Allow noisy-but-style-orthogonal lints with reason (priority=1 escapes pedantic-group) module_name_repetitions = { level = "allow", priority = 1 }NOTE the previously-allowed
missing_errors_doc,missing_panics_doc,must_use_candidateare DROPPED — coding-conventions.md §Style mandates docs and the_usedlints cover the panic/error doc concerns directly. -
clippy.toml(NEW or EDIT at repo root) — function-size threshold per coding-conventions.md §Style:too-many-lines-threshold = 40
Branch: chore/idiomatic-rust-step2-clippy-lints
MR title: chore(workspace): expand clippy lints — panicking calls, missing-docs, silent-discard, max_lines=40 [Step 2 of idiomatic-rust]
Crate-level allow for _used lints (mandatory in this same MR): Step 2 lights up clippy::unwrap_used + expect_used + panic + todo + unimplemented + unreachable workspace-wide, but Step 4 doesn’t do the sweep — so Step 2 must also land per-crate #[allow(clippy::unwrap_used, clippy::expect_used, reason = "scheduled for Plan H Step 4 sweep")] at every crate’s lib.rs / main.rs top. Without these allows, cargo clippy is unusable between Step 2 and Step 4 lands.
Workspace-warn override mechanism: the [workspace.lints.clippy] block above uses "warn" not "deny". The warnings = "deny" flip happens via:
# Cargo.toml workspace [lints.rust] section, after Step 9 closes:
[workspace.lints.rust]
warnings = "deny"
This is a workspace-level lint table (Rust 2024+; see cargo-features if pinning is required). NOT a -W warnings clippy flag (which would only apply to per-invocation), NOT a .gitlab-ci.yml env-var override (CI invocation reuses workspace lints).
Verification:
-
cargo clippy --workspace --all-targets --lockedruns cleanly (warnings expected); noerror-level lints surface immediately -
Workspace CHANGELOG note explains the warning flood will drain in Step 3 + subsequent finding-specific steps
-
[workspace.lints.rust] warnings = "warn"(the default; explicit override not needed during Steps 2-9 since the lints themselves are"warn"level) -
After Step 9 closes (final finding audit complete), set
[workspace.lints.rust] warnings = "deny"
Step 3: F-031 followup — burn down the warning flood
1 MR per lint family. Expected categories:
-
missing_docs_in_private_items— almost every internalfn/struct. Largest single category; could be its own multi-batch effort. Per-crate batches. -
needless_pass_by_value—String/HashMapparameters that could be&str/&HashMap -
too_many_lines(threshold 40) — fat handlers; overlap with Plan I F-033. Plan I Step 2 lands first if both run in parallel; Step 3 here adopts Plan I’s decomposed handlers. Subagent triages "important function" exceptions per-site (coding-conventions.md §Style: <10% may exceed 40 lines). -
cognitive_complexity— convoluted control flow; overlap with Plan I Step 2 -
unnecessary_to_owned/unnecessary_clone— clone proliferation -
pedanticsub-lints (redundant_closure_for_method_calls,manual_let_else, etc.) — minor stylistic cleanups -
_usedlints (unwrap_used,expect_used) — these surface every site Step 4 will sweep; Step 3 acknowledges them via#[allow(clippy::unwrap_used, reason = "scheduled for Step 4 sweep")]at the crate or fn level so Step 3’s burn-down doesn’t have to convert before Step 8’s parking_lot lands
Branch (per family): chore/idiomatic-rust-step3-burndown-<lint-family>
Verification (final):
-
cargo clippy --workspace --all-targets— warning count drops to zero except the_usedlints (those are Step 4’s job) -
Per-family MR documents starting warning count + ending warning count
Step 4: F-030 panicking-call audit + conversion
Scope (re-anchored 2026-05-18):
-
1007
.unwrap()(was 413 on 2026-05-15) -
138
.expect()(was 88 on 2026-05-15) -
62
panic!+ 2unreachable!+ 1todo!(0unimplemented!) -
Total: 1210 sites workspace-wide
-
All non-
tests/code is in scope. Test code is out of scope; tests may panic as the assertion mechanism.crates/craig-test-libis out of scope as a test-support crate — its `.unwrap()`s ARE the assertion surface for its callers.
Per-service distribution (2026-05-18 — TRUE in-scope counts, excluding inline #[cfg(test)] mod tests blocks within src/ files):
The initial 2026-05-18 table over-counted because it included .unwrap()/.expect() calls inside [cfg(test)] mod tests modules embedded in src/ files (test code, OUT OF SCOPE per the §Scope rule). Re-counted 2026-05-18-pm by filtering each file’s hits against the position of its first [cfg(test)] marker:
| Crate | True in-scope sites | Notes |
|---|---|---|
services/craig-rules |
0 |
Done 2026-05-18 batch 1 (3 sites cleared) |
services/craig-cases |
1 → 0 |
Done 2026-05-18 batch 5 ( |
services/craig-cli |
1 → 0 |
Done 2026-05-18 batch 2 ( |
services/craig-exchange |
1 → 0 |
Done 2026-05-18 batch 2 (semaphore acquire — bail gracefully on shutdown) |
crates/craig-auth |
2 → 0 |
Done 2026-05-18 batch 3 (both sites were inside |
crates/craig-authz |
2 → 0 |
Done 2026-05-18 batch 3 ( |
crates/craig-common |
2 → 0 |
Done 2026-05-18 batch 3 ( |
crates/craig-crypto |
2 → 0 |
Done 2026-05-18 batch 3 ( |
crates/craig-mq |
2 → 0 |
Done 2026-05-18 batch 3 ( |
crates/craig-signing |
2 → 0 |
Done 2026-05-18 batch 3 ( |
services/craig-financial |
2 → 0 |
Done 2026-05-18 batch 2 ( |
services/craig-web |
5 → 0 |
Done 2026-05-18 batch 4 (main.rs reqwest builder |
tools/craig-mock-server |
5 → 0 |
Done 2026-05-18 batch 5 (added |
services/craig-intake |
7 → 0 |
Done 2026-05-18 batch 4 (5 |
crates/craig-api |
10 → 0 |
Done 2026-05-18 batch 5 (8 idempotency.rs |
xtask |
13 → 0 |
Done 2026-05-18 batch 6 ( |
tools/craig-seed |
294 → 0 |
Done 2026-05-18 batch 7 (fixture-generator CLI sweep — |
TOTAL in-scope (estimated) |
~408 |
|
crates/craig-test-lib |
— |
Out of scope (test-support crate) |
services/craig-reporting |
— |
All hits inside inline |
services/craig-placement |
— |
All hits inside inline |
crates/craig-store / db / matching / reference / security |
— |
Mostly test-inline or none after filter |
Methodology note: per-batch recount uses for f in $(rg -l '…' <crate>/src); do n=$(rg -n '…' "$f" | awk -F: -v b=$(grep -n '^#\[cfg(test)\]' "$f" \| head -1 \| cut -d: -f1) '\$1 < b'); done. The 2026-05-15 baseline (~501) was correct under the same methodology (no test-inline filter wasn’t on yet because counted differently). The 2026-05-18 morning recount of "1210" was unfiltered (included inline tests); the corrected count is ~408. Each per-service batch should still re-count at branch time.
Batching strategy: one MR per crate, ordered smallest → largest within service/library tier. Multi-crate "small-batch" MRs OK when each crate has only 1-3 sites and the conversion patterns are similar (e.g. batch 2 above bundled 3 crates × 4 total sites). Tools (xtask + craig-seed + mock-server, ~312 sites) ship in their own batches at the end since they’re CLI binaries where main is the legal panic boundary — the conversion shape there is "propagate to main, exit non-zero on err" rather than HTTP handler chains.
Files: per-service batches; sites identified by cargo clippy … — -W clippy::unwrap_used -W clippy::expect_used -W clippy::panic reading the warnings list.
Process per site:
-
Classify against one rule: this site exists in a function whose return type is
Result<T, E>or can be made so?-
If yes (overwhelming default): propagate via
?+ appropriateFrom<X> for ApiErrorimpl -
If no (e.g. axum’s
IntoResponsefor some structured error path): wrap inResultat the outermost boundary; propagate inward
-
-
mutex.lock().expect(…)sites: SKIP — Step 8 swaps to parking_lot which doesn’t poison; converting now means converting twice -
Bootstrap-time
expect("documented invariant")in servicemain: convert toResultpropagation;mainreturnsResult<(), MainError>; the OS exit happens via the bootstrap shim’s match on the Result
Branch (per service): refactor/idiomatic-rust-step4-panicking-calls-<service>
Verification:
-
cargo nextest run --workspace— no test regression -
Per-service
cargo clippy -p <svc> — -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic -D clippy::todo -D clippy::unimplemented -D clippy::unreachableclean -
New
From<X> for ApiErrorimpls land incrates/craig-common/src/error.rsas needed
Step 5: F-032 error-handling policy execution
Process:
-
Policy lock (no decision left to make): typed-everywhere per coding-conventions.md §Style.
-
Three sub-deliverables:
-
(a)
anyhow::Errorreturns at API boundaries →thiserror-derived typed enum. Estimated ~221 sites. Per-crate batches. -
(b)
craig-mq::Subscribertrait-bound parameterization (mandatory):// Before pub trait Subscriber: Send + Sync { fn process(&self, payload: ...) -> Result<(), anyhow::Error>; } // After pub trait Subscriber: Send + Sync { type Error: std::error::Error + Send + Sync + 'static; fn process(&self, payload: ...) -> Result<(), Self::Error>; }Cascading migration shape: the trait-change MR ships first (
craig-mqonly). Then N per-serviceimpl Subscribermigrations land as small follow-up MRs. Surfaced impl sites at branch time viarg -n 'impl.*Subscriber.*for' services/ crates/. Expected count: 6+ percraig-mq’s current bound-count, including the wildcard audit subscriber in `services/craig-security. One trait-change MR + ~6 per-service impl MRs. The trait-change MR usestype Error = anyhow::Erroras the per-impl default during the transition; each follow-up MR tightens to a typed<Service>SubscriberErrorenum. -
(c) error-variant audit — every
#[error("…")]variant whose payload isStringmust convert to a typed inner error type. Per-crategrep -rE '\{[^}]+:\s*String\s*\}' crates//src/error.rs services//src/error.rsto enumerate. -
(d)
std::io::ErrorKind::Otheraudit — replace each construction with a typed variant.grep -rn "ErrorKind::Other" services/ crates/.
-
-
Document policy in
docs/modules/ROOT/pages/architecture.adoc § Error Handling(new section).
Branch: refactor/idiomatic-rust-step5-typed-errors
MR title: refactor: lock error-handling policy to typed-everywhere; parameterize Subscriber trait bound [Step 5 of idiomatic-rust]
Verification:
-
cargo build --workspaceclean after Subscriber trait bound change -
cargo nextest run --workspace— no regression -
grep -rn "anyhow::Error" services/ crates/returns zero matches inpub fn/pub async fnsignatures (internal use may persist where it doesn’t cross the API boundary) -
grep -rn "Box<dyn .*Error" services/ crates/returns zero matches in return types -
grep -rn ".: String," crates//src/error.rs services/*/src/error.rsshows zero unconverted String-payload variants
Step 6: F-050 sync/async mixing audit (NEW; splits into 6a/6b/6c)
Sub-divided because "every async fn that contains a blocking call site" is potentially every handler in the codebase. Each sub-step is its own MR:
-
Step 6a:
std::fs/std::ioinsideasync fn→ mechanical conversion totokio::fs/tokio::io::AsyncRead-or-AsyncWrite. Often one-line edits + import swaps. Largest expected category. -
Step 6b:
block_onaudit (structural) —tokio::runtime::Handle::block_on/futures::executor::block_onin non-test code. Each site needs restructuring (either move work to anasync fnboundary ORspawn_blockingif it’s CPU-bound). -
Step 6c:
Mutex-across-.awaitaudit —std::sync::Mutex/parking_lot::Mutexheld across an.awaitis a deadlock risk; convert totokio::sync::Mutex. Step 8 (parking_lot migration) happens-before; this sub-step focuses on async-held mutexes specifically.
Files: every async fn that contains a blocking call site; surfaced via audit grep.
Audit grep targets:
# std::io / std::fs blocking calls inside async
rg -t rust 'std::(fs|io)::' --files-with-matches | xargs rg -l 'async fn'
# std::thread::sleep inside async
rg 'std::thread::sleep' --type rust
# block_on outside test code
rg 'tokio::runtime::Handle::block_on|futures::executor::block_on' services/ crates/
# Mutex::lock inside async fn (parking_lot's mutex is brief-critical-section-acceptable per its docs;
# tokio::sync::Mutex is the right async-aware choice for held-across-await locks)
rg 'std::sync::Mutex|parking_lot::Mutex' --type rust | xargs rg -B5 'async fn'
Process per site:
-
Classify: brief sync call that won’t yield ≥ ~10µs (acceptable per Tokio docs — e.g.
String::new()) / blocking call that should be async (convert totokio::fs/tokio::io::AsyncRead/ etc.) / blocking call that has no async equivalent (wrap intokio::task::spawn_blocking) -
Document the decision per site with a
// SYNC-OK: <reason>comment for accepted brief-sync; otherwise convert -
Special case:
parking_lot::Mutexacquired briefly inside async is OK; held across.awaitis a deadlock risk — convert totokio::sync::Mutexin that case
Branch: refactor/idiomatic-rust-step6-sync-async-audit
MR title: refactor: sync/async mixing audit + conversions per coding-conventions.md §Style [Step 6 of idiomatic-rust]
Verification:
-
cargo nextest run --workspace— no test regression -
cargo clippy --workspace --all-targets— no new warnings introduced -
Audit report committed as MR body or
.claude/docs/sync-async-audit.md
Step 7: F-051 silent-discard audit (NEW)
Files: every let _ = result_expr / .ok(); / .unwrap_or_default() on a Result; surfaced via the new clippy lints from Step 2.
Audit grep targets: These greps are exploratory — they over-match (e.g. let _ = expensive_compute() where the type is (), not Result). Manual inspection per site for the actual Result-discard cases. The authoritative gate is the clippy lints from Step 2 (let_underscore_must_use + ignored_unit_patterns), NOT the grep counts.
# Bare let _ = (expression returning Result) — manually inspect each hit
rg 'let _ =' services/ crates/
# .ok() on a Result silently drops the error
rg '\.ok\(\);' services/ crates/
# .unwrap_or_default() — discards error but may hide bugs
rg '\.unwrap_or_default\(\)' services/ crates/
Process per site:
-
Classify: is the discard intentional (e.g. cleanup-best-effort, idempotent retry, fire-and-forget log)?
-
If yes → convert to explicit
if let Err(e) = expr { tracing::warn!(error = %e, "context"); }+// SILENT-OK: <reason>comment -
If no → propagate via
?+ appropriate error mapping
-
Branch: refactor/idiomatic-rust-step7-silent-discard-audit
Verification:
-
cargo clippy --workspace --all-targets — -D clippy::let_underscore_must_use -D clippy::ignored_unit_patternsclean -
grep -c "SILENT-OK:" services/ crates/— count of accepted discards documented in MR body
Step 8: F-052 std::sync::Mutex → parking_lot migration
Files: every CRAIG-authored std::sync::Mutex field or local; surfaced via:
rg 'std::sync::Mutex' services/ crates/
rg 'use std::sync::Mutex' services/ crates/
(Third-party uses are out of scope per Q2 of the style review.)
Per-site change:
-
Replace
std::sync::Mutex<T>field type withparking_lot::Mutex<T> -
Replace
mutex.lock().expect("not poisoned")/.unwrap()withmutex.lock()(noResult, no panic) -
If the site holds the lock across
.await: convert totokio::sync::Mutex<T>instead — parking_lot’s mutex is brief-critical-section only
Cargo.toml workspace deps: verify parking_lot is present via cargo tree -i parking_lot. If transitively-present, add as explicit workspace dep with cargo add parking_lot --package <root> (or hand-edit the workspace Cargo.toml per the delivery-protocol standard’s Architectural-Recommendations guidance (check existing infra/tooling first)).
Branch: refactor/idiomatic-rust-step8-parking-lot-mutex
MR title: refactor: std::sync::Mutex → parking_lot::Mutex (no poisoning, no expect idiom) [Step 8 of idiomatic-rust]
Verification:
-
cargo nextest run --workspace— no regression -
grep -rn "std::sync::Mutex" services/ crates/returns zero matches in non-test, non-FFI code -
grep -rn "expect(\"not poisoned\")\|expect(\"poisoned\")" services/ crates/returns zero matches
Step 9: F-053 interior-mutability audit (NEW)
Files: every CRAIG-authored RefCell, Cell, or Mutex<T> field exhibiting &self mutation; surfaced via:
rg 'RefCell|Cell<' services/ crates/
# Mutex sites already audited in Step 8; this catches the remaining
# CRAIG-authored cases where Mutex provides &self-mutation deliberately
rg 'parking_lot::Mutex|tokio::sync::Mutex' services/ crates/ # post-Step-8
(Third-party types — DashMap, governor, parking_lot internals, tokio sync — are explicitly approved per Q2 of the style review; this audit covers CRAIG-authored types only.)
Per-site decision:
-
Refactor to
&mut selfownership (preferred — eliminates the interior-mutability requirement) -
Convert to an approved third-party type (
DashMapfor concurrent map mutation,parking_lot::Mutexfor short critical sections,tokio::sync::Mutexfor async-held locks) -
File an approval note in a new ADR if interior mutability is genuinely required (rare — typically only for
Cellin static-init paths)
Branch: refactor/idiomatic-rust-step9-interior-mutability-audit
Verification:
-
cargo nextest run --workspace— no regression -
grep -rn "RefCell\|Cell<" services/ crates/ --include='*.rs' | grep -v 'tests/' | grep -v 'examples/'returns zero matches OR each remaining match has an ADR reference comment -
After this step, set
warnings = "deny"workspace-wide (delivers F-031 final flip)
Step 10: Plan completion audit + archive
Mirror Plan B Step 8 / Plan C Step 18 / Plan F Step 6 pattern.
Final flip checklist (per the project_plan_h_transition_state memory):
-
Cargo.toml [workspace.lints.clippy]— 15 lints flipped fromwarntodeny(Phase A !370 swept 7 + Phase B !376 swept the remaining 8). Done. -
xtask/src/cmd/validate.rs—-D warningsrestored on clippy invocation via !370. -
Per-crate
![allow(…)]transitional blocks — all removed;![cfg_attr(test, allow(…))]on 29 lib roots scopes test-only suppression to#[cfg(test)] mod tests.crates/craig-test-lib+crates/craig-auth/src/jwks.rs::test_fixturesremain permanently exempt as test-support code. -
clippy::wildcard_enum_match_arm = "deny"— shipped post-archive 2026-05-21 via !380. 5 production sites fixed (serde_json::Valuematches enumerated to primitives;EngineErrorvariant list spelled out; fn-level reasoned-allows forobject_store::Errornon_exhaustive +syn::Type14-variant cases). Test code allow-listed via existingcfg_attr(test, allow)pattern. Workspace lint count: 15 → 16 lintsdeny. -
Per-library-crate
#![deny(unreachable_pub)]— shipped post-archive 2026-05-21 via !381 across 15 lib roots. 1 production site fixed (crates/craig-api/src/otel.rs::otel_propagation→pub(crate)). -
Per-library-crate
#![warn(unused_crate_dependencies)]— deferred. Initial probe suggested zero hits but--all-targetsclippy surfaces ~15 across the workspace (dev-deps used only intests/*.rs, stale[dependencies]entries). Requires per-crate cleanup MR. -
Per-library-crate
#![warn(missing_docs)]— still deferred. 2400+ hits; cleanup-first work on a much larger surface than the other two lints. -
cargo clippy --workspace --all-targets — -D warningsreturns 0 violations. ✓ -
cargo xtask validate --skip-dockergreen. ✓
Completion-audit findings (2026-05-21): see [§Plan H completion audit](#_plan_h_completion_audit) below.
Plan H completion audit (2026-05-21)
Spawned a plan-completion-audit subagent per delivery-protocol.md § Plan Completion Audit. Findings:
All 9 substantive steps shipped. Per-step verification against the git history:
-
Step 2 (F-031 workspace lints) — !311 →
de558dd(2026-05-15) -
Step 3 (F-031 burn-down) — !362 first-pass (2026-05-19) + test-code residual sweep folded into !370/!376
-
Step 4 (F-030 panicking-call sweep) — 7 MRs across 17 crates (2026-05-18)
-
Step 5 (F-032 typed-everywhere) — !361 (Subscriber) + !364/!365/!366/!368 (per-crate batches 1-4) + !371/!372/!373 (multi-source follow-ups #467/#468/#469) + !377 (upstream
craig-auth+craig-commonfinale) -
Step 6 (F-050 sync/async) — !358 (2026-05-18)
-
Step 7 (F-051 silent-discard) — !359 (2026-05-18) + !370 Phase A residual sweep
-
Step 8 (F-052 parking_lot) — !346 + !350 (2026-05-17/18)
-
Step 9 (F-053 interior-mutability + lint-enforcement flip) — !360 (interior-mutability audit; zero CRAIG-authored hits) + !370 (Phase A: 7 lints flipped to
deny) + !376 (Phase B: 8 lints flipped todeny)
Acceptance criteria from §After this plan lands:
| Criterion | Status |
|---|---|
Workspace clippy enforces pedantic + cargo + complexity + panicking-call + missing-docs + silent-discard lints with |
✓ |
Function-size threshold = 40 lines ( |
✓ |
All panicking-call sites converted; |
✓ (F-030 batches 1-7) |
Error-handling policy: typed-everywhere; zero |
✓ (Step 5 + upstream sweep !377) |
|
✓ (!361) |
Zero sync/async mixing (or each site |
✓ (Step 6 !358) |
Zero silent-discard (or each site |
✓ (Step 7 !359 + !370) |
Zero |
✓ (Step 8 !346 + !350) |
Zero CRAIG-authored interior-mutability (or each has an ADR ref) |
✓ (Step 9 !360 — zero hits found) |
Issues closed: #421 (Step 2), #422 (Step 3), #423 (Step 4), #424 (Step 5), #449 (Step 6), #450 (Step 7), #451 (Step 8), #452 (Step 9), #425 (Step 10 — this MR). Plus 4 follow-ups closed during the 3-day sweep: #465, #466, #467, #468, #469.
Plan archived to docs/modules/ROOT/pages/plans/archive.adoc § Code Quality and removed from nav.adoc § Planned. Phase Status row added to .claude/CLAUDE.md.
Files Touched
| File | Step | Change |
|---|---|---|
|
2 |
EDIT (workspace lints — full curated set) |
|
2 |
NEW or EDIT (max_lines = 40) |
Per-service src/** |
3,4,6,7,8,9 |
EDIT (lint burn-down + panicking-call conversions + sync/async + silent-discard + parking_lot + interior-mutability) |
|
4,5 |
EDIT (new |
|
5 |
EDIT (trait-bound parameterization — mandatory) |
Per-crate |
5 |
EDIT (String-payload variant audit + typed inner types) |
|
5 |
EDIT (error-handling policy section) |
Workspace |
8 |
EDIT (add parking_lot if not transitively present) |
Risks
| Risk | Mitigation |
|---|---|
Step 2 surfaces so many warnings that Step 3 burn-down becomes its own multi-week effort |
Split into Plan H1 (steps 2+4 — lints on + unwrap audit) and Plan H2 (step 3 — burn-down) if scope explodes |
Step 4 |
Accept the surface growth; the error type is the right place to centralize. Use |
Step 5 picks a policy that invalidates a chunk of existing code (e.g. typed-everywhere forces a sweep of all 221 anyhow sites) |
If typed-everywhere is the chosen policy, defer to a separate sub-plan; ship policy doc + Subscriber trait change only in Step 5 |
Step 2’s lint allow-list grows large enough to defeat the purpose |
Audit allow-list quarterly; remove allows as code-style improves |
After this plan lands
-
Workspace clippy enforces pedantic + cargo + complexity + panicking-call + missing-docs + silent-discard lints with
warnings = "deny" -
Function-size threshold = 40 lines (strict per coding-conventions.md §Style; subagent-triaged "important" overrides documented)
-
All 501 panicking-call sites (unwrap + expect + panic + unimplemented + unreachable + todo) converted to
Resultpropagation;mainis the only legal panic site -
Error-handling policy: typed-everywhere. Zero
anyhow::Errorat API boundaries; zeroBox<dyn Error>returns; zeroString-payload error variants -
craig-mq::Subscribertrait bound parameterized (mandatory) -
Zero sync/async mixing — every site converted or documented
SYNC-OK -
Zero silent-discard — every site converted or documented
SILENT-OK -
Zero
std::sync::Mutexin CRAIG code (parking_lot for sync; tokio::sync::Mutex for async-held) -
Zero CRAIG-authored interior-mutability sites (or each has an ADR reference)
Appendix: Conversion patterns reference (Steps 4/6/7)
Each per-site Plan H sweep produces the same recurring conversion shapes. This reference is consulted by per-batch MRs so the conversion choice is canonical and reviewable, not improvised.
Step 4 (F-030) — panicking-call conversions
| Pattern | Before | After | First-shipped reference |
|---|---|---|---|
Static HTTP header value |
|
|
craig-rules/api.rs:687 (batch 1) |
Runtime HTTP header value |
|
|
craig-rules/api.rs:693 (batch 1) |
Tokio runtime build inside |
|
|
craig-rules/engine.rs:80, craig-authz/eval_thread.rs:33 (batch 1/3) |
Bootstrap config-validated |
|
Propagate via |
craig-cli/client.rs, craig-intake/config.rs (batch 2/4) |
|
|
Manual race-tolerant try-init: optimistic |
craig-cli/client.rs (batch 2) |
Semaphore |
|
|
craig-exchange/send_worker.rs:140 (batch 2) |
Chrono arithmetic chain |
|
|
craig-financial/main.rs:481 (batch 2) |
Documented-infallible crypto / serde |
|
|
craig-crypto/lib.rs::hmac, craig-mq/publisher.rs (batch 3) |
|
|
|
craig-signing/lib.rs::canonicalize_json (batch 3) |
OTLP / Prometheus exporter build |
|
|
craig-common/telemetry.rs (batch 3) |
Doc-comment artifact |
Literal |
No code change — clippy lints don’t fire on doc-comments. Verify with |
craig-auth/lib.rs (batch 3) |
|
|
PREFERRED: local typed enum that lifts validation into the parse step so the later match is exhaustive. Eliminates BOTH the |
craig-cases/api/report_persons.rs::validate_link_request (batch 5) |
CLI |
|
|
craig-seed (batch 7 — 294 sites) |
Chrono date constants in fixture data |
|
Module-local helpers using |
craig-seed/datagen.rs (batch 7) |
Axum |
8 sites in idempotency.rs constructing Response from string-literal headers + already-validated `HeaderValue`s |
Single helper |
craig-api/idempotency.rs (batch 5) |
|
|
Bind a non-Option local before the assignment: |
craig-seed/datagen.rs::generate_family (batch 7) |
|
|
Collapse to |
craig-seed/datagen.rs (batch 7) |
Validation helper for mock-server JSON extraction |
|
Add |
craig-mock-server/validation.rs (batch 5) |
Subscriber generic parameterization (Plan H Step 5b) |
|
|
craig-mq/subscriber.rs + craig-rules/main.rs (Step 5 MR !361) |
Step 6 (F-050) — sync/async mixing
| Pattern | Before | After | First-shipped reference |
|---|---|---|---|
|
|
Restructure to async |
craig-cases/main.rs (Step 6b) |
SIGTERM / ctrl_c registration |
|
Degrade-not-crash match: |
craig-api/bootstrap.rs::shutdown_signal (batch 5) |
Brief sync I/O in |
|
KEEP the std::fs call; add inline |
craig-cli/{auth, cmd/login, cmd/icpc}.rs, craig-intake/api/api_key_lookup.rs (Step 6a) |
Step 7 (F-051) — silent-discard
| Pattern | Before | After | First-shipped reference |
|---|---|---|---|
|
|
KEEP the |
craig-{web,intake,api/bootstrap}.rs, craig-common/telemetry.rs (Step 7) |
Process notes
-
Cascade-management: production callers cascade with
?(typically already inanyhow::ResultorResult<_, ApiError>). Test sites (anywhere intests/OR inside#[cfg(test)] mod tests) get.unwrap()appended — out of F-030 scope.crates/craig-test-lib/src/…is the test-support crate; out of scope;.unwrap()on its own internal calls is fine. -
Non-panicking ops on the allow-list:
unwrap_or,unwrap_or_else,unwrap_or_default,ok_or/ok_or_else,?,let-else { bail!(…) }are all Plan H-compliant. -
Verify before working: run
cargo clippy -p <crate> — -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic -D clippy::todo -D clippy::unimplemented -D clippy::unreachablefirst. If clean, no work needed — even ifrgfinds occurrences in doc-comments. -
Count in-scope sites properly:
rgalone over-counts. Filter each file’s hits against its first#[cfg(test)]marker position:boundary=$(grep -n "^#\[cfg(test)\]" "$f" | head -1 | cut -d: -f1) rg -n '...' "$f" | awk -F: -v b="$boundary" '$1 < b' | wc -l -
Foundation craig-common::error variants added (batch 1, reusable):
ApiError::Header(#[from] axum::http::header::InvalidHeaderValue)— maps to 500 inIntoResponse(invalid header is server-side data-integrity bug, never user-driven). -
Per-batch shipping pattern: branch
refactor/idiomatic-rust-step4-<scope>off fresh main; convert sites;cargo fmt --all; build + nextest + strict clippy; refresh §Step 4 status + CHANGELOG; Q1-Q8 walkthrough; commit + push; force-merge runbook; pull main + branch cleanup.