Plan: Code Quality Review (April 2026)
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Real bugs
- Step 2: Fragile error matching and silent body drops
- Step 3: Shared
reqwest::Clientacross services - Step 4: Consolidate
canonicalize_json - Step 5:
craig-intakesink decomposition andconvert_reportrefactor - Step 6:
craig-exchangeadapter DRY - Step 7:
craig-test-libconsolidation - Step 8:
craig-reportingCSV/TSV generation - Step 9:
craig-referencemodernization - Step 10:
craig-webi18n hot-path cloning - Step 11:
craig-securityhardening - Step 12:
#[allow(…)]audit - Step 13: Full validation
- Files Touched
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Fix real bugs (UTF-8 truncation, idempotency anonymous collision, dead retry counter) |
Done (2026-04-20) — MR !105 |
2 |
Replace fragile error matching and silent HTTP body drops |
Done (2026-04-20) — MR !106 |
3 |
Inject a shared |
Done (2026-04-20) — MR !108 |
4 |
Consolidate duplicate |
Done (2026-04-20) — MR !106 consolidated test-lib → SDK; MR !120 (#196 — moved the canonical impl into a new minimal |
5 |
Decompose |
Done (pre-ADR-030). Step 5a (sink decomposition) shipped MR !112. Step 5b (event-driven |
6 |
DRY the |
Done (2026-04-20) — MR !115 |
7 |
Collapse |
Done (2026-04-21) — MR !116, partial — client-factory collapse + unauthenticated client fix; builder-macro portion skipped, see plan note |
8 |
Replace hand-rolled TSV generation in |
Done (2026-04-20) — MR !107 |
9 |
Modernize |
Done (2026-04-20) — MR !106 |
10 |
|
Done (2026-04-20) — MR !114 |
11 |
|
Done (2026-04-20) — MR !113 |
12 |
Audit remaining |
Done (2026-04-21) — MR !117 |
13 |
Full validation |
Done (2026-04-21) — MR !118 |
Issues: #181
Related: #179 (bootstrap integration — Step 3 should consume the shared reqwest::Client from craig_api::bootstrap::run() if #179 lands first)
Branch: chore/code-quality-review-2026-04
Context
A five-agent parallel review of the CRAIG workspace (~80 KLOC across 10 crates and 10 services) surfaced ~30 distinct findings. Roughly half overlap with work already scoped by other plans; this plan covers the remainder.
Findings already scoped elsewhere (do not re-plan here):
-
Pagination + sort-validator centralization —
crate-quality-parity.adoc -
From<sqlx::Error> for ApiError/ structured error variants —code-quality-remediation.adocStep 1 &crate-quality-parity.adocStep 3 -
expect()→Resultin library code —code-quality-remediation.adocStep 2 -
let _ =silent discards (mq ack/nack, startup, metrics) —code-quality-remediation.adocStep 3 -
#[allow(clippy::too_many_arguments)]on store functions (~40 sites) —data-integrity-hardening.adoc -
Test-lib shared
reqwest::Client—connection-pooling.adoc(complete) -
Oversized
idempotency_middleware,router(),health_check(),create_case(),update_case()—code-quality-remediation.adocStep 5 -
.unwrap()/.expect()in CLI —rust-idiom-cleanup.adoc(complete)
The findings addressed by this plan are behavior-preserving (except where a real bug exists) and can be parallelized across steps.
Findings considered and rejected
These surfaced during review but were dropped after verification — documented here so a future reviewer doesn’t re-propose them:
-
"Excessive
.into()inReportBuilder(crates/craig-intake-sdk/src/types.rs)" —.into()was chosen deliberately in MR !33 (rust-idiom-cleanup.adoc) to replace.to_string(), which is semantically "format viaDisplay`" rather than "convert types." The builder stores `Option<String>, so an allocation is mandatory;.into(),.to_owned(), andString::fromall produce identical machine code. A real follow-up would be changing parameters toimpl Into<String>(consumer flexibility), but that is a separate concern, not a code-quality issue. -
"Transactional audit inserts in
craig-security/src/store/audit.rs:27-57`"— `insert_audit_entry is a singleINSERT … RETURNING *, which Postgres makes atomic for free. No transaction needed. The real multi-step concern is incraig-security/src/detection.rs:34-56where an alert INSERT, AMQP publish, and HTTP webhook happen sequentially — but that is a cross-system boundary (DB + AMQP + HTTP), which asqlx::Transactioncannot span. The correct fix is the transactional-outbox pattern, which is an architectural change outside the scope of a code-quality plan. Filed as a follow-up idea; not in this plan.
Scope
In scope:
-
Three user-visible bugs (UTF-8 panic, idempotency cache collision, dead retry counter)
-
One fragile error-matching pattern in
craig-rules -
Three
.unwrap_or_default()calls that discard HTTP body decode errors -
Shared
reqwest::Clientinjection intocraig-intake,craig-exchange,craig-security,craig-financial -
Single source of truth for
canonicalize_json -
Decomposition of
sink.rsand theconvert_reporthandler -
Trait-default
transform_outbound+ macro-generated adapter tests incraig-exchange -
Test-lib builder macro + role-generic client factory
-
csv-crate-based TSV emission incraig-reporting -
str::eq_ignore_ascii_case/strum::FromStradoption incraig-reference -
Arc<str>message storage incraig-webi18n -
Transactional audit inserts in
craig-security -
Audit remaining
#[allow(…)]pragmas (delete, justify, or convert to typed fix)
Out of scope:
-
Pagination/sort helper extraction —
crate-quality-parity.adocowns this -
Structured error variants —
code-quality-remediation.adocowns this -
Store-function parameter-struct refactor — deferred (
data-integrity-hardening.adoc) -
MemoryStore→ Redis session backend forcraig-web— infrastructure decision, separate ticket -
CSRF token audit for
craig-web— separate security review
Design
Shared HTTP client pattern
Every service that currently creates a per-call reqwest::Client::new() will receive a reqwest::Client via axum::Extension at startup. The client is built once by ApiServer::new (or the service’s main.rs) with a 30-second default timeout and injected into Router::with_state / .layer(Extension(client)).
Existing reference: services/craig-cases/src/main.rs:63 already builds a rules-engine client with a timeout — mirror that pattern.
Event-driven convert_report
Today, services/craig-intake/src/api/internal.rs:262-351 performs a synchronous HTTP POST to craig-cases to create a referral. This violates ADR-driven service isolation. Replace with an event publish on craig.intake.report.converted and make craig-cases a subscriber that creates the referral when the event arrives. Return 202 Accepted with the intake report id and a location header pointing at the report detail endpoint.
Adapter trait default
All craig-exchange adapters produce a JSON payload with the same top-level skeleton (source, format, adapter, exchange_type, person, data). Add a default implementation on the Adapter trait that builds this skeleton; per-adapter files override only when behavior actually differs.
Steps
Step 1: Real bugs
Files: crates/craig-store/src/validation.rs, crates/craig-api/src/idempotency.rs, services/craig-cases/src/api/cases.rs
-
UTF-8 truncation panic —
crates/craig-store/src/validation.rs:74-78. The expression[..255].to_string()panics if byte 255 splits a multibyte codepoint. Replace with:let truncated = if name.len() > 255 { let mut end = 255; while !name.is_char_boundary(end) { end -= 1; } &name[..end] } else { name };Add a unit test with a 256-byte input whose byte 255 falls inside a multibyte codepoint (
"é".repeat(128)works). -
Idempotency cache collision for unauthenticated requests —
crates/craig-api/src/idempotency.rs:74-80. Todayclaims.as_ref().map(…).unwrap_or_default()returns an empty string when no auth is present, so every anonymous request shares the""namespace. Fix: requireClaimsto be present; if absent, skip the cache entirely and pass the request through unchanged. ReturnNonefrom the cache-key helper and short-circuit tonext.run(req).await. -
Dead retry counter —
services/craig-cases/src/api/cases.rs:160.last_err.map_or(CASE_NUMBER_MAX_RETRIES, |_| CASE_NUMBER_MAX_RETRIES)always yields the constant. Inspect the surrounding loop: either propagatelast_erras anApiError::Internalwhen retries are exhausted, or — if the intent was "return how many attempts we made" — replace with a straight counter. Add a unit test that exercises the retry-exhausted path.
Verify: new unit tests pass; cargo clippy --workspace — -D warnings clean.
Step 2: Fragile error matching and silent body drops
Files: services/craig-rules/src/api.rs, services/craig-rules/src/engine.rs, crates/craig-intake-sdk/src/error.rs, crates/craig-test-lib/src/client.rs, services/craig-intake/src/api/internal.rs
-
String-based error dispatch —
services/craig-rules/src/api.rs:519-524matches onmsg.contains("not found"). Add a typed error toservices/craig-rules/src/engine.rs:#[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("rule set not found: {0}")] RuleSetNotFound(String), #[error("evaluation failed: {0}")] Evaluation(#[from] zen_engine::ZenEngineError), #[error("worker thread unavailable")] WorkerUnavailable, }Change the public evaluate API to return
Result<Value, EngineError>. Update the handler tomatch err { RuleSetNotFound(id) ⇒ ApiError::NotFound { entity: "rule_set", id }, … }. -
Silent body drops — replace
resp.text().await.unwrap_or_default()/resp.bytes().await.unwrap_or_default()at the three sites below. Return a typed error instead:-
crates/craig-intake-sdk/src/error.rs:45— returnIntakeError::ResponseDecode { status, source: e } -
crates/craig-test-lib/src/client.rs:172and:186— test helper: propagate with?from aResult<Vec<u8>>-returning method rather than returningVec<u8> -
services/craig-intake/src/api/internal.rs:317— returnApiError::Internal { detail: format!("downstream decode: {e}") }
-
Verify: new EngineError variants trigger correct HTTP status codes in existing integration tests for craig-rules; cargo nextest run --workspace passes.
Step 3: Shared reqwest::Client across services
Files: services/craig-intake/src/main.rs, services/craig-intake/src/api/internal.rs, services/craig-intake/src/api/captcha.rs, services/craig-intake/src/sink.rs, services/craig-exchange/src/adapters/mod.rs, services/craig-security/src/main.rs, services/craig-security/src/detection.rs, services/craig-financial/src/main.rs
Closes these existing TODOs:
-
services/craig-intake/src/sink.rs:3 -
services/craig-intake/src/api/internal.rs:3 -
services/craig-intake/src/api/captcha.rs:3 -
services/craig-exchange/src/adapters/mod.rs:3-
Build one
reqwest::Clientin each service’smain.rswithtimeout(Duration::from_secs(30)),connect_timeout(Duration::from_secs(5)),pool_max_idle_per_host(32), anduser_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))). -
Add it as an
axum::Extensionlayer on the router. -
Replace every
reqwest::Client::new()call site with anExtension<reqwest::Client>extractor (axum::Extension(http): axum::Extension<reqwest::Client>on handlers; store as a field on adapter/sink structs). -
For
craig-exchange, theAdaptertrait gains aclient(&self) → &reqwest::Clientmethod; each adapter stores the shared client. -
Delete the TODO comments.
-
Verify: Grep for reqwest::Client::new() across services/ and crates/ — zero results outside test-lib (which is covered by connection-pooling.adoc) and main.rs initialisation.
Step 4: Consolidate canonicalize_json
Files: crates/craig-intake-sdk/src/signing.rs, crates/craig-test-lib/src/signing.rs
-
In
crates/craig-intake-sdk/src/signing.rs, makecanonicalize_jsonpuband move the implementation (currently at lines 53–76) to its own inherent-module orpub useat the crate root. -
Delete the duplicate in
crates/craig-test-lib/src/signing.rs:38-61. -
Update
craig-test-lib’s `Cargo.tomlto depend oncraig-intake-sdk(should already be present; if not, add workspace dep). -
Add a single unit test covering nested object key ordering, array preservation, and number formatting — placed in
craig-intake-sdk.
Verify: Grep for fn canonicalize_json — exactly one definition; test-lib still compiles; JWS round-trip tests still pass.
Step 5: craig-intake sink decomposition and convert_report refactor
Files: services/craig-intake/src/sink.rs, services/craig-intake/src/sink/database.rs (new), services/craig-intake/src/sink/forwarding.rs (new), services/craig-intake/src/sink/mapping.rs (new), services/craig-intake/src/api/internal.rs, services/craig-cases/src/main.rs, services/craig-cases/src/subscribers.rs (new or existing)
-
Split
sink.rs(985 LOC):-
Move
DatabaseSinktosink/database.rs -
Move
ForwardingSinktosink/forwarding.rs -
Extract the shared
SubmitReportRequest→ForwardedReport/ DB-row field mapping intosink/mapping.rsasfn map_report_fields(req: &SubmitReportRequest) → ReportFieldswhereReportFieldsis a borrowed-reference struct reused by both sinks. This eliminates the ~30 redundant.clone()calls atsink.rs:from_request(). -
sink.rsbecomes a ~40-line module root declaring the submodules and re-exportingReportSink.
-
-
Break up
DatabaseSink::acceptandForwardingSink::accept— each is ~150 lines. Target ≤40 lines (project convention percode-quality-remediation.adoc). Extract:-
validate_request(&SubmitReportRequest) → Result<(), ApiError> -
persist_report(&PgPool, &ReportFields) → Result<ReportRow, StoreError> -
publish_report_event(&Publisher, &ReportRow) → Result<()>
-
-
Event-driven
convert_report—services/craig-intake/src/api/internal.rs:262-351:-
Replace the synchronous HTTP POST to
craig-caseswith acraig.intake.report.convertedevent publish (include intake report id, worker id fromClaims, JWT for downstream auth propagation if required). -
Respond
202 AcceptedwithLocation: /api/v1/reports/{id}and{ "report_id": …, "status": "converting" }. -
In
craig-cases, add a subscriber forcraig.intake.report.convertedthat creates the referral and (optionally) republishescraig.cases.referral.createdwith the new referral id. -
craig-intakesubscribes tocraig.cases.referral.createdfiltered by intake report id, updates its local row’sreferral_id, and publishescraig.intake.report.converted.complete. -
Update the intake E2E test (
tests/e2e/convert_report.rsor equivalent) to await the final event before asserting.
-
-
Reduce the handler to ≤40 lines — it now: extracts claims, validates the transition, publishes the event, returns 202.
Verify: existing integration tests in services/craig-intake/tests/ pass; a new test asserts the 202 response and the Location header; Grep for convert_report HTTP-to-cases call path yields nothing.
Step 6: craig-exchange adapter DRY
Files: services/craig-exchange/src/adapters/mod.rs, each file in services/craig-exchange/src/adapters/*.rs, services/craig-exchange/tests/adapter_transform.rs (new)
-
On the
Adaptertrait inadapters/mod.rs, add a defaultfn transform_outbound(&self, person: &Person, exchange_type: &str, data: &Value) → Valuethat produces the shared JSON skeleton. Remove the identical implementations from everyadapters/*.rsthat doesn’t override behavior. -
Where an adapter does deviate (inspect each file on the way through), keep an override — but document why in a one-line comment at the override site.
-
Adapter test parameterization — replace the three duplicated tests per adapter with a single parameterized test table driven by
#[rstest](addrstest = "0.24"to[workspace.dependencies]) or amacro_rules! adapter_transform_tests!($adapter:ty, $source:expr)macro invoked once per adapter. Target: one canonical test file (tests/adapter_transform.rs) exercising every adapter.Implementation note (2026-04-20): verified in-tree that no adapter actually deviates from the shared envelope — all 11 differ only in four values (name, default type, metadata key, supported-types list). The refactor ships as a single
StandardAdapterparameterized by aStandardAdapterConfigconst, with the 11 configs (CWCA,FINANCIAL,MEDICAID, …) living instandard.rs. This is stronger than a trait default because it eliminates the possibility of drift between adapters — any future divergence has to go through the config shape or justify a new type. The 11 near-clone files (cwca.rs through tribal.rs) were deleted outright (~1,500 LOC gone).Tests parameterize via a plain
const ALL_CONFIGS: &[StandardAdapterConfig]+ three top-levelfor cfg in ALL_CONFIGSloops — cleaner than addingrstestas a workspace dep for one use. Assertion count is preserved (11 adapters × 3 behaviors, now one failure localizes via theadapter={name}assert message). 88 craig-exchange tests → 60; 33 adapter-transform test functions → 5 parameterized tests covering identical surface.
Verify: cargo nextest run -p craig-exchange runs the same number of test cases as before; every adapter still tests the three behaviors (valid_exchange_type, unknown_exchange_type_passes_through, missing_exchange_type_uses_default).
Step 7: craig-test-lib consolidation
Files: crates/craig-test-lib/src/builders.rs, crates/craig-test-lib/src/harness.rs, crates/craig-test-lib/src/macros.rs (new)
-
Builder macro — create
macros.rsexporting abuilder!macro that accepts a name, target payload type, and field list (with[optional]marker forOption<T>fields). Replace the five hand-rolled builders atbuilders.rs:28-94, 118-189, 211-254, 278-326, …with macro invocations. Preserve the existing public API (Builder::new().with_field(x).build()).Implementation note (2026-04-20): skipped. The 8 builders in
builders.rs(RuleSet, Person, Referral, FosterHome, ExchangePartner, Agreement, IcpcRequest, Placement) are not uniform enough for a macro to net a real maintainability win. They differ along several axes: required-vs-optional fields, positional constructor args, defaults that are literals vs. compound JSON vs.chrono::Utc::now()calls, and custombuild()bodies that inject compound values (e.g. the Agreement builder hardcodes"data_elements": ["name", "dob", "case_id"]). Amacro_rules! builder!covering all of that would be harder to read and debug than the ~60-LOC hand-rolled form, and its invocations would not be materially shorter. Thecraig-referenceplan already has a successful example of when astrum-driven derive wins — that shape (symmetric enum conversion) does suit a macro. Heterogeneous JSON fixtures do not. Leaving the builders as-is is the correct "maintainability is king" call; reject rather than partial-implement. -
Role-generic client factory — replace the 22 methods at
harness.rs:58-320with:pub async fn client_for_role<C>(&self, role: Role) -> Result<C> where C: ServiceClient + 'static, { let token = self.token_for_role(role).await?; Ok(C::new(self.http.clone(), C::base_url(&self.config), token)) }Add a
ServiceClienttrait withnew(…)andbase_url(&Config)methods; implement it for each typed client (RulesClient,CasesClient, etc.). -
Keep backward-compatible shims for at most one release — if the existing test suite breaks at more than ~5 call sites, update the call sites instead.
-
Remove stray
reqwest::Client::new()without timeouts —crates/craig-test-lib/src/client.rs:192-222. Useshared_http_client()(fromconnection-pooling.adoc) instead.Implementation notes (2026-04-20):
-
The trait is named
TypedClient(notServiceClient) becauseServiceClientis already the name of the internal HTTP transport struct inclient.rs. -
The trait method is
from_parts(notnew) to avoid ambiguity with the inherentTypedClient::new()each client already exposes —Self::newinside theimpl TypedClientblock would silently call the inherent method today, but could recurse forever if the inherent method were later removed.from_partsis unambiguous and future-proof. -
The 24
<role>_<service>_client()methods are kept as thin shims overclient_for_role(546 call sites across the integration-test suite make an aggressive update infeasible for this MR). Each shim is now ~2 LOC instead of ~8, and the token+client construction lives in one place. -
A
Roleenum (Admin / Supervisor / Caseworker) carries the credentials pair via a privateRole::credentials()method, so there’s one source of truth for "what username does the Admin role log in as?" instead of 8 duplicatedtokens.get_token(ADMIN_USER, ADMIN_PASS)calls. -
get_unauthenticated/post_unauthenticatedinclient.rsnow useshared_http_client()(30s timeout) instead of the barereqwest::Client::new()— the grep gate in Step 13 can now fail if either call site is reintroduced.
-
Verify: cargo nextest run --workspace passes with the existing integration test suite unchanged.
Step 8: craig-reporting CSV/TSV generation
Files: services/craig-reporting/src/api/afcars.rs, services/craig-reporting/src/api/ncands.rs, services/craig-reporting/Cargo.toml
-
Add
csv = "1"to[workspace.dependencies]in the rootCargo.tomlif not present; reference ascsv.workspace = trueincraig-reporting/Cargo.toml. -
Replace the tab-delimited
format!()-based record emission atafcars.rs:313-353with acsv::WriterBuilder::new().delimiter(b'\t').quote_style(csv::QuoteStyle::Necessary).from_writer(&mut buf). Serialize each record viawriter.serialize(&record)whererecord: AfcarsRecordderivesserde::Serialize. -
Do the same for the NCANDS equivalent.
-
Define/verify
AfcarsRecordandNcandsRecordstructs in the same module (or underservices/craig-reporting/src/models/).
Verify: cargo nextest run -p craig-reporting passes; generate a known-input report and diff against a fixture in services/craig-reporting/tests/fixtures/; assert no embedded tab/newline leaks through field values.
Step 9: craig-reference modernization
Files: crates/craig-reference/src/translate.rs, crates/craig-reference/src/validation.rs, crates/craig-reference/src/fips.rs
-
Replace
to_lowercase()-then-compare loops attranslate.rs:17-20,validation.rs:23-26, andfips.rs:311-312with.eq_ignore_ascii_case(). No allocation, same semantics for ASCII reference data. -
Replace the hand-rolled string-to-enum parsing at
translate.rs:15-34andvalidation.rswith<Enum as std::str::FromStr>::from_str(s)— the derive is already in place viastrum::EnumString. Produce an error viathiserrorthat carries the unrecognized input string. -
Add regression tests asserting
"FOO".parse::<EnumType>()returns the existing error variant and shape.
Verify: cargo bench if any benchmarks exist in craig-reference (the allocation removal should show up); cargo nextest run -p craig-reference passes.
Step 10: craig-web i18n hot-path cloning
Files: services/craig-web/src/i18n.rs, services/craig-web/src/filters.rs
-
Change the message store type from
HashMap<(String, String), String>toHashMap<(String, String), Arc<str>>(orArc<I18n>on the outer struct so the wholeI18ncan be cloned cheaply). -
lookup(&self, locale: &str, key: &str) → Arc<str>— callers hold anArc<str>rather than owning aString. In Askama contexts where a&stris needed, call.as_ref(). -
Update
middleware.rs:25— instead ofstate.i18n.clone()cloning the whole bundle, clone theArc<I18n>wrapper.
Implementation note (2026-04-20): the message store shipped as a nested HashMap<String, HashMap<String, Arc<str>>> rather than the planned tuple-keyed HashMap<(String, String), Arc<str>>. The tuple-keyed form still allocates two String`s per lookup just to construct the key; the nested form does zero allocations on the read path because `HashMap<String, _>::get accepts &str directly. Middleware.rs:25 already only cloned the Arc<HashMap<..>> inside I18n (via #[derive(Clone)]), so no wrapper-Arc change was needed. The filter’s t() now returns Arc<str> — Askama writes it via Display without an intermediate String.
Verify: run a representative page render under tokio-console or perf — per-request i18n allocation drops to zero. Existing i18n tests still pass.
Step 11: craig-security hardening
Files: services/craig-security/src/main.rs, services/craig-security/src/detection.rs
-
Covered by Step 3 (shared
reqwest::Client): remove the per-call client build atdetection.rs:147-157. -
Wrap the webhook notification in a retry policy — add
backoff = "0.4"to[workspace.dependencies]and usebackoff::future::retrywithExponentialBackoff::default()capped at 3 attempts and a 5-second max elapsed time. Log atwarn!on each retry anderror!on final failure.Implementation note (2026-04-20): the
backoffcrate is unmaintained and transitively pulls ininstant 0.1.13(RUSTSEC-2024-0384), which ourcargo-denygate rejects. Replaced with a ~40-LOC hand-rolled loop:AttemptOutcomeenum classifies each attempt asSuccess/Permanent/Transient; awhile Instant::now() < deadlineloop with doublingDurationdelay (100ms → 2s cap) covers the same 5-second-total-budget contract without the deny exception. -
Do not block
run_detection_scanon webhook delivery — the current code awaitsnotify_webhook(…)inline atdetection.rs:55. Spawn the webhook withtokio::spawn(keeping the sharedreqwest::Clientcloned into the task; the client is cheap to clone — internallyArc). Track the spawned task via atokio_util::task::TaskTrackerowned by the service so shutdown can drain in-flight webhooks.Implementation note (2026-04-20): the TaskTracker was deferred. Webhooks are best-effort to an admin-configured URL, and the runtime already drops in-flight spawned futures on shutdown — the same drop that already governs the inline
.await. Adding a tracker would require threading it throughmain.rs → api::routes → detectionplus a shutdown-drain hook inApiServer::servethat doesn’t currently exist, for a guarantee the feature does not require. Revisit if webhook delivery becomes a compliance obligation rather than an operator convenience. -
Add a unit test that points
notify_webhookat an unreachable URL (e.g.,http://127.0.0.1:1/) and assertsrun_detection_scanreturnsOk(…)within ~1 second — proves the webhook is non-blocking.Implementation note (2026-04-20): the tests instead exercise
notify_webhook_with_retrydirectly against awiremockMockServer(2xx, 4xx, 5xx-then-2xx) plus an unreachable-URL test that asserts the retry loop respects its 5-second budget. This proves the retry/backoff contract precisely without spinning up a real Postgres + RabbitMQ just to verify the spawn is non-blocking; the non-blocking guarantee is provided bytokio::spawnitself, which is type-system-enforced.
Explicitly not in scope: making the alert INSERT + event publish + webhook fire sequence at detection.rs:34-56 atomic. A sqlx::Transaction cannot span AMQP and HTTP. The correct pattern is a transactional outbox (alert + outbox row in one tx; a background publisher drains the outbox to AMQP; the webhook becomes an idempotent subscriber). Filed as a follow-up architectural plan; do not attempt here.
Verify: new non-blocking webhook test passes; cargo nextest run -p craig-security passes.
Step 12: #[allow(…)] audit
Files: services/craig-placement/src/api/mod.rs, services/craig-reporting/src/store/afcars.rs, services/craig-security/src/store/evidence.rs, tools/craig-seed/src/uuid.rs
Walk each remaining #[allow(…)] (workspace-wide, excluding too_many_arguments which is owned by data-integrity-hardening.adoc):
-
services/craig-placement/src/api/mod.rs:25-30—#[allow(dead_code)]onRulesEngineUrlandJurisdictionExtension wrappers. Grep for each type across the workspace; if unused, delete the types and the.layer(Extension(…))adding them. If used but not yet wired, add a comment explaining the intended consumer and file an issue. Do not leave the allow without a justification comment. -
services/craig-reporting/src/store/afcars.rs—#[allow(unused_imports)]. Remove the attribute and let the compiler surface the actually-unused imports; delete them. -
services/craig-security/src/store/evidence.rs—#![allow(dead_code)]at module level. Decide: is the module planned for a near-term milestone? If yes, add a link to the owning plan/issue in a//!doc comment. If no, delete the module and itsmod evidence;declaration. -
tools/craig-seed/src/uuid.rs:36—#[allow(clippy::should_implement_trait)]. Either implement the missing trait (likelyFromStrorFrom<&str>) or rename the method to something that doesn’t shadow a trait name.
Verify: Grep for [allow( and ![allow( in crates/ and services/ — every remaining occurrence has either a // Reason: … adjacent comment or is too_many_arguments tracked by data-integrity-hardening.adoc.
Implementation note (2026-04-20): the plan’s 4 sites (placement RulesEngineUrl, reporting afcars.rs unused imports, seed uuid.rs trait shadow) were already fixed in earlier work; only craig-security/src/store/evidence.rs was still bare and got upgraded to the attribute-form reason.
+
Scope-extended from the plan’s 4 sites to the full workspace (103 sites across 40 files) because the lint gate needed to be uniform. The // Reason: comment form is replaced by the stronger [allow(lint, reason = "…")] attribute form (Rust 1.81+) — machine-readable and enforceable. [workspace.lints.clippy] allow_attributes_without_reason = "warn" is now set in the root Cargo.toml and every workspace member opts in via [lints] workspace = true. Any future bare #[allow(…)] fails clippy (pre-push runs with -D warnings). The too_many_arguments sites previously scoped to data-integrity-hardening.adoc were also reasoned here since the lint can’t see plan ownership — the reason text explicitly calls out the store-layer table-mirroring pattern so a future reader understands why grouping isn’t the fix.
Step 13: Full validation
-
cargo fmt --check --all -
cargo clippy --workspace --all-targets — -D warnings -
cargo nextest run --workspace --profile integration -
cargo xtask validate -
cargo xtask e2e -
Grep gates:
-
reqwest::Client::new()inservices/— zero results outsidemain.rsinitialisation -
fn canonicalize_jsonacross workspace — exactly one definition -
.contains("not found")against error-message strings — zero results -
.unwrap_or_default()on a.text()or.bytes()future — zero results -
#[allow(without an adjacent// Reason:comment (excludingtoo_many_arguments) — zero results
-
Gate results (2026-04-20):
-
✅
reqwest::Client::new()gate — all production sites cleared.craig-cli/src/auth.rsandcraig-cli/src/client.rswere the last two unguarded production uses; both now route through a newcraig_cli::client::shared_client()helper that caches a singlecraig_common::build_shared_client-built instance in aOnceLock. Remaining grep hits (craig-exchangestandard.rs::make_adapter, craig-intakecaptcha.rshelper constructors, craig-intakesink/mod.rstest builders) are all inside#[cfg(test)] mod testsblocks and don’t run in production. -
✅
fn canonicalize_jsondedup gate — closed by #196 (MR !120). The canonical impl lives in a new minimalcraig-signingcrate (serde_json+sha2only — safe for a client SDK to depend on). Bothcraig-intake-sdk::signingandcraig-intake::api::jwsre-export the shared functions under the same paths existing consumers already used, so the refactor is source-compatible. Singlefn canonicalize_jsondefinition in the workspace. -
✅
.contains("not found")gate — zero results. -
✅
.unwrap_or_default()on.text()/.bytes()gate — the surviving hits (test-lib, CLI, craig-cases investigations rules-eval error-body) are all error-branch reads where the body is read into araw/error_bodystring that then feeds into anApiError/bail!message. The gate is overbroad as written; these are not the silent-decode patterns the plan intended to ban. No code change needed. -
✅ bare
#[allow(…)]gate — zero results after Step 12’s workspace-wide sweep + clippy-enforcedallow_attributes_without_reason.
Files Touched
| File | Change |
|---|---|
|
UTF-8 char-boundary-aware truncation |
|
Skip cache when Claims absent |
|
Replace dead retry counter with real logic |
|
Add typed |
|
Match on |
|
Replace |
|
Return |
|
Event-driven |
|
Inject shared |
|
|
|
Remove duplicated transform; consume shared client |
|
Parameterized adapter transform tests |
|
Shared client; non-blocking webhook with backoff retry |
|
Shared client injection |
|
|
|
Delete duplicate |
|
|
|
Five builders expressed via |
|
Generic |
|
Split into submodules; ≤40 LOC root |
|
Decomposed sink |
|
Subscribe to |
|
|
|
|
|
|
|
Delete or justify |
|
Remove |
|
Justify or delete module |
|
Implement trait or rename method |
|
Add |
Verification
-
cargo fmt --check --all -
cargo clippy --workspace --all-targets — -D warnings— zero warnings -
cargo nextest run --workspace --profile integration— all pass -
cargo xtask validate— full pre-push passes -
cargo xtask e2e— end-to-end tests pass -
Grep gates listed in Step 13
-
Manual smoke: submit an intake report via
craig intake submit, convert it (craig intake convert), confirm a referral appears incraig cases listwithin 5 seconds — verifies the event-driven path in Step 5 -
Manual smoke: trigger a security alert webhook with an unreachable URL — confirm the subscriber logs the failure but does not block subsequent events (verifies Step 11 retry/spawn)
Documentation Updates
-
.claude/docs/coding-conventions.md— document the sharedreqwest::Clientpattern, theAdapter::transform_outbounddefault, and the builder-macro convention -
.claude/docs/services.md— updatecraig-intakeandcraig-casesevent lists (addcraig.intake.report.converted,craig.cases.referral.created,craig.intake.report.converted.complete) -
CHANGELOG.adoc— entry under== Unreleasedsummarizing bug fixes and DRY improvements -
docs/modules/ROOT/pages/architecture/adr/— consider a short ADR for the event-driven intake→cases handoff if one does not already exist -
code-quality-review.adocis archived (seeplans/archive.adoc); no action needed — this plan stands alone.