Plan I: Handler / Module Decomposition + DIP + ISP
On this page
- Status
- Context
- Cross-cutting invariants
- Scope
- Steps
- Step 2: F-033 function decomposition (threshold 40 lines)
- Step 3: F-034 god-module split
- Step 4: F-035 test client ISP — MUST split (≤16 methods per coding-conventions.md §Style)
- Step 5: F-036 DIP: handler → store-layer sweep
- Step 6: F-037 typed DTOs replacing CRAIG-business-logic Value (partner-edge cites Plan L)
- Step 7: F-054 struct-method-count xtask lint (NEW per coding-conventions.md §Style)
- Step 8: 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/G/H/J/K. nav.adoc + CHANGELOG. No code changes. |
Done (2026-05-15) — Plan filed via !307. |
2 |
F-033 implementation: function decomposition for ALL functions over 40 lines (strict per coding-conventions.md §Style; subagent triages "important function" overrides per-site). Baseline sites at 2026-05-15: |
Done (2026-05-17) — Substantiated full sweep + triage (ADR-030 §3): 220 over-40-line functions across services audited via custom AST tool. 53 are bootstrappers ( |
3 |
F-034 implementation: god-module split. Per-file batches: (a) |
Done (2026-05-17) — All 4 F-034 batches shipped: 3a (craig-cases |
4 |
F-035 implementation: test client ISP — MUST split. Per coding-conventions.md §Style: "No struct/object/impl should have more than 16 methods" with carve-out for getters/setters/builder-pattern. CasesClient (65 methods on audit — was quoted at 62 in the 2026-05-15 plan body; grew slightly) and SecurityClient (51 methods) violate. Chosen design (substantiated 2026-05-17 architectural review): Concrete domain-shaped clients (Pattern 1), not trait extensions (Pattern 2) and NOT multiple impl blocks on one struct (Pattern 3 — fails the per-struct half of the rule). Pattern 1 matches the existing concrete-client idiom across the other 8 test-lib clients ( F-035a — CasesClient split into 7 concrete domain clients: * `CasesPersonsClient (7 methods) — persons CRUD (4) + report-person linking (3)
* All clients live under F-035b — SecurityClient split into 8 concrete domain clients (substantiated 2026-05-17 review): Architectural correctness drove the 8-way split over a more compact 5-6 way. Three small (5-method) clients — Reviews, NIST, Changes — are intentionally kept separate rather than lumped under a * All clients live under F-035c/d/e — additional violations surfaced by the F-054 lint (2026-05-17): The F-054 lint (Step 7) implementation surfaced 3 more test-client violations beyond the two named in the 2026-05-15 plan body: * F-035c — PlacementClient (36) → 6 clients ( 11 new domain clients + 33 new harness accessors (96 total typed-client accessors workspace-wide). Branches: |
Done (2026-05-17) — F-035a (CasesClient → 7, !343 / |
5 |
F-036 implementation: DIP — sweep direct Scope-correction substantiated 2026-05-17 (ADR-030 §3): The "20+ in True DIP violations remaining at F-036 audit (15 sites total): * 13 sites of Trait-based DIP refactor (true type-level abstraction, hiding the executor entirely behind per-domain store traits) is deferred to F-067 (filed at #464). That work needs its own plan: trait surface design, tx-composition strategy, per-store concrete types, mock implementations for tests, migration sequence. Scope is Plan M (or similar), not a Plan I sub-step. |
Done (2026-05-17) — 3 Plan G F-026 misses migrated to |
6 |
F-037 implementation: typed DTOs replacing Scope substantiated 2026-05-17 (ADR-030 §3): Audit categorized 24 . CRAIG-business-logic event-payload subscribers (typed) — craig-financial + craig-reporting are generic event-bus subscribers that consume Each carries an explicit The |
Done (2026-05-17) — craig-financial + craig-reporting event-payload subscribers typed; 5 partner-edge sites carry |
7 |
F-054 implementation: Posture change (substantiated 2026-05-17 during implementation): The 2026-05-15 plan body specified "`allow_failure: true` initially; promote to blocking after Step 4". When the lint was implemented, it surfaced 3 additional violations beyond the F-035a/b set: |
Done (2026-05-17) — |
8 |
Plan completion audit + archive. |
Done (2026-05-17) — All 7 prior steps Done. Plan archives via |
Epic: &31 (epic: Handler / Module Decomposition + DIP + ISP (Plan I))
Issues: #426 (Step 2) · #427 (Step 3) · #428 (Step 4) · #429 (Step 5) · #430 (Step 6) · #453 (Step 7 — F-054 struct-method-count lint) · #431 (Step 8 — plan completion)
Branch prefix: refactor/decomposition-
*Milestone: TBD (heaviest plan; ship in phases as bandwidth allows)
Context
Six separation-of-concerns smells, five from the 2026-05-15 post-Plan-C/F audit and one added 2026-05-15-pm when the project-wide style doctrine was tightened (see .claude/docs/coding-conventions.md §Style):
-
F-033 (P0): Functions over 40 lines. Threshold tightened 2026-05-15 from "fat handlers >100 lines" to strict 40 per coding-conventions.md §Style: "Almost no function should be more than 40 lines. Important functions can be larger… Less than 10% of total functions should be more than 40 lines." Original spot-checks:
convert_report(148 lines, 4 transactions, decrypt+re-encrypt boundaries, person-matching, two distinct event publishes),issue_key(52),create_partner(46), 3+ others. Post-threshold-flip the count grows substantially — every >40-line function gets a subagent triage for "important enough to stay" justification. -
F-034 (P1): 4 source files >500 lines:
cases/api/encryption.rs(830),cases/api/cases.rs(706),exchange/api/icpc.rs(748),cases/api/reports.rs(646). IDE navigation slow; changes to one handler risk others. -
F-035 (P1):
CasesClientexposes 62 public methods;SecurityClientexposes 51. Posture changed 2026-05-15 from "audit-then-decide split" to "MUST split" per coding-conventions.md §Style: "No struct/object/impl should have more than 16 methods. Getters/setters/builder-pattern methods don’t count." Both clients exceed by 3-4×; tests importing them typically use ~3-5 methods per test. Per-client split-boundary determination still requires audit, but the decision to split is forced. -
F-036 (P1): Handlers reach into
app.db.inner()directly, bypassing the store-layer abstraction. 6+ incraig-reporting/api/quality.rs, 20+ acrosscraig-security/api/partners.rs. Tests are forced to depend on DB state; swapping storage = workspace-wide rewrite. -
F-037 (P2):
serde_json::Valueused as function-parameter type in CRAIG-controlled code.reporting/main.rs:403accepts an untyped payload;cases/api/investigations.rsconstructsserde_json::Value::Stringinline for authz attrs. Scope decided 2026-05-15 as "Option 2-as-roadmap, pragmatic today" — CRAIG-business-logic Value occurrences are strict (must be typed); partner-edge Value occurrences (raw_submission, audit envelopes, mock-server inbound) retained with explicit// PARTNER-EDGE-UNTYPED: see plans/partner-typed-schemas.adoccite. Plan L (partner-typed-schemas.adoc) is the destination architecture that retires the carve-outs; this plan’s F-037 covers the immediate CRAIG-owned slice. -
F-054 (P1) NEW: No clippy lint enforces struct method count. The "<16 methods" rule (coding-conventions.md §Style) requires a custom xtask check via
syn::visit::Visit. Without enforcement, future contributors silently re-violate after Step 4’s clean-up.
Cross-cutting invariants
-
F-033 and F-034 overlap. Handler decomposition often reveals natural module splits. Order: F-033 first (per-function refactor), F-034 second (per-file split). Skipping F-033 would make F-034’s split boundaries arbitrary.
-
Use
git mvfor F-034 file moves. Preserve blame history; reviewers can see what moved vs what changed. The MR commit-graph should showR100(100% rename) for the bulk of code, with separate edits for any in-place rewrites. -
F-033 + Plan H F-031 (too_many_lines = 40) are bound at the hip. Plan H Step 2 sets the workspace-wide threshold; Plan I Step 2 does the decomposition work. Sequence is interchangeable as long as the threshold and the decomposition land together. If Plan H ships first, Plan I’s Step 2 surfaces the full violation list via
cargo clippy. If Plan I ships first, Plan H’s lint enforces the post-decomposition invariant. -
F-035 is MUST-split, not audit-then-decide. The "<16 methods (excl. getters/setters/builders)" rule (coding-conventions.md §Style) forces the decision. Audit determines the split boundaries, not whether to split. Per-client analysis identifies natural role-shaped or domain-shaped clusters.
-
F-036 has exceptions. Some direct DB access is correct (boot-time checks, authz preflight that doesn’t fit store-layer semantics). Each exception must carry a
// REASON: <doc>comment or be wrapped in a small named function. -
F-037 has roadmap-cited exceptions. Anywhere we accept an arbitrary partner JSON payload (raw_submission, mock-server inbound, audit-event JSON) carries a temporary
// PARTNER-EDGE-UNTYPED: see plans/partner-typed-schemas.adoccite. Plan L is the destination; this plan’s scope is everything CRAIG-controlled. Final retirement of the carve-outs happens in Plan L Step 8 (F-064). -
F-054’s lint promotes to blocking only after F-035 lands. Allow-failure during the sweep; blocking after the sweep is complete (per Plan K F-047 plan-lint pattern).
Scope
In scope (6 findings):
-
F-033 function decomposition (all functions over 40 lines per coding-conventions.md §Style; subagent-triaged "important" overrides)
-
F-034 god-module split
-
F-035 test client ISP (MUST split — <16 methods per coding-conventions.md §Style)
-
F-036 handler → store-layer sweep (DIP)
-
F-037 typed DTOs replacing CRAIG-business-logic
Value(partner-edge cites Plan L destination) -
F-054
cargo xtask lints struct-method-countenforcement
Out of scope:
-
DTO type discipline (strum continuation) — Plan D
-
DRY refactors / tx-boilerplate extraction — Plan G (note: Plan G’s F-026 helper extraction reduces fat-handler line count; sequencing matters — Plan G first if both run in parallel)
-
Idiomatic Rust + clippy + panicking-call audit + parking_lot migration — Plan H
-
Env-var sprawl — Plan J
-
Canopy xtask + hook backports — Plan K
-
Partner typed schemas (Plan L) — F-037’s retirement happens there, not here
Steps
Step 2: F-033 function decomposition (threshold 40 lines)
Audit-first: surface the full violation list via clippy:
# Requires Plan H Step 2's clippy.toml: too-many-lines-threshold = 40
cargo clippy --workspace --all-targets 2>&1 | rg "too_many_lines"
If Plan H Step 2 hasn’t landed yet, run a one-off clippy.toml for the audit only:
# Temporary clippy.toml at repo root (revert after audit)
cat > clippy.toml <<EOF
too-many-lines-threshold = 40
EOF
cargo clippy --workspace --all-targets -- -W clippy::too_many_lines 2>&1 | rg "too_many_lines"
rm clippy.toml
(Plan H Step 2 will land the permanent clippy.toml; this temp file is for the Plan I audit only.)
Per-violation triage (subagent-assisted):
-
Read the function. Apply the coding-conventions.md §Style test: "Important functions can be larger than 40 lines. Less than 10% of total functions should be more than 40 lines."
-
Classify as one of:
-
Decomposable — multiple distinct responsibilities; extract sub-functions
-
Important and atomic — single coherent responsibility that genuinely needs the line count (state machine fold, deeply-cased validation, JSON parsing into a typed enum with many variants). Document the "important" reason inline:
#[allow(clippy::too_many_lines, reason = "<one-line explanation per coding-conventions.md §Style>")] fn coherent_atomic_function() { ... }
-
-
Subagent verification per "important" classification — use Claude Code’s
Agenttool with theExploresubagent_type and this prompt template:Read the function at <file>:<line-start>-<line-end>. Read .claude/docs/coding-conventions.md § Style. The author has classified this function as "important enough to remain over 40 lines per the <10% exception clause." Verify: could this function be cleanly decomposed into 2+ functions each ≤40 lines without obscuring the logic flow? List the candidate cut points (function name + extracted responsibility) OR confirm that no clean cut exists. Return PASS (no clean decomposition; "important" claim stands) or FAIL (decomposition is clean — list the cut points). Under 150 words.A fresh subagent reads only the function + style doctrine; primary-agent context fatigue doesn’t bias the call.
Files (initial known violations from spot-check; full list emerges from clippy audit):
-
services/craig-cases/src/api/reports.rs::convert_report(148 lines) — extractconvert_report_create_referral_tx+convert_report_audit_txprivate helpers. The decrypt→re-encrypt path stays inconvert_reportproper; the two tx blocks become helpers receiving&mut PgConnection. -
services/craig-security/src/api/partners.rs::issue_key(52 lines) — extract validation + hashing-orchestration helpers -
services/craig-cases/src/api/investigations.rs::create_investigation— extract authz-attrs builder (also touched by F-037 in Step 6) -
services/craig-security/src/api/partners.rs::create_partner(46 lines) — extract validation -
All other functions surfaced by clippy
too_many_linesagainst threshold=40
Per-handler batching: one MR per decomposed function family (related handlers within the same module). "Important and atomic" classifications batch separately as a doc-only MR documenting the carve-outs.
Branch (per handler): refactor/decomposition-step2-<handler-name>
Verification:
-
cargo nextest run -p <service>— no test regression -
cargo clippy --workspace — -D clippy::too_many_linesclean (after the sweep + after "important" allows are documented) -
Subagent has signed off on every
#[allow(clippy::too_many_lines)]site -
Per-handler MR documents the responsibilities-before vs responsibilities-after split
Step 3: F-034 god-module split
Files (4 MRs):
-
services/craig-cases/src/api/encryption.rs(830 lines) → split intoencryption/mod.rs+encryption/{field,report,referral,person}.rs. Usegit mvto preserve blame;mod.rsre-exports the public surface unchanged. -
services/craig-cases/src/api/cases.rs(706) → split by CRUD/lifecycle:cases/{create,read,update,lifecycle}.rs(or similar — actual boundaries determined during refactor). -
services/craig-exchange/src/api/icpc.rs(748) → split intoicpc/{state_machines,validation,workflows}.rs. -
services/craig-cases/src/api/reports.rs(646) — likely split-eligible after F-033 lands; revisit after Step 2 completes.
Branch (per file): refactor/decomposition-step3-<filename>-split
Verification (per MR):
-
cargo build --workspaceclean -
cargo nextest run -p <service>— no test regression -
Git log shows
R100renames for the bulk of moved code -
No
use crate::api::<file>::*regressions in callers (re-exports preserved)
Step 4: F-035 test client ISP — MUST split (≤16 methods per coding-conventions.md §Style)
Process:
-
Audit determines boundaries, not whether to split. The "<16 methods (excl. getters/setters/builder-pattern)" rule (coding-conventions.md §Style) forces the split for
CasesClient(62 methods) andSecurityClient(51 methods). -
For each oversized client: grep all
tests/for.method_name(…)calls; tally which methods each test uses; cluster by method-set. -
Choose split shape based on the clustering. Tentative skeletons (final shape determined by audit):
-
Role-shaped —
CasesReaderClient/CasesWriterClient/CasesAdminClientif tests cluster by GET vs POST/PUT vs DELETE. ~20 methods per client for CasesClient’s 62 — first-tier split likely below 16 ceiling without second tier -
Domain-shaped —
CasesReportsClient/CasesInvestigationsClient/CasesReferralsClient/CasesEncryptionClient/CasesPersonsClientif tests cluster by sub-resource. CasesClient’s 62 methods distribute as e.g. reports (15) / investigations (12) / referrals (10) / encryption (8) / persons (10) / lifecycle (7) — first-tier may need second-tier for the larger sub-domains -
Hybrid — domain-shaped at top with role-shaped sub-clients where the domain has heavy CRUD (e.g.
CasesReportsReaderClient+CasesReportsWriterClientif Reports' 15 split into 10 reads + 5 writes)
-
-
Each post-split client respects the 16-method ceiling. If a domain-shaped split still has a >16-method client, decompose further (e.g.
CasesReportsReaderClient+CasesReportsWriterClient). -
Old wide client stays as a transitional facade re-exporting the sub-clients for one cycle; remove after all integration tests migrate.
Branch: refactor/decomposition-step4-test-client-isp
MR title: refactor(craig-test-lib): split CasesClient (62 methods) + SecurityClient (51 methods) per coding-conventions.md §Style <16 [Step 4 of decomposition]
Verification:
-
Audit report committed as part of the MR body documenting the chosen split + per-test-usage tally
-
Per-post-split client:
cargo xtask lints struct-method-count -p craig-test-libreports ≤16 (using Step 7’s lint once it exists; for this Step’s MR, a manual count is acceptable) -
All integration tests build + pass after migration
-
Old wide-surface clients remain as transitional facades for one cycle; deprecation warning surfaces in
cargo buildto remind contributors
Step 5: F-036 DIP: handler → store-layer sweep
Files (per-service batches):
-
services/craig-reporting/src/api/quality.rs— 6+ directapp.db.inner()calls; move intoservices/craig-reporting/src/store/quality.rs(existing module or NEW) -
services/craig-security/src/api/partners.rs— 20+ direct calls; move intoservices/craig-security/src/store/partners.rs(already exists; expand) -
Per other services as identified during audit
Per-batch process:
-
grep -n 'app\.db\.inner()' services/<svc>/src/api/ -
For each call: extract the SQL into a
store/<domain>.rs::<verb>_<noun>(executor, params)function -
Handler invokes the store function via
store::<domain>::<fn>(app.db.inner(), …). Exceptions (boot-time, authz preflight) stay direct with// REASON: …comment.
Branch (per service): refactor/decomposition-step5-dip-<service>
Verification:
-
grep -c 'app\.db\.inner()' services/<svc>/src/api/drops in each batch -
cargo nextest run -p <service>— no regression -
Per-batch MR documents what moved + what stayed exception with reason
Step 6: F-037 typed DTOs replacing CRAIG-business-logic Value (partner-edge cites Plan L)
Scope split:
-
In scope (typed strictly): every
serde_json::Valueoccurrence in CRAIG-business-logic code —services/craig-reporting/src/main.rs:403,services/craig-cases/src/api/investigations.rs:27-30, everyValuefield on a CRAIG-owned struct that doesn’t cross a partner boundary, everyValue-typed parameter in CRAIG-internal helpers. -
Out of scope (partner-edge — retain temporarily with explicit cite):
raw_submissionJSONB envelopes, partner audit payloads, mock-server inbound JSON,services/craig-exchange/src/adapters/mod.rs::ExchangeAdaptertrait Value params. Each site adds a// PARTNER-EDGE-UNTYPED: see plans/partner-typed-schemas.adoccomment. Plan L F-064 closes these sites.
Files (representative — full list at audit time):
-
services/craig-reporting/src/main.rs:403::parse_uuid(payload: &serde_json::Value, field: &str) → Uuid— define a typed payload struct + extractparse_uuidto a method on it -
services/craig-cases/src/api/investigations.rs:27-30— inlineserde_json::Value::String("…")for authz attrs. Define typedAuthzAttrsForInvestigationCreatestruct, deriveSerialize/Deserialize, use it. -
Every other site surfaced by
rg 'serde_json::Value' services/ crates/ --type rustnot under partner-edge classification
Partner-edge sites that get the comment (not the type change):
-
services/craig-cases/src/api/reports.rs—raw_submission: serde_json::Valuefield onReportRow -
services/craig-security/src/api/audit.rs—payload: serde_json::Valueon audit records -
services/craig-exchange/src/adapters/mod.rs::send+noop.rs,standard.rs— adapterpayload: &Value/Result<Value, String>(Plan L F-058 redesigns this signature entirely)
Branch: refactor/decomposition-step6-typed-dtos
Verification:
-
cargo build --workspaceclean -
cargo nextest run --workspace— no regression -
Per-MR: list of
Valueparams replaced + the typed shapes that replaced them -
Every retained
Valuesite carries thePARTNER-EDGE-UNTYPED:comment -
rg 'serde_json::Value' services/ crates/ --type rust | rg -v PARTNER-EDGE-UNTYPED | rg -v '/tests/'returns only test code
Step 7: F-054 struct-method-count xtask lint (NEW per coding-conventions.md §Style)
Files:
-
xtask/src/cmd/lints.rs(NEW or EDIT — if other custom lints already live there, extend) — implement struct-method-count check viasyn::visit::Visit. Walks everyItemImplnode inservices//.rsandcrates//.rs; for each impl block, counts methods after filtering:fn is_getter(sig: &Signature) -> bool { /* fn name(&self) -> &T */ } fn is_setter(sig: &Signature) -> bool { /* fn name(&mut self, val: T) */ /* or builder fn name(mut self, val: T) -> Self */ } fn is_builder(sig: &Signature) -> bool { /* fn name(self, ...) -> Self */ }Method counts excluding the three shapes; report any impl block with count > 16.
-
xtask/src/cmd/mod.rs— registerlintssubcommand if new; otherwise extend -
xtask/src/cmd/validate.rs— call into the new lint withallow_failure: trueinitially (F-035 sweep reduces violations); promote toallow_failure: falseafter Step 4 lands -
xtask/tests/struct_method_count_test.rs(NEW) — fixture impl blocks: (a) 17 plain methods → fail, (b) 17 methods of which 3 are getters → pass (effective count 14), (c) 50-method client → fail with method-list dump, (d) trait impl with 17 methods → pass (trait impls don’t count — the trait itself defines the surface)
Branch: feat/decomposition-step7-struct-method-count-lint
MR title: feat(xtask): cargo xtask lints struct-method-count — enforce ≤16 method ceiling [Step 7 of decomposition]
Verification:
-
cargo xtask lints struct-method-countagainst the current tree surfaces the same violations identified in Step 4’s pre-split audit -
Post-Step-4 tree: zero violations (or each remaining violation has a documented carve-out comment)
-
cargo xtask validateruns the lint -
Fixture tests in
xtask/tests/struct_method_count_test.rsexercise the four cases above
Files Touched
| File | Step | Change |
|---|---|---|
|
2,3 |
EDIT (handler decomposition + module split) |
|
3 |
SPLIT (830 → multiple files) |
|
3 |
SPLIT (706 → multiple files) |
|
3 |
SPLIT (748 → multiple files) |
|
2,5 |
EDIT (handler decomposition + DIP sweep) |
|
2,6 |
EDIT (handler decomposition + typed authz attrs) |
|
5 |
EDIT (DIP sweep) |
|
4 |
SPLIT (CasesClient 62 → multiple ≤16-method clients; same for SecurityClient 51) |
|
6 |
EDIT (typed payload struct) |
|
7 |
NEW or EDIT (struct-method-count check via |
|
7 |
EDIT (wire struct-method-count lint; allow_failure first then promote) |
|
7 |
NEW (4 fixture cases) |
Risks
| Risk | Mitigation |
|---|---|
F-033 over-decomposes a coherent handler into 5 tiny private functions that obscure flow |
Accept "100 lines is fine if linear and readable"; the goal is "decompose mixed concerns", not "everything fits on one screen" |
F-034 file splits change import paths workspace-wide; integration tests break |
Use |
F-035 Reader/Writer/Admin split is premature abstraction if real test usage doesn’t cluster |
Audit-first; the audit report itself is the deliverable if split is wrong |
F-035 CasesClient 62 methods → realistically 5-6 sub-clients |
62/16 = 3.875 mathematically, but if any sub-cluster lands at 17 methods the second-tier |
F-036 might force tests to grow a new mock-store layer where direct-DB tests sufficed |
Defer the DIP completion in services where the test-cost > benefit; document the exception |
Plan G’s tx-helper extraction (F-026) overlaps with F-033’s fat-handler decomposition |
Plan G ships first if both run in parallel; F-033’s per-handler MRs adopt Plan G’s helper if it’s landed |
After this plan lands
-
Every function over 40 lines either decomposed or carries a subagent-signed-off
#[allow(clippy::too_many_lines, reason = "…")]carve-out (<10% of total functions per coding-conventions.md §Style) -
4 god-modules (>500 lines) split into responsibility-shaped files
-
CasesClient(62 methods) +SecurityClient(51 methods) split into ≤16-method-per-impl clients (strict per coding-conventions.md §Style) -
Direct
app.db.inner()calls in handlers swept into store-layer functions (with documented exceptions) -
serde_json::Valueremoved from every CRAIG-business-logic site; remaining sites cite Plan L as the destination via// PARTNER-EDGE-UNTYPED:comment. F-037 partner-edge sites remain carve-out until Plan L F-064 lands (which closes them). -
cargo xtask lints struct-method-countenforces the <16 ceiling going forward (allow_failure during Step 4 sweep; blocking after)