Plan: Crate Quality Parity (Backport from Canopy)

On this page

Status

Step Description Status

1

Centralize pagination logic — adopt PageRequest helper pattern in all stores and handlers

Not started

2

Deduplicate WHERE clauses across count/list store function pairs

Not started

3

Add From<sqlx::Error> for ApiError impl

Not started

4

Adopt jurisdiction config as Extension pattern for flexible per-deployment behavior

Not started

5

Refactor store modules to entity-based organization

Not started

6

Introduce newtype wrappers for domain IDs (CaseId, PersonId, ReferralId, etc.)

Not started

7

Simplify handler boilerplate using centralized pagination

Not started

8

Full validation pass

Not started

Epic: TBD
Branch: chore/crate-quality-parity
Labels: type::chore, priority::high

Context

A detailed crate-by-crate comparison between CRAIG and its sibling project Canopy revealed that while CRAIG has superior documentation, security posture, test infrastructure, and defensive test coverage, Canopy has better DRY discipline at the implementation level.

Specific gaps in CRAIG:

  1. Pagination math is scattered across 30+ handlers. Every list endpoint manually computes per_page, limit, and offset. Canopy centralizes this in a PageRequest helper with saturating_sub, clamping, and DoS prevention built in. One change to pagination logic in CRAIG requires updating 30+ locations.

  2. Duplicated WHERE clauses. Store modules duplicate the same WHERE clause across count and list function pairs. For example, list_cases_paged and count_cases in craig-cases/src/store/cases.rs have identical WHERE logic copy-pasted. A filter change requires updating both functions in lockstep.

  3. No From<sqlx::Error> for ApiError. Handlers must manually map database errors. Canopy implements this conversion, enabling clean ? operator usage.

  4. Hard-coded per-service configuration. Each service manually sets up its own rules client with custom timeout and pool config. Canopy makes jurisdiction configuration a first-class Extension, loaded from rulesets/{jurisdiction}/ at startup.

  5. Flat module organization. craig-cases/src/store/mod.rs exports 8 flat modules. Canopy uses entity-based modules where each domain concept (persons, addresses, income) is cleanly separated.

  6. No newtype IDs. Both projects use raw Uuid for all identifiers. A CaseId and a PersonId are interchangeable at compile time — the compiler cannot catch ID-mixing bugs.

These are mechanical improvements that reduce defect surface without changing architecture.

Scope

In scope:

  • PageRequest helper in craig-common (matching Canopy’s implementation)

  • Refactor all store list/count functions to use PageRequest

  • Extract shared WHERE clause builders to eliminate duplication

  • From<sqlx::Error> for ApiError impl in craig-common

  • Jurisdiction config loading as Extension pattern

  • Entity-based store module reorganization

  • Newtype domain IDs with sqlx/serde compatibility

  • Handler simplification using centralized pagination

Out of scope:

  • Security headers, rate limiting, CORS (CRAIG already has these)

  • Doc comments (CRAIG already has these)

  • Test infrastructure changes (CRAIG’s test-lib is already modular)

  • Auth test coverage expansion (CRAIG already has defensive tests)

Design

Centralized Pagination

Add to crates/craig-common/src/pagination.rs (matching Canopy):

/// Maximum results per page (DoS prevention).
const MAX_PER_PAGE: u32 = 500;

/// Pagination request with built-in clamping and overflow protection.
#[derive(Debug, Clone, Deserialize)]
pub struct PageRequest {
    #[serde(default = "default_page")]
    pub page: u32,
    #[serde(default = "default_per_page")]
    pub per_page: u32,
}

impl PageRequest {
    /// Compute the SQL OFFSET value. Uses saturating arithmetic to prevent underflow.
    pub fn offset(&self) -> i64 {
        ((self.page.saturating_sub(1)) as i64) * (self.clamped_per_page() as i64)
    }

    /// Compute the SQL LIMIT value, clamped to MAX_PER_PAGE.
    pub fn limit(&self) -> i64 {
        self.clamped_per_page() as i64
    }

    fn clamped_per_page(&self) -> u32 {
        self.per_page.min(MAX_PER_PAGE)
    }
}

Then replace all manual pagination math in handlers with:

// Before (10+ lines per handler):
let per_page = query.per_page.clamp(1, super::MAX_PER_PAGE);
let limit = per_page as i64;
let offset = (query.page.saturating_sub(1) as i64) * limit;
let total = store::cases::count_cases(pool, &filter).await?;
let cases = store::cases::list_cases_paged(pool, &filter, limit, offset).await?;

// After (2 lines):
let page = PageRequest { page: query.page, per_page: query.per_page };
let (cases, total) = store::cases::list_cases(pool, &filter, &page).await?;

WHERE Clause Deduplication

Merge count and list functions into a single function that returns both results, or extract the WHERE clause into a shared builder:

/// List cases matching the filter, with count for pagination.
pub async fn list_cases(
    pool: &PgPool,
    filter: &CaseFilter,
    page: &PageRequest,
) -> Result<(Vec<Case>, i64), sqlx::Error> {
    // Single WHERE clause used for both count and list
    let where_clause = build_case_where_clause(filter);

    let count_sql = format!("SELECT COUNT(*) as count FROM cases {where_clause}");
    let list_sql = format!(
        "SELECT * FROM cases {where_clause} ORDER BY {col} {dir} LIMIT $N OFFSET $M",
        col = filter.validated_sort_column(),
        dir = filter.validated_sort_dir(),
    );

    let total: i64 = sqlx::query_scalar(&count_sql)
        .bind_filter(filter)
        .fetch_one(pool)
        .await?;

    let items: Vec<Case> = sqlx::query_as(&list_sql)
        .bind_filter(filter)
        .bind(page.limit())
        .bind(page.offset())
        .fetch_all(pool)
        .await?;

    Ok((items, total))
}

This eliminates the duplicated WHERE clause entirely. Apply this pattern to all count/list pairs across: cases, persons, referrals, investigations, placements, kinship, contacts, court orders.

Newtype IDs

Create crates/craig-common/src/id.rs:

macro_rules! define_id {
    ($name:ident) => {
        /// Strongly-typed identifier. Wraps UUID v7 to prevent ID mixing at compile time.
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash,
                 serde::Serialize, serde::Deserialize, sqlx::Type)]
        #[sqlx(transparent)]
        pub struct $name(pub uuid::Uuid);

        impl $name {
            /// Generate a new ID using UUID v7 (time-sortable).
            pub fn new() -> Self { Self(uuid::Uuid::now_v7()) }
        }

        impl std::fmt::Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                self.0.fmt(f)
            }
        }
    };
}

define_id!(CaseId);
define_id!(PersonId);
define_id!(ReferralId);
define_id!(InvestigationId);
define_id!(PlacementId);
define_id!(FosterHomeId);
define_id!(KinshipId);
define_id!(CourtOrderId);
define_id!(ContactId);
define_id!(AllegationId);
define_id!(CasePlanId);
define_id!(CasePlanTaskId);
define_id!(AttachmentId);

The #[sqlx(transparent)] derive ensures these work with sqlx::query_as! without changing SQL. serde derives ensure API request/response compatibility (serializes as UUID string).

Jurisdiction Config as Extension

Replace per-service hard-coded configuration with a jurisdiction-driven Extension:

// services/craig-cases/src/config.rs
#[derive(Clone)]
pub struct CasesConfig {
    pub admin_unit_label: String,
    pub case_number_prefix: String,
    pub investigation_window_days: u32,
    // ... loaded from rulesets/{jurisdiction}/cases.toml
}

impl CasesConfig {
    pub fn load(rulesets_dir: &Path, jurisdiction: &str) -> Result<Self> {
        let path = rulesets_dir.join(jurisdiction).join("cases.toml");
        let raw = std::fs::read_to_string(&path)?;
        Ok(toml::from_str(&raw)?)
    }
}

Then in main.rs:

let config = CasesConfig::load(&rulesets_dir, &settings.jurisdiction)?;
let router = router.layer(axum::Extension(config));

Handlers extract it via Extension(config): Extension<CasesConfig> instead of reading hard-coded values.

Steps

Step 1: Centralize pagination in craig-common

Files: crates/craig-common/src/pagination.rs (new or expand existing), crates/craig-common/src/lib.rs

  1. Implement PageRequest struct with offset(), limit(), clamped_per_page() methods

  2. Add MAX_PER_PAGE constant (500)

  3. Add Deserialize derive for direct use as Axum query parameter

  4. Add comprehensive tests: page 0 saturates, per_page clamped, overflow protection

  5. Re-export from craig_common::pagination

Step 2: Deduplicate WHERE clauses in store modules

Files: services/craig-cases/src/store/cases.rs, services/craig-cases/src/store/persons.rs, services/craig-cases/src/store/referrals.rs, services/craig-cases/src/store/investigations.rs, services/craig-placement/src/store/placements.rs, services/craig-placement/src/store/foster_homes.rs

  1. For each count/list function pair, merge into a single function returning (Vec<T>, i64)

  2. Extract shared WHERE clause into a builder function or constant

  3. Validate that sort column/direction validation still applies

  4. Update all callers (handlers) to destructure the tuple return

  5. Delete orphaned count-only functions

Step 3: Add From<sqlx::Error> for ApiError

Files: crates/craig-common/src/error.rs

  1. Add implementation:

    impl From<sqlx::Error> for ApiError {
        fn from(err: sqlx::Error) -> Self {
            tracing::error!("database error: {err}");
            ApiError::Internal("database error".into())
        }
    }
  2. Update handlers that manually map sqlx errors to use ? instead

  3. Verify no information leakage (error detail is generic, real error is logged)

  4. Add test: sqlx error converts to 500 with generic message

Step 4: Jurisdiction config as Extension

Files: services/craig-cases/src/config.rs (new), services/craig-cases/src/main.rs, services/craig-placement/src/config.rs (new), services/craig-placement/src/main.rs

  1. Create config struct per service with jurisdiction-specific parameters

  2. Load from rulesets/{jurisdiction}/{service}.toml at startup

  3. Inject as axum::Extension in main.rs

  4. Update handlers to extract config from Extension instead of hard-coded values

  5. Create default config files for Georgia jurisdiction

Step 5: Refactor store modules to entity-based organization

Files: services/craig-cases/src/store/

  1. Current flat structure (8 modules + models) is functional but wide

  2. Group related entities: store/cases/mod.rs (cases + case_plans + case_plan_tasks), store/referrals/mod.rs (referrals + allegations), store/contacts/mod.rs (contacts + attachments)

  3. Keep models.rs as shared types module

  4. Update all imports across service

  5. Verify all tests pass

Step 6: Introduce newtype domain IDs

Files: crates/craig-common/src/id.rs (new), crates/craig-common/src/lib.rs, all service store and API modules

  1. Create id.rs with define_id! macro generating: CaseId, PersonId, ReferralId, InvestigationId, PlacementId, FosterHomeId, KinshipId, CourtOrderId, ContactId, AllegationId, CasePlanId, CasePlanTaskId, AttachmentId

  2. Derive sqlx::Type (transparent), serde::Serialize/Deserialize

  3. Update craig-cases store functions to accept/return typed IDs

  4. Update craig-placement store functions

  5. Update all API handlers to use Path(id): Path<CaseId> instead of Path(id): Path<Uuid>

  6. Update test builders and fixtures

  7. Verify all tests still pass

This is the largest step — can be done incrementally per service.

Step 7: Simplify handler boilerplate

Files: All api/*.rs files in craig-cases, craig-placement, craig-exchange, craig-financial, craig-reporting

  1. Replace inline pagination math with PageRequest extraction

  2. Replace manual sqlx error mapping with ? operator (after Step 3)

  3. Replace tuple destructuring where merged count/list is available (after Step 2)

  4. Verify handler line count reduces by ~60% per list endpoint

  5. Run integration tests to verify behavior unchanged

Step 8: Full validation

  1. cargo fmt --check --all

  2. cargo clippy --workspace — -D warnings

  3. cargo nextest run --workspace --profile ci — all tests pass

  4. cargo xtask e2e — E2E tests pass

  5. cargo xtask validate — full pre-push validation

  6. Verify: no duplicated WHERE clauses (grep for identical SQL across count/list pairs)

  7. Verify: no raw Uuid in store function signatures (replaced by typed IDs)

  8. Verify: no inline pagination math in handlers (grep for saturating_sub in api/ files)

Files Touched

File Change

crates/craig-common/src/pagination.rs

New or expanded: PageRequest helper with clamping and overflow protection

crates/craig-common/src/error.rs

Add From<sqlx::Error> for ApiError

crates/craig-common/src/id.rs

New: define_id! macro and 13 newtype ID definitions

crates/craig-common/src/lib.rs

Re-export pagination, id modules

services/craig-cases/src/store/cases.rs

Merge count/list, deduplicate WHERE, use typed IDs

services/craig-cases/src/store/persons.rs

Same refactor

services/craig-cases/src/store/referrals.rs

Same refactor

services/craig-cases/src/store/investigations.rs

Same refactor

services/craig-cases/src/store/contacts.rs

Same refactor

services/craig-cases/src/store/court_orders.rs

Same refactor

services/craig-cases/src/config.rs

New: jurisdiction config struct

services/craig-cases/src/main.rs

Load jurisdiction config, inject as Extension

services/craig-cases/src/api/*.rs

Use PageRequest, typed IDs, ? operator

services/craig-placement/src/store/*.rs

Same refactor pattern

services/craig-placement/src/api/*.rs

Same handler simplification

services/craig-exchange/src/api/*.rs

Same handler simplification

services/craig-financial/src/api/*.rs

Same handler simplification

rulesets/georgia/cases.toml

New: jurisdiction config for cases service

rulesets/georgia/placement.toml

New: jurisdiction config for placement service

Verification

  1. cargo fmt --check --all — no formatting issues

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

  3. cargo nextest run --workspace --profile ci — all tests pass

  4. cargo xtask e2e — E2E tests pass

  5. cargo xtask validate — full pre-push validation passes

  6. Compile-time: verify that passing a PersonId where CaseId is expected produces a type error

  7. Grep: no count_cases / count_referrals standalone functions remain (merged into list functions)

  8. Grep: no .clamp(1, MAX_PER_PAGE) in handler files (moved to PageRequest)

  9. Grep: no raw Uuid in store function parameter positions (replaced by typed IDs)

Documentation Updates

  • .claude/docs/coding-conventions.md — document PageRequest pattern, newtype ID convention, jurisdiction config pattern

  • .claude/docs/services.md — update if API signatures change

  • docs/modules/ROOT/pages/developer-guide.adoc — document PageRequest, typed IDs, config loading

  • CHANGELOG.adoc — entry under == Unreleased

Edit this page · latest