Plan G: DRY + YAGNI Cleanup
On this page
- Status
- Context
- Cross-cutting invariants
- Scope
- Steps
- Step 2: F-026 transaction-boilerplate helper
- Step 3: F-027 event-publish error-chain collapse
- Step 4: F-028 test-fixture helper extraction
- Step 5: F-029 unused config knob audit
- Step 6: F-065 cross-handler DRY consolidation scan (was gated on Plans D / G / H / I / J / K / L — completed 2026-06-10 via !671)
- Step 7: Plan completion audit + archive
- Files Touched
- Verification
- Risks
- After this plan lands
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan filing — body lands in the docs-only Plan D refresh MR alongside Plans D/H/I/J/K. nav.adoc + CHANGELOG. No code changes. |
Done (2026-05-15 via !307) |
2 |
F-026 implementation: extract |
Done (2026-05-16) — 2a (!318) + 2b (craig-cases 16/18 !319) + 2c (craig-placement 5/7 !321) + 2d (craig-financial 2/3 !323) + 2e (craig-exchange 2/4 !325) + 2f (craig-security 12/12) shipped 2026-05-16. All 37 helper-compatible sites migrated; 7 manual-handling sites preserved by design (compensation-on-blob-Err, retry loops, FOR UPDATE re-reads — structurally don’t fit the helper’s begin → commit → drop-rollback shape). Per-service: craig-cases 18 (16 swept + 2 manual); craig-placement 7 (5 swept + 2 manual); craig-financial 3 (2 swept + 1 manual: |
3 |
F-027 implementation: add typed source variants to |
Done (2026-05-16) — 3a (!329) + 3b (craig-cases 169 · 3 preserved match-arms · 236/236) + 3c (craig-placement 75 · 7 preserved string-literal stubs · 99/99) + 3d (craig-financial 39 · 0 preserved · 106/106) + 3e (craig-exchange 54 · 1 preserved adapter-edge · 92/92) + 3f (craig-security 70 · 5 preserved match-arms · 192/192). Total: 407 sites collapsed across the 5 stateful services; 16 preserved by design (Plan H F-032 territory); net -489 LOC; 725/725 service tests + 34/34 craig-common unchanged. Closes #417. |
4 |
F-028 implementation: extract test-fixture helpers into |
Done (2026-05-16) — 3 async helpers ( |
5 |
F-029 implementation: audit each YAGNI-flagged config knob; either delete (if truly never overridden) or wire override paths + document in |
Done (2026-05-17) — 3-agent audit found ALL 7 knobs are real operator-tunable knobs (zero DELETE candidates). All 7 promoted: |
6 |
F-065 implementation: cross-handler DRY consolidation scan. Inventory the per-handler helpers extracted during Plan I F-033 + F-034 (and any other code-quality plan’s decompositions); cluster by shape; promote ≥3-instance clusters to shared helpers in |
Done (2026-06-10) — gate satisfied (Plans D/G2-5/H/I/J/K/L all Done; M/N/P/Q/R landed since, so the substrate was even more stable than the gating clause demanded). 2-lens scan: thesis-driven verification of the 5 #462 candidate categories + unbiased sweep of 290 private helpers across 127 files / 9 services. Verdicts — multipart upload parsing: CLUSTER (5 sites) → consolidated into NEW |
7 |
Plan completion audit + archive. |
Done (2026-05-17) — substantive plan-completion audit per |
Epic: &29 (epic: DRY + YAGNI Cleanup (Plan G))
Issues: #416 (Step 2) · #417 (Step 3) · #418 (Step 4) · #419 (Step 5) · #462 (Step 6 — cross-handler DRY scan; BLOCKED) · #420 (Step 7 — plan completion)
Branch prefix: chore/dry-yagni- / refactor/dry-yagni-
Milestone: TBD (lowest-priority of the maintainability suite; ship on reviewer capacity)
Context
Two themes surfaced during the 2026-05-15 post-Plan-C/F audit:
-
F-026 / F-027 (DRY — P0): 45 handlers across 5 services follow the same
let mut tx = app.db.inner().begin().await.map_err(ApiError::internal)?→ store call →events::publish_*(&mut tx, …)→tx.commit()shape. ~40+ event-publish error chains repeat the same.await.map_err(ApiError::internal)?postfix. The duplication is purely mechanical — handlers differ only in what they pass to the store. Both are extractable as a thin helper onAppState(or as an axum extractor). -
F-028 (DRY — P1): Recent MRs added test-fixture boilerplate across services (e.g. the 7-services-textually-identical
healthz_carries_documented_csptest bodies in !306). When a future contributor adds a 9th service or needs to extend the CSP assertion, they have to remember to update 7 files. Pulling the shape intocraig_test_lib::assert_service_cspmakes the call site 1 line. -
F-029 (YAGNI — P2): 7 config knobs identified (re-anchored 2026-05-15 from earlier "6" count) that have a single hardcoded default + are never overridden in
.env.example,docker-compose.yml, or any deployment surface:payment_period,introspection_cache_ttl_seconds,introspection_cache_max_entries,introspection_serve_on_outage,public_rate_limit,captcha_secret,captcha_verify_url. Either they’re operational constants masquerading as config (delete the knob; document the constant) or they’re real knobs that need to be wired into deployment surfaces (otherwise operators can’t toggle them).
This plan is the application-layer slice of the broader code-quality work; Plans D (taxonomy + visibility + skip + ignore-audit), H (idiomatic Rust + clippy), I (KISS / SOLID / separation), J (env-var sprawl), and K (canopy backports) cover the rest. Plans D / G / H / I / J / K run independently except where Step 5 touches the same handler files (sync via per-service batches).
Cross-cutting invariants
-
No behavior change. Every extraction must produce identical handler behavior. New helpers are mechanical refactors, not logic changes. Pre/post
cargo nextest run --workspacemust show identical pass/fail. -
Per-service batches. F-026 + F-027 sweeps run in independent batches per service crate (5 batches for the 5 services that hold the 45 sites). Each batch is an independent MR; conflicts only on
craig-api/src/lib.rs(the helper itself). -
Helper API design before sweep — Step 2 splits into 2a + 2b-2f. Step 2a is the helper-only MR (zero call-site changes;
craig-api/src/lib.rsonly). Steps 2b-2f are the 5 service-batch MRs (per-service callsite migration). Avoids landing 45 changes against an API the reviewer hasn’t seen yet. Step 3 (F-027) follows the same shape. -
Plan I F-033 sequencing. Plan I F-033 decomposes fat handlers (
convert_report148 lines + 5 others); many of those handlers ARE the 45 tx-boilerplate sites. Ordering constraint: Plan G’s Step 2 + Step 3 sweeps must precede Plan I F-033 within the same service crate, OR Plan G must be fully merged before F-033 starts. Don’t co-merge — the diff stacks become unreviewable. -
F-029 audit-then-decide. Each YAGNI knob is either deleted (constant inlined; doc note added if non-obvious) or promoted (wired into
.env.example+docker-compose.yml). No middle ground — "leave it as undocumented config" is the current state and explicitly not acceptable. F-029’s promote-path overlaps with Plan J F-038’s.env.exampleexpansion — if Plan J ships first, F-029 just deletes the surviving knobs; if Plan G ships first, the promoted knobs feed Plan J’s audit.
Scope
In scope (5 findings):
-
F-026 transaction-boilerplate helper extraction
-
F-027 event-publish error-chain collapse
-
F-028 test-fixture helper extraction (CSP + similar duplication patterns)
-
F-029 unused config knob audit (delete or promote)
-
F-065 cross-handler DRY consolidation scan (post-decomposition; gated on Plans D / G / H / I / J / K / L completion)
Out of scope:
-
Strum DTO conversion — Plan D
-
pub(crate)discipline — Plan D -
Idiomatic Rust + clippy — Plan H
-
KISS / SOLID / separation — Plan I
-
Env-var sprawl /
.env.exampleexpansion — Plan J -
Canopy xtask backports — Plan K
Steps
Step 2: F-026 transaction-boilerplate helper
Audit grep (45 sites enumerable via):
rg -n 'let mut tx = app\.db\.inner\(\)\.begin\(\)' services/craig-{cases,placement,financial,exchange,security}/src/api/
Per-service counts (2026-05-15 baseline): craig-cases 18 · craig-placement 7 · craig-financial 3 · craig-exchange 5 · craig-security 12. Re-anchor at branch time.
Files:
-
crates/craig-api/src/lib.rs— add method onAppState(NOT a trait — keeps callsitesapp.execute_within_tx(…)rather than<AppState as AppStateExt>::execute_within_tx(&app, …)). Signature as shipped (Step 2a, !MR-pending 2026-05-16):impl AppState { pub async fn execute_within_tx<F, T>( &self, handler: F, ) -> Result<T, craig_common::ApiError> where F: for<'c> AsyncFnOnce( &'c mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<T, craig_common::ApiError>, { let mut tx = self .db .inner() .begin() .await .map_err(craig_common::ApiError::internal)?; match handler(&mut tx).await { Ok(value) => { tx.commit() .await .map_err(craig_common::ApiError::internal)?; Ok(value) } // sqlx::Transaction rolls back on Drop; explicit rollback is optional. Err(e) => Err(e), } } }handler closure receives &mut sqlx::Transaction<', Postgres>(NOT&mut PgConnection) — this is the type allevents::publish*and store-fn signatures already accept, and what Step 3’spublish_within_txwill also take.Deviation from plan body 2026-05-15 draft: the draft called for
FnOnce + FuturewithFutnamed as a generic parameter. That shape does not satisfy Rust 2024’s lifetime rules — the returned future needs to capture the&'c mut Transactionborrow, but a single namedFuttype cannot depend on the HRTB lifetime'c.AsyncFnOnce(stable since Rust 1.85; CRAIG is on 1.94) is the correct form and is what the helper ships with. The draft listedAsyncFnOnceas "a valid alternative"; promoting it to the chosen path per ADR-030 §3 (deviation → update Design/Scope, not errata). -
Per-service batches (5 MRs):
-
services/craig-cases/src/api/*.rs— 18 sites -
services/craig-placement/src/api/*.rs— 7 sites -
services/craig-financial/src/api/*.rs— 3 sites -
services/craig-exchange/src/api/*.rs— 5 sites -
services/craig-security/src/api/*.rs— 12 sites
-
Branch (per-batch): refactor/dry-yagni-step2-tx-helper-<service> (5 branches)
Verification (per batch):
-
cargo build -p <service>clean -
cargo nextest run -p <service>— pass/fail count unchanged from pre-extraction baseline -
grep -c "let mut tx" services/<service>/src/api/— drops to ~0
CHANGELOG draft: per-MR, list of handlers refactored + before/after line count.
Step 3: F-027 event-publish error-chain collapse
Chosen shape (substantiated 2026-05-16 via 7-agent architectural review). Add typed source variants to ApiError in crates/craig-common/src/error.rs so ? propagates sqlx::Error / object_store::Error / reqwest::Error / csv::Error / uuid::Error directly without .map_err(ApiError::internal)? postfix. This collapses ~473 sites (sqlx 438 + object_store ~10 + reqwest ~16 + csv ~3 + uuid ~6) — ~85% of the workspace’s .map_err(ApiError::internal)? surface.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
// ... NotFound / BadRequest / Unauthorized / Forbidden / Conflict unchanged ...
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("object store error: {0}")]
ObjectStore(#[from] object_store::Error),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("csv error: {0}")]
Csv(#[from] csv::Error),
#[error("uuid error: {0}")]
Uuid(#[from] uuid::Error),
// Transitional catch-all — F-032 (Plan H Step 5) deprecates this:
#[error("internal error: {detail}")]
Internal { detail: String },
}
Deviation from 2026-05-15 draft per ADR-030 §3. The draft named impl From<events::PublishError> for ApiError; no events::PublishError type exists (publish_* returns Result<(), sqlx::Error>). Path B (publish_within_tx helper) was rejected — each publish_* fn has different params; no common Publishable trait covers them. The typed-source-variant shape is the architecturally sound expression of "Path A" given the actual code, advances Plan H F-032’s typed-everywhere policy (.claude/docs/coding-conventions.md:23-27) at the trait boundary instead of locking in the Internal { detail: String } antipattern via a blanket From<sqlx::Error> → Internal-stringify impl, and covers ~85% of the postfix surface (vs the draft’s ~13% publish-only scope).
|
Preserved-by-design sites (DO NOT migrate to ?):
-
services/craig-cases/src/api/reports.rs:148-161—Err(e)arm with rollback + DB error code"23505"idempotency probe. Match-on-variant, not?. -
services/craig-cases/src/api/report_persons.rs:258-269—match { Ok | Err(unique_violation) | Err(e) }returning 409 Conflict. -
services/craig-cases/src/matching/mod.rs:377-393—Ok | Err(unique_violation) | Err(other)idempotent-replay swallow with logging.
These three sites pattern-match on sqlx::Error::Database(…).is_unique_violation() to produce typed ApiError::Conflict or silent retry behavior — semantically distinct from "any sqlx error is an internal error." The sweep agents preserve them with a // PLAN-G-F027-RETAIN-MATCH: comment annotation.
Out-of-scope (handled by F-032 in Plan H Step 5):
-
BadRequest { detail: String }/NotFound { entity, id: String }/Conflict { entity, detail: String }— client-facing String payloads. F-032 audits these for typed*Kindenums. -
The ~79 non-typed sites (28 string-literal misconfig + ~17
anyhow::Errorinners from crypto/token/matching + custom enumsJwsReplayError/EngineError/ApiError-passthrough). The transitionalInternal { detail: String }variant +ApiError::internal(impl Display)constructor remain available; F-032 deprecates them in the broader audit.
Per-MR structure (mirrors Step 2’s split):
-
Step 3a — helper-only MR (
crates/craig-common/src/error.rsonly). Add 5 typed variants +#[from]derives. ExtendIntoResponsewith explicit arms for each variant (all map to HTTP 500 with the redacted body; inner detail logged attracing::error!). Add 4 unit tests covering round-trip + status + redaction. Zero callsite changes. Done (2026-05-16) on branchrefactor/dry-yagni-step3a-typed-source-variants. -
Steps 3b-3f — per-service sweeps. Each batch removes
.map_err(ApiError::internal)?from sqlx/object_store/reqwest/csv/uuid sites in one service crate. Preserve the 3 explicit-match sites above + anytracing::warn!(…)audit comments. Branch:refactor/dry-yagni-step3<letter>-<service>.
Verification (per batch):
-
cargo build -p <service>clean -
cargo nextest run -p <service>— pass/fail count unchanged from pre-extraction baseline -
grep -c "map_err(ApiError::internal)" services/<service>/src/api/— drops by the typed-source quota (sqlx + tier-2 sites) -
Preserved-by-design sites still present (audit comment + match-on-variant intact)
Step 4: F-028 test-fixture helper extraction
Files:
-
crates/craig-test-lib/src/csp.rs— addassert_service_csp<F: Future<Output = HeaderMap>>(…)async helper that takes a URL + the appropriateassert_*_cspcallback. Each service’shealthz_carries_documented_cspcollapses to ~5 lines. -
services/craig-{rules,cases,placement,exchange,financial,reporting,security}/tests/api/health.rs— collapse to helper invocation. -
services/craig-intake/tests/api/health.rs— both modes use the helper with different CSP assertions. -
services/craig-web/tests/csp.rs— same.
Branch: refactor/dry-yagni-step4-csp-test-helper
Verification:
-
cargo nextest run --workspace healthz_carries_documented_csp— 11/11 pass (same as today) -
Line count delta: -120 LOC across the 9 test files (160 LOC removed; ~40 LOC added in
craig-test-lib)
Step 5: F-029 unused config knob audit
For each of the 6 flagged knobs:
-
payment_period(crates/craig-common/src/settings.rs:149) — verify incraig-financialhandlers whether it’s actually read; if not consumed, delete. If consumed but never overridden, inline the default + delete the config field. -
introspection_cache_ttl_seconds,introspection_cache_max_entries,introspection_serve_on_outage(settings.rs:162-173) — Plan F shipped without operator-visible overrides. Either wire into.env.example+ document indocs/modules/ROOT/pages/idp-integration.adocOR delete + inline the constants. Probably the former — operators tuning introspection latency need these. -
public_rate_limit(services/craig-intake/src/config.rs:90) — verify whether it’s actually used by the rate-limit middleware; document if so. -
captcha_secret+captcha_verify_url— already partially documented in.env.example; check coverage.
Branch: refactor/dry-yagni-step5-config-audit
MR title: refactor(craig-common, craig-intake, deployment-guide): audit unused config knobs — promote or delete [Step 5 of dry-yagni]
Verification:
-
cargo nextest run --workspace— no test regression from default changes -
.env.examplediff covers each promoted knob with a comment explaining when an operator would override
Step 6: F-065 cross-handler DRY consolidation scan (was gated on Plans D / G / H / I / J / K / L — completed 2026-06-10 via !671)
Gating. Do not start until every other code-quality plan (D, G Steps 2-5, H, I, J, K, L) is on main with all-Done Status rows. The scan’s value depends on a stable post-decomposition substrate — running it earlier produces speculative helpers that decompose work-in-flight.
Approach:
-
Inventory. Grep + cluster the per-handler helpers landed across
services/*/src/api/during Plan I F-033 + F-034 decompositions. The 2026-05-16 starter helpers (prepare_conversion,create_referral_from_conversion,parse_upload_multipart,place_upload_object, etc.) are the substrate. -
Cluster by shape. For each ≥3-instance cluster, draft a shared helper signature. Candidate categories surfaced during !320 / !322 (see issue #462):
-
Multipart upload boilerplate — likely shared as
craig-store::upload_helpers::receive_multipart_to_store(…) → (UploadParts, object_key, file_size). -
Tx1-commit + Tx2-best-effort-audit pattern — only if 3+ sites emerge; candidate
AppState::execute_with_audit(primary, audit). -
Idempotent-create —
find_by_Xshort-circuit; candidatestore::find_or_insert_*. -
PII encrypt/decrypt-at-boundary orchestration — the call shape, not the existing helpers themselves.
-
Actor-sub parsing (
claims.sub.parse::<Uuid>()) — canonicalize incraig-auth.
-
-
Per-cluster MR. One MR per consolidation. Behavior preserved; tests pass; line-count delta should be net negative.
Out of scope for this step:
-
Speculative helpers without ≥3 call sites.
-
Helpers crossing service boundaries that would require new crates.
-
Pre-completion consolidation (substrate not stable).
Branch prefix: refactor/dry-yagni-step6-consolidate-<cluster-name>
Tracking: #462.
Files Touched
| File | Step | Change |
|---|---|---|
|
2,3 |
EDIT (add |
|
2,3 |
EDIT (~45 + ~40 sites) |
|
4 |
EDIT (add |
|
4 |
EDIT (collapse to helper invocations) |
|
5 |
EDIT (delete or promote 6 config knobs) |
|
5 |
EDIT |
|
5 |
EDIT (promoted knobs) |
|
5 |
EDIT (document promoted knobs) |
Risks
| Risk | Mitigation |
|---|---|
Step 2’s |
Per-handler audit during refactor; helpers that don’t fit stay as-is and are documented as exceptions |
Step 5 deletion of a "never overridden" knob breaks a deployment surface that wasn’t audited |
Search GitLab issues + |
Step 4’s |
Accept and refactor in-place; the helper is internal to |
After this plan lands
-
45 handler tx-open/commit boilerplate sites collapsed to helper calls
-
~40 event-publish error chains collapsed to helper calls
-
7+ duplicate CSP test bodies replaced with 1-line helper invocations
-
6 unused config knobs either deleted or promoted to documented config