Code Quality Review & Refactoring Plan

On this page

Status

COMPLETE (partial — see Residual) — archived 2026-04-17 (plan-hygiene sweep).

Phase Description Status

1

Service boot consolidation (extract to craig-api::bootstrap)

Deferred (not applied) — crates/craig-api/src/bootstrap.rs exists as a result of exploratory work but no service’s main.rs calls it. All 8 service main.rs files still contain ~50 lines of duplicated boot code. See residual note below.

2

Role-check helper centralization

Done (pre-ADR-030) — helpers are methods on Claims in craig-auth and used across craig-cases / craig-placement / craig-financial / craig-exchange / craig-reporting / craig-security / craig-intake

3

PageResponse<T> deduplication

Done (pre-ADR-030) — PageResponse<T>, fetch_page(), total_pages(), DEFAULT_PER_PAGE centralized in services/craig-web/src/routes/mod.rs

4

Split oversized files

Done (2026-03-23) — MR !63 — craig-security/src/api.rs split into admin.rs/archive.rs/audit.rs/detection.rs/nist.rs; craig-web/src/routes/placement.rsplacement/; craig-web/src/routes/security.rssecurity/

5

SecurityAddon consolidation

Done (pre-ADR-030) — consolidated in crates/craig-api/src/lib.rs (commit ca6ff1c)

6

Template cleanup (cases/detail.html split) + dead-code removal

Deferred (out of scope) — polish deferred indefinitely; no active plan

Residual — moved to follow-up tracking

Phase 1 (bootstrap integration) is the only Phase with genuine remaining work: bootstrap() function exists in crates/craig-api/src/bootstrap.rs but no service consumes it. Retrofitting all 8 services is a mechanical refactor (~350 lines saved) — tracked as #179, referenced from the active Code Quality Remediation plan (#180).

Context

A comprehensive code quality audit of the CRAIG codebase identified recurring patterns of duplication, oversized files, and inconsistent error handling. The shared crates (crates/) are excellent — clean APIs, no god modules, proper separation of concerns. The issues concentrate in two areas: (1) boilerplate duplicated across backend services, and (2) structural issues in craig-web’s route handlers and templates.

The goal is to eliminate spaghetti code, reduce duplication, and improve maintainability without changing any external behavior.

Audit Summary

Strengths (no action needed)

  • Shared crates (craig-common, craig-auth, craig-db, craig-mq, craig-api, craig-store, craig-reference) — production quality

  • EventEnvelope pattern — standardized across all services

  • Test organization — consistent structure across all services

  • Workspace dependency management — no unused deps, justified pins

  • Configuration/settings pattern — consistent env var loading with redaction

Issues Found

# Issue Severity Instances Est. Lines Saved

1

Boot sequence boilerplate in every service main.rs

HIGH

8 services

~350

2

Role-check helpers redefined in 4+ services

HIGH

12+ functions

~60

3

PageResponse<T> defined 11 times in craig-web

HIGH

11

~70

4

List handler pagination pattern duplicated

HIGH

18 handlers

~150

5

Oversized files (800+ lines)

HIGH

5 files

 — (reorganize)

6

Silent error swallowing in web list handlers

MEDIUM

115 call sites

 — (fix pattern)

7

SecurityAddon duplicated in every service

MEDIUM

8 services

~50

8

per_page = 25 hardcoded everywhere

MEDIUM

18 sites

 — (constant)

9

cases/detail.html monolithic (471 lines, 5 tabs)

MEDIUM

1 file

 — (split)

10

27 list/count function pairs with near-identical WHERE

LOW

27 pairs

complex


Phase 1: Service Boot Consolidation

Problem

All 8 service main.rs files contain ~50 identical lines. The shared boot sequence in every service is:

// 1. dotenvy
dotenvy::dotenv().ok();
// 2. settings
let settings = ServiceSettings::load("CRAIG_<SERVICE>");  // or IntakeSettings::load() for intake
// 3. telemetry
craig_common::telemetry::init(&settings.log_level);
// 4. DB connect + options
let db = DbPool::connect_with(
    PgConnectOptions::from_str(&settings.database_url)?.log_statements(log::LevelFilter::Debug),
).await?;
// 5. migrations
db.run_migrations(&sqlx::migrate!()).await?;
// 6. JWKS + auth
let jwks = JwksProvider::new(&settings.keycloak_issuer, None).await;
jwks.refresh().await?;
jwks.start_refresh_task();
let auth = AuthLayer::new(jwks);
// 7. RabbitMQ + channels
let rmq = lapin::Connection::connect(&settings.rabbitmq_url, ...).await?;
let pub_channel = rmq.create_channel().await?;
let sub_channel = rmq.create_channel().await?;  // except intake: no subscriber
let publisher = Publisher::new(pub_channel);
let subscriber = Subscriber::new(sub_channel);

Each service then adds its own setup after the shared boot:

Service Service-Specific Setup After Boot

craig-rules

RulesEngine::new(db, publisher, jurisdiction) + subscribe() for 3 event patterns + subscribe_exclusive() for cache invalidation with instance_id self-event skip

craig-cases

std::env::var("CRAIG_CASES__RULES_ENGINE_URL") + reqwest Client (30s timeout, pool_max_idle=10) + ObjectStoreConfig::load() + Store::from_config() + subscribe() for 3 event patterns

craig-placement

std::env::var("CRAIG_PLACEMENT__RULES_ENGINE_URL") + subscribe() for 2 event patterns

craig-exchange

ObjectStoreConfig::load() + Store::from_config() + NoopAdapter + subscribe() for 3 event patterns

craig-financial

subscribe() for 4 event patterns with business logic handlers (handle_placement_created, handle_placement_ended, handle_eligibility_evaluated, handle_rules_evaluated)

craig-reporting

subscribe() for wildcard patterns (case., placement., eligibility., financial.) with validation handlers

craig-security

ObjectStoreConfig::load() + Store::from_config() + subscribe() for # (all events) with audit log handler

craig-intake

IntakeSettings::load() (NOT ServiceSettings), NO subscriber, CaptchaVerifier::new(), ApiKeyState, 3-tier routing (JWT + CAPTCHA + API key), manual axum::serve() (NOT ApiServer::serve())

Solution

Add bootstrap() to craig-api crate that encapsulates steps 1-7 above.

File: crates/craig-api/src/bootstrap.rs (NEW)

pub struct BootstrapResult {
    pub db: DbPool,
    pub auth: AuthLayer,
    pub publisher: Publisher,
    pub subscriber: Subscriber,
}

pub async fn bootstrap(service_name: &str) -> (ServiceSettings, BootstrapResult) {
    dotenvy::dotenv().ok();
    let settings = ServiceSettings::load(service_name);
    craig_common::telemetry::init(&settings.log_level);
    tracing::info!("{service_name} starting — {settings:?}");

    let db = DbPool::connect_with(
        PgConnectOptions::from_str(&settings.database_url)
            .expect("invalid database URL")
            .log_statements(log::LevelFilter::Debug),
    )
    .await
    .expect("DB connect");
    db.run_migrations(&sqlx::migrate!()).await.expect("migrations");

    let jwks = JwksProvider::new(&settings.keycloak_issuer, None).await;
    jwks.refresh().await.expect("JWKS refresh");
    jwks.start_refresh_task();
    let auth = AuthLayer::new(jwks);

    let rmq = lapin::Connection::connect(
        &settings.rabbitmq_url,
        lapin::ConnectionProperties::default(),
    )
    .await
    .expect("RabbitMQ connect");
    let pub_channel = rmq.create_channel().await.expect("publisher channel");
    let sub_channel = rmq.create_channel().await.expect("subscriber channel");
    let publisher = Publisher::new(pub_channel);
    let subscriber = Subscriber::new(sub_channel);

    (settings, BootstrapResult { db, auth, publisher, subscriber })
}
bootstrap() takes &sqlx::migrate::Migrator as a parameter since each service has its own sqlx::migrate!() macro invocation (compile-time embedded migrations).

Each service main.rs reduces to:

let (settings, boot) = craig_api::bootstrap("CRAIG_CASES", &sqlx::migrate!()).await;
// Service-specific setup only:
let rules_client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
let object_store = Store::from_config(&ObjectStoreConfig::load()?)?;
let router = ApiServer::router(state, routes, opts, Some(api_doc));
ApiServer::serve(router, settings.port, shutdown_signal()).await;

Per-service changes

  • craig-rules (main.rs, 235 lines → ~80): Extract lines 19-74 into bootstrap(). Keep lines 77-130 (RulesEngine, event subscriptions incl. subscribe_exclusive cache invalidation).

  • craig-cases (main.rs, 186 lines → ~60): Extract lines 19-77 into bootstrap(). Keep lines 80-110 (rules HTTP client, ObjectStore).

  • craig-placement (main.rs, 154 lines → ~50): Extract lines 18-76 into bootstrap(). Keep lines 79-90 (rules URL, subscriptions).

  • craig-exchange (main.rs, 166 lines → ~55): Extract lines 22-76 into bootstrap(). Keep lines 79-98 (ObjectStore, NoopAdapter).

  • craig-financial (main.rs, 396 lines → ~340): Extract lines 19-73 into bootstrap(). Keep lines 76-396 (event handlers with business logic — these are large and service-specific).

  • craig-reporting (main.rs, 357 lines → ~300): Extract lines 18-72 into bootstrap(). Keep lines 75-357 (event handlers with validation logic).

  • craig-security (main.rs, 185 lines → ~60): Extract lines 20-74 into bootstrap(). Keep lines 77-93 (ObjectStore, wildcard subscriber).

  • craig-intake (main.rs, 181 lines → ~120): Extract lines 26-69 into bootstrap(). Keep lines 75-144 (CaptchaVerifier, ApiKeyState, 3-tier routing, manual axum::serve()). NOTE: intake uses IntakeSettings not ServiceSettings — either add a generic settings parameter to bootstrap() or have intake load IntakeSettings separately and call a bootstrap_with_settings() variant. Also, intake has NO subscriber channel.

Files modified

  • crates/craig-api/src/bootstrap.rs — NEW

  • crates/craig-api/src/lib.rs — add pub mod bootstrap;

  • crates/craig-api/Cargo.toml — add deps (craig-db, craig-mq, craig-auth, lapin, sqlx)

  • services/craig-rules/src/main.rs — replace lines 19-74 with bootstrap() call

  • services/craig-cases/src/main.rs — replace lines 19-77 with bootstrap() call

  • services/craig-placement/src/main.rs — replace lines 18-76 with bootstrap() call

  • services/craig-exchange/src/main.rs — replace lines 22-76 with bootstrap() call

  • services/craig-financial/src/main.rs — replace lines 19-73 with bootstrap() call

  • services/craig-reporting/src/main.rs — replace lines 18-72 with bootstrap() call

  • services/craig-security/src/main.rs — replace lines 20-74 with bootstrap() call

  • services/craig-intake/src/main.rs — replace lines 26-69 with bootstrap() call (special handling for IntakeSettings + no subscriber)

  • craig-web does NOT use this bootstrap (no DB, no MQ, different auth flow)


Phase 2: Role-Check Helper Consolidation

Problem

Role-check free functions are redefined in multiple services. The current pattern (identical in each service):

// craig-reporting/src/api.rs lines 27-51, craig-security/src/api.rs, craig-placement/src/api/mod.rs
fn require_caseworker_or_above(claims: &Claims) -> Result<(), ApiError> {
    if claims.has_role("caseworker") || claims.has_role("supervisor") || claims.has_role("admin") {
        Ok(())
    } else {
        Err(ApiError::Forbidden)
    }
}

fn require_supervisor_or_above(claims: &Claims) -> Result<(), ApiError> {
    if claims.has_role("supervisor") || claims.has_role("admin") {
        Ok(())
    } else {
        Err(ApiError::Forbidden)
    }
}

fn require_admin(claims: &Claims) -> Result<(), ApiError> {
    if claims.has_role("admin") {
        Ok(())
    } else {
        Err(ApiError::Forbidden)
    }
}

// craig-financial/src/api.rs lines 31-56 — same pattern but also has:
fn require_eligibility_worker_or_above(claims: &Claims) -> Result<(), ApiError> {
    if claims.has_role("eligibility_worker") || claims.has_role("supervisor") || claims.has_role("admin") {
        Ok(())
    } else {
        Err(ApiError::Forbidden)
    }
}

Locations to delete:

  • services/craig-reporting/src/api.rs lines 27-51 — require_caseworker_or_above, require_supervisor_or_above, require_admin

  • services/craig-financial/src/api.rs lines 31-56 — require_eligibility_worker_or_above, require_supervisor_or_above, require_admin

  • services/craig-placement/src/api/mod.rs — require_caseworker_or_above, require_supervisor_or_above, require_admin

  • services/craig-security/src/api.rs — require_caseworker_or_above, require_supervisor_or_above, require_admin

  • services/craig-cases/src/api/cases.rs — check for local helpers

  • services/craig-exchange/src/api/mod.rs — check for local helpers

Call sites change from require_caseworker_or_above(&claims)? to claims.require_caseworker_or_above()?.

Solution

Move to craig-auth crate as methods on Claims:

File: crates/craig-auth/src/claims.rs

impl Claims {
    pub fn require_role(&self, role: &str) -> Result<(), ApiError> {
        if self.has_role(role) { Ok(()) } else { Err(ApiError::forbidden("Insufficient role")) }
    }

    pub fn require_any_role(&self, roles: &[&str]) -> Result<(), ApiError> {
        if roles.iter().any(|r| self.has_role(r)) {
            Ok(())
        } else {
            Err(ApiError::forbidden("Insufficient role"))
        }
    }

    pub fn require_caseworker_or_above(&self) -> Result<(), ApiError> {
        self.require_any_role(&["caseworker", "supervisor", "admin"])
    }

    pub fn require_supervisor_or_above(&self) -> Result<(), ApiError> {
        self.require_any_role(&["supervisor", "admin"])
    }

    pub fn require_admin(&self) -> Result<(), ApiError> {
        self.require_role("admin")
    }

    pub fn require_eligibility_worker_or_above(&self) -> Result<(), ApiError> {
        self.require_any_role(&["eligibility_worker", "supervisor", "admin"])
    }

    pub fn require_icpc_coordinator_or_above(&self) -> Result<(), ApiError> {
        self.require_any_role(&["icpc_coordinator", "supervisor", "admin"])
    }
}
The current code uses ApiError::Forbidden (enum variant). Verify whether craig-auth already depends on craig-common (which defines ApiError). If not, add the dependency. The new methods use ApiError::Forbidden to match the existing pattern exactly.

Files modified

  • crates/craig-auth/src/claims.rs — add methods

  • crates/craig-auth/Cargo.toml — add craig-common dep (for ApiError) if not already present

  • services/craig-financial/src/api.rs — delete lines 31-56, replace all call sites

  • services/craig-reporting/src/api.rs — delete lines 27-51, replace all call sites

  • services/craig-placement/src/api/mod.rs — delete local helpers, replace all call sites

  • services/craig-security/src/api.rs — delete local helpers, replace all call sites

  • services/craig-cases/src/api/cases.rs — delete local helpers if present, replace call sites

  • services/craig-exchange/src/api/mod.rs — delete local helpers if present, replace call sites


Phase 3: craig-web Deduplication

3a. Extract PageResponse<T> to routes/mod.rs

Currently defined identically in 8 files (intake.rs has it twice). Delete from all locations:

File Lines to delete

routes/cases.rs

34-39

routes/intake.rs

67-72 AND 814-820 (duplicated for public reports)

routes/placement.rs

62-67

routes/exchange.rs

72-77

routes/financial.rs

89-94

routes/security.rs

74-79

routes/rules.rs

30-35

report.rs and dashboard.rs do NOT define PageResponse (no list handlers).

Move to shared location. File: services/craig-web/src/routes/mod.rs

#[derive(Deserialize, Default)]
pub struct PageResponse<T> {
    pub data: Vec<T>,
    pub page: u32,
    pub per_page: u32,
    pub total: i64,
}

Each route file adds use super::PageResponse;.

3b. Extract pagination utility and fix error swallowing

The current pattern in ALL 18 list handlers (identical in every one):

// Current pattern — silent error swallowing, duplicated 18 times:
let resp: PageResponse<SomeView> = state
    .api
    .get(&state.config.some_url, &path, token)
    .await
    .ok()                                          // <-- error silently discarded
    .and_then(|v| serde_json::from_value(v).ok())  // <-- parse error silently discarded
    .unwrap_or_default();

let total_pages = if resp.per_page > 0 {
    (resp.total as u32).div_ceil(resp.per_page)
} else {
    1
};

Replace with shared utility. File: services/craig-web/src/routes/mod.rs

pub const DEFAULT_PER_PAGE: u32 = 25;

pub fn total_pages(per_page: u32, total: i64) -> u32 {
    if per_page > 0 { (total as u32).div_ceil(per_page) } else { 1 }
}

pub async fn fetch_list<T: serde::de::DeserializeOwned + Default>(
    api: &ApiClient,
    base_url: &str,
    path: &str,
    token: &str,
) -> PageResponse<T> {
    match api.get(base_url, path, token).await {
        Ok(v) => serde_json::from_value(v).unwrap_or_default(),
        Err(e) => {
            tracing::warn!("API call failed: {path}: {e}");
            PageResponse::default()
        }
    }
}

18 list handlers to update

Each handler’s fetch+deserialize+total_pages block (8-12 lines) reduces to 2 lines:

let resp = fetch_list::<CaseView>(&state.api, &state.config.cases_url, &path, token).await;
let total_pages = total_pages(resp.per_page, resp.total);

Complete list of handlers to update:

File Handler Function Lines

cases.rs

list()

207-220

intake.rs

worklist()

173-192

intake.rs

referral_list()

235-254

intake.rs

public_reports()

807-826

placement.rs

matching()

155-174

placement.rs

homes_list()

220-230

placement.rs

placements_list()

321-340

exchange.rs

partners()

176-189

exchange.rs

agreements()

217-235

exchange.rs

transactions()

266-287

exchange.rs

icpc()

319-340

financial.rs

payments()

167-185

financial.rs

rates()

328-341

financial.rs

claims()

445-463

security.rs

audit()

143-167

security.rs

reviews()

198-211

security.rs

archive()

242-255

security.rs

nist()

286-299

rules.rs

list()

74-87

Also replace all per_page=25 hardcodings with DEFAULT_PER_PAGE:

  • cases.rs:182, intake.rs:157, placement.rs:144,209,313, exchange.rs:176,217,266,319, financial.rs:167,328,445, security.rs:143,198,242,286, rules.rs:74

Files modified

  • services/craig-web/src/routes/mod.rs — add PageResponse<T>, fetch_list(), total_pages(), DEFAULT_PER_PAGE

  • services/craig-web/src/routes/cases.rs — delete PageResponse (lines 34-39), update list() handler

  • services/craig-web/src/routes/intake.rs — delete PageResponse (lines 67-72 AND 814-820), update 3 handlers

  • services/craig-web/src/routes/placement.rs — delete PageResponse (lines 62-67), update 3 handlers

  • services/craig-web/src/routes/exchange.rs — delete PageResponse (lines 72-77), update 4 handlers

  • services/craig-web/src/routes/financial.rs — delete PageResponse (lines 89-94), update 3 handlers

  • services/craig-web/src/routes/security.rs — delete PageResponse (lines 74-79), update 4 handlers

  • services/craig-web/src/routes/rules.rs — delete PageResponse (lines 30-35), update 1 handler

  • services/craig-web/src/routes/report.rs — NO changes (no list handlers)

  • services/craig-web/src/routes/dashboard.rs — NO changes (no list handlers)


Phase 4: Split Oversized Files

4a. routes/intake.rs (996 lines) → routes/intake/ directory

routes/intake/mod.rs — shared types, re-exports, route registration:

  • View models: ReferralView, AllegationView, ReferralDetailView, InvestigationView, ChildView, PerpView

  • Query params: WorklistParams, ReferralListParams

  • Template structs: re-export from submodules

  • pub use for all public handler functions

routes/intake/worklist.rs — investigation worklist handlers:

  • worklist() — investigation worklist list (lines 148-210)

  • investigation_detail() — investigation detail

  • create_investigation() — create investigation

  • update_investigation() — update investigation

  • safety_assessment_form() — safety assessment form

  • submit_safety_assessment() — submit safety assessment

routes/intake/referrals.rs — referral handlers:

  • referral_list() — referral list (lines 212-274)

  • new_referral_form() — new referral form

  • create_referral() — create referral

  • referral_detail() — referral detail

  • add_allegation() — add allegation to referral

routes/intake/reports.rs — public report list handlers (authenticated staff view):

  • PublicReportView, PublicReportListParams (view model + params)

  • public_reports() — public reports list (lines 785-847)

  • public_report_detail() — report detail

  • claim_public_report() — claim report

  • convert_public_report() — convert to referral

  • screen_out_public_report() — screen out report

4b. routes/cases.rs (838 lines) → routes/cases/ directory

routes/cases/mod.rs — shared types, re-exports:

  • View models: CaseView, HouseholdMemberView, CasePlanView, ContactView, ContactAttachmentView, CourtOrderView

  • Query params: CaseListParams

  • pub use for all handler functions

routes/cases/list.rs — case listing:

  • list() (lines 172-239)

  • new_form() — new case form

  • create() — create case

routes/cases/detail.rs — case detail:

  • detail() (lines 302-375) — uses tokio::join! for 5 parallel API calls

  • update() — update case

routes/cases/contacts.rs — contact CRUD + attachments:

  • add_contact() — add contact log entry

  • list_contact_attachments() — htmx fragment for attachment list

  • upload_contact_attachment() — multipart upload

  • download_contact_attachment() — streaming download

  • delete_contact_attachment() — delete attachment

routes/cases/household.rs — household members:

  • add_household_member() — add member

routes/cases/plans.rs — case plans + tasks:

  • create_plan() — create case plan

  • approve_plan() — approve case plan

  • add_task() — add task to plan

routes/cases/court.rs — court orders:

  • add_court_order() — add court order

4c. services/craig-reporting/src/api.rs (830 lines) → api/ directory

api/mod.rs — routes builder, OpenAPI doc, shared types:

  • routes() function (builds Router)

  • ApiDoc struct (#[derive(OpenApi)])

  • SecurityAddon (until Phase 5 removes it)

  • Role-check helpers (until Phase 2 removes them, lines 27-51)

api/quality.rs — data quality:

  • quality_dashboard() (lines 195-231)

  • list_issues() (lines 246-285)

  • resolve_issue() (lines 301-319)

api/afcars.rs — AFCARS submissions:

  • generate_afcars() (lines 337-362)

  • list_afcars() (lines 377-407)

  • get_afcars() (lines 423-436)

  • review_afcars() (lines 454-484)

  • approve_afcars() (lines 501-532)

  • transmit_afcars() (lines 550-589)

api/ncands.rs — NCANDS submissions:

  • generate_ncands() (lines 607-632)

  • list_ncands() (lines 647-677)

  • review_ncands() (lines 695-725)

  • approve_ncands() (lines 742-772)

  • transmit_ncands() (lines 790-829)

4d. services/craig-financial/src/api.rs (803 lines) → api/ directory

api/mod.rs — routes builder, OpenAPI doc, shared types:

  • routes() function (builds Router)

  • ApiDoc struct

  • SecurityAddon (until Phase 5 removes it)

  • Role-check helpers (until Phase 2 removes them, lines 31-56)

api/rates.rs — rate tables:

  • list_rates() (lines 240-275)

  • create_rate() (lines 290-311)

api/payments.rs — payments:

  • list_payments() (lines 328-371)

  • get_payment() (lines 387-400)

  • calculate_payment() (lines 417-457)

  • approve_payment() (lines 474-513)

api/adjustments.rs — payment adjustments:

  • create_adjustment() (lines 532-557)

  • approve_adjustment() (lines 574-622)

api/claims.rs — claiming records:

  • list_claims() (lines 639-676)

  • generate_claim() (lines 691-721)

  • get_claim() (lines 737-750)

  • submit_claim() (lines 767-802)

Files modified

4 monolithic files → 4 directories with 17 focused files total. No behavior changes — pure reorganization. Each mod.rs re-exports all public items so external use paths remain the same.


Phase 5: SecurityAddon Consolidation

Problem

Every service defines an identical SecurityAddon struct. The current implementation (same in all 8 services):

// e.g., craig-reporting/src/api.rs lines 97-112, craig-financial/src/api.rs lines 137-152
struct SecurityAddon;

impl Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.get_or_insert_default();
        components.add_security_scheme(
            "bearer",
            SecurityScheme::Http(
                HttpBuilder::new()
                    .scheme(HttpAuthScheme::Bearer)
                    .bearer_format("JWT")
                    .build(),
            ),
        );
    }
}
The current code uses get_or_insert_default() and the scheme name "bearer" (not "bearer_auth"). The consolidated version must match exactly.

Locations to delete:

  • services/craig-rules/src/api.rs — SecurityAddon struct + impl

  • services/craig-cases/src/api/mod.rs — SecurityAddon struct + impl

  • services/craig-placement/src/api/mod.rs — SecurityAddon struct + impl

  • services/craig-exchange/src/api/mod.rs — SecurityAddon struct + impl

  • services/craig-financial/src/api.rs lines 137-152

  • services/craig-reporting/src/api.rs lines 97-112

  • services/craig-security/src/api.rs — SecurityAddon struct + impl

  • services/craig-intake/src/api.rs — SecurityAddon struct + impl

Solution

Move to craig-api crate.

File: crates/craig-api/src/lib.rs

pub struct SecurityAddon;

impl utoipa::Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.get_or_insert_default();
        components.add_security_scheme(
            "bearer",
            utoipa::openapi::security::SecurityScheme::Http(
                utoipa::openapi::security::HttpBuilder::new()
                    .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
                    .bearer_format("JWT")
                    .build(),
            ),
        );
    }
}

Each service replaces use of local SecurityAddon with use craig_api::SecurityAddon; and deletes the local definition.

Files modified

  • crates/craig-api/src/lib.rs — add SecurityAddon + impl Modify

  • crates/craig-api/Cargo.toml — add utoipa dep

  • services/craig-rules/src/api.rs — delete local SecurityAddon, add use craig_api::SecurityAddon;

  • services/craig-cases/src/api/mod.rs — same

  • services/craig-placement/src/api/mod.rs — same

  • services/craig-exchange/src/api/mod.rs — same

  • services/craig-financial/src/api.rs — delete lines 137-152, add use craig_api::SecurityAddon;

  • services/craig-reporting/src/api.rs — delete lines 97-112, add use craig_api::SecurityAddon;

  • services/craig-security/src/api.rs — same

  • services/craig-intake/src/api.rs — same


Phase 6: Template Cleanup

6a. Split cases/detail.html (472 lines)

Currently contains 5 tab panels controlled by Alpine.js x-data="{ tab: 'Summary' }". The tab strip is at lines 22-30.

Tab content line ranges:

Tab Start End Description

Summary

33

83

Case details card, status badge, close case button

Household

86

159

Member table, add member form

Case Plan

162

282

Plans table with nested tasks, create plan form, approve button

Contacts

285

384

Contact log table, expandable narrative + attachment list (htmx), add contact form

Court

387

469

Court orders table, add court order form

Shared template variables available to all tabs:

  • case — main case object: case_number, status, stage, admin_unit, assigned_worker, supervisor, opened_at, closed_at, closure_reason, id, icwa_flag

  • household — Vec of members: person_id, role, primary_caregiver, active, added_at

  • plans — Vec of plans: permanency_goal, status, strengths, needs, review_due_at, approved_by, created_by, id (each with nested tasks)

  • contacts — Vec of contacts: occurred_at, contact_type, contact_with, duration_minutes, recorded_by, narrative, id

  • court_orders — Vec of orders: order_date, order_type, court_name, judge, next_hearing_date, id

  • ctx — page context: user (with can_write(), is_supervisor()), branding_app_name

Split into Askama includes:

  • templates/cases/detail.html — parent: extends base.html, defines Alpine x-data, tab strip (lines 1-30), includes 5 tab files, closing tags (line 470-472)

  • templates/cases/_tab_summary.html — lines 33-83 (51 lines)

  • templates/cases/_tab_household.html — lines 86-159 (74 lines)

  • templates/cases/_tab_plans.html — lines 162-282 (121 lines)

  • templates/cases/_tab_contacts.html — lines 285-384 (100 lines)

  • templates/cases/_tab_court.html — lines 387-469 (83 lines)

Each include file is wrapped in <div x-show="tab === 'TabName'" x-cloak> which stays in the parent or the include (implementer’s choice — both work with Askama).

6b. Remove dead code markers

Clean up #[allow(dead_code)] annotations (~17 instances). For each one:

  • If the code is truly unused (no callers, no tests) — delete it

  • If the code is planned for a future phase — add a // TODO(phase-N): …​ comment explaining the intended use

  • Specific known case: services/craig-placement/src/api/mod.rs lines 20-27 — RulesEngineUrl and Jurisdiction structs with #[allow(dead_code)] are declared but never used as Extension types. Delete if unused or annotate with planned use.


Implementation Order

  1. Phase 3 (craig-web dedup) — lowest risk, highest frequency of duplication, no API changes

  2. Phase 2 (role helpers) — small, contained, improves all services

  3. Phase 5 (SecurityAddon) — small, contained

  4. Phase 4 (split files) — reorganization only, no behavior changes

  5. Phase 1 (bootstrap) — highest impact but most complex, touches all services

  6. Phase 6 (templates + dead code) — polish

Each phase is independently testable and can be done on its own feature branch.

Testing Strategy

Every phase:

  1. cargo fmt --all + cargo clippy --workspace --locked — lint clean

  2. cargo test --workspace --lib — unit tests pass

  3. cargo xtask dev reload — rebuild all services

  4. cargo test --workspace — integration tests pass

  5. cargo xtask e2e — E2E tests pass (no behavior changes)

No new tests needed — these are pure refactoring changes. All existing tests serve as regression tests.

Verification

After all phases:

  1. Full test battery passes (unit + integration + E2E)

  2. cargo clippy --workspace --locked — zero warnings

  3. git diff --stat shows net line reduction of ~600+ lines

  4. No file exceeds 600 lines

  5. No duplicate PageResponse<T> definitions

  6. No duplicate role-check functions

  7. No duplicate SecurityAddon implementations

  8. All error paths in web list handlers log via tracing

Edit this page · latest