Plan: Crate Quality Parity (Backport from Canopy)
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Centralize pagination in craig-common
- Step 2: Deduplicate WHERE clauses in store modules
- Step 3: Add From<sqlx::Error> for ApiError
- Step 4: Jurisdiction config as Extension
- Step 5: Refactor store modules to entity-based organization
- Step 6: Introduce newtype domain IDs
- Step 7: Simplify handler boilerplate
- Step 8: Full validation
- Files Touched
- Verification
- Documentation Updates
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:
-
Pagination math is scattered across 30+ handlers. Every list endpoint manually computes
per_page,limit, andoffset. Canopy centralizes this in aPageRequesthelper withsaturating_sub, clamping, and DoS prevention built in. One change to pagination logic in CRAIG requires updating 30+ locations. -
Duplicated WHERE clauses. Store modules duplicate the same WHERE clause across count and list function pairs. For example,
list_cases_pagedandcount_casesincraig-cases/src/store/cases.rshave identical WHERE logic copy-pasted. A filter change requires updating both functions in lockstep. -
No
From<sqlx::Error> for ApiError. Handlers must manually map database errors. Canopy implements this conversion, enabling clean?operator usage. -
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 fromrulesets/{jurisdiction}/at startup. -
Flat module organization.
craig-cases/src/store/mod.rsexports 8 flat modules. Canopy uses entity-based modules where each domain concept (persons, addresses, income) is cleanly separated. -
No newtype IDs. Both projects use raw
Uuidfor all identifiers. ACaseIdand aPersonIdare 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:
-
PageRequesthelper incraig-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 ApiErrorimpl incraig-common -
Jurisdiction config loading as
Extensionpattern -
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
-
Implement
PageRequeststruct withoffset(),limit(),clamped_per_page()methods -
Add
MAX_PER_PAGEconstant (500) -
Add
Deserializederive for direct use as Axum query parameter -
Add comprehensive tests: page 0 saturates, per_page clamped, overflow protection
-
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
-
For each count/list function pair, merge into a single function returning
(Vec<T>, i64) -
Extract shared WHERE clause into a builder function or constant
-
Validate that sort column/direction validation still applies
-
Update all callers (handlers) to destructure the tuple return
-
Delete orphaned count-only functions
Step 3: Add From<sqlx::Error> for ApiError
Files: crates/craig-common/src/error.rs
-
Add implementation:
impl From<sqlx::Error> for ApiError { fn from(err: sqlx::Error) -> Self { tracing::error!("database error: {err}"); ApiError::Internal("database error".into()) } } -
Update handlers that manually map sqlx errors to use
?instead -
Verify no information leakage (error detail is generic, real error is logged)
-
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
-
Create config struct per service with jurisdiction-specific parameters
-
Load from
rulesets/{jurisdiction}/{service}.tomlat startup -
Inject as
axum::Extensionin main.rs -
Update handlers to extract config from Extension instead of hard-coded values
-
Create default config files for Georgia jurisdiction
Step 5: Refactor store modules to entity-based organization
Files: services/craig-cases/src/store/
-
Current flat structure (8 modules + models) is functional but wide
-
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) -
Keep
models.rsas shared types module -
Update all imports across service
-
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
-
Create
id.rswithdefine_id!macro generating:CaseId,PersonId,ReferralId,InvestigationId,PlacementId,FosterHomeId,KinshipId,CourtOrderId,ContactId,AllegationId,CasePlanId,CasePlanTaskId,AttachmentId -
Derive
sqlx::Type(transparent),serde::Serialize/Deserialize -
Update
craig-casesstore functions to accept/return typed IDs -
Update
craig-placementstore functions -
Update all API handlers to use
Path(id): Path<CaseId>instead ofPath(id): Path<Uuid> -
Update test builders and fixtures
-
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
-
Replace inline pagination math with
PageRequestextraction -
Replace manual sqlx error mapping with
?operator (after Step 3) -
Replace tuple destructuring where merged count/list is available (after Step 2)
-
Verify handler line count reduces by ~60% per list endpoint
-
Run integration tests to verify behavior unchanged
Step 8: Full validation
-
cargo fmt --check --all -
cargo clippy --workspace — -D warnings -
cargo nextest run --workspace --profile ci— all tests pass -
cargo xtask e2e— E2E tests pass -
cargo xtask validate— full pre-push validation -
Verify: no duplicated WHERE clauses (grep for identical SQL across count/list pairs)
-
Verify: no raw
Uuidin store function signatures (replaced by typed IDs) -
Verify: no inline pagination math in handlers (grep for
saturating_subin api/ files)
Files Touched
| File | Change |
|---|---|
|
New or expanded: PageRequest helper with clamping and overflow protection |
|
Add From<sqlx::Error> for ApiError |
|
New: define_id! macro and 13 newtype ID definitions |
|
Re-export pagination, id modules |
|
Merge count/list, deduplicate WHERE, use typed IDs |
|
Same refactor |
|
Same refactor |
|
Same refactor |
|
Same refactor |
|
Same refactor |
|
New: jurisdiction config struct |
|
Load jurisdiction config, inject as Extension |
|
Use PageRequest, typed IDs, |
|
Same refactor pattern |
|
Same handler simplification |
|
Same handler simplification |
|
Same handler simplification |
|
New: jurisdiction config for cases service |
|
New: jurisdiction config for placement service |
Verification
-
cargo fmt --check --all— no formatting issues -
cargo clippy --workspace — -D warnings— zero warnings -
cargo nextest run --workspace --profile ci— all tests pass -
cargo xtask e2e— E2E tests pass -
cargo xtask validate— full pre-push validation passes -
Compile-time: verify that passing a
PersonIdwhereCaseIdis expected produces a type error -
Grep: no
count_cases/count_referralsstandalone functions remain (merged into list functions) -
Grep: no
.clamp(1, MAX_PER_PAGE)in handler files (moved to PageRequest) -
Grep: no raw
Uuidin 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