Implementation Guide
On this page
- Shared Architecture
- Object Storage
- Phase 1 — Foundation
- Phase 2 — Rules and Policy Engine
- Phase 3 — Case Management Service
- Phase 4 — Placement and Foster Care Service
- Phase 5 — Data Exchange Service
- Hardening — Seed Data, Tests & Documentation
- Phase 6 — Financial and Claims Service
- Phase 7 — Reporting and Data Quality Service
- Phase 8 — Security and Compliance Service
- Phase 9 — CLI Client (
craig) - Phase 10 — Caseworker Web UI
- Phase 11 — Constituent and Provider Portals
- Phase 12 — Public Intake Service
- Phase 13 — Mobile & Offline-Capable Client
- Phase Dependencies
This guide provides the technical specification for implementing each phase of the CRAIG platform. It is the companion to the Roadmap, which tracks progress at a strategic level. Each section below contains enough detail — database schemas, API endpoints, event flows, and verification steps — for any developer to implement the phase independently.
Shared Architecture
Every CRAIG service follows the same structural pattern. Understanding this pattern once makes each subsequent phase straightforward.
Service Pattern
Each service is a standalone Axum binary (services/craig-<name>/) that:
-
Loads configuration from environment variables (
CRAIG_<SERVICE>__*) -
Connects to its own PostgreSQL database (
craig_<service>) -
Connects to the shared RabbitMQ message bus (
craig.eventstopic exchange) -
Validates Keycloak OIDC tokens via the
craig-authmiddleware -
Exposes a versioned REST API under
/v1/<service>/… -
Exposes an unauthenticated health check at
GET /healthz
Shared Crates
| Crate | Purpose |
|---|---|
|
Configuration loading, |
|
Keycloak OIDC discovery, JWKS caching, Bearer token validation middleware, role-based access helpers |
|
|
|
RabbitMQ connection via |
|
Axum server builder with standard middleware stack (CORS, compression, tracing, auth), health endpoint |
|
Object storage abstraction wrapping the Apache |
|
Shared domain enums (strum-derived), FIPS codes for all US states and counties, AFCARS/NCANDS field translations, and validation functions. Used by craig-seed for deterministic test data and available to all services for enum-based validation. |
Sort & Search Pattern
All paginated list endpoints support server-side sorting and text search via optional query parameters: search, sort_by, sort_dir.
Sort safety: Dynamic ORDER BY columns are validated through a whitelist function that returns hardcoded string literals, preventing SQL injection:
fn validated_sort_column(sort_by: Option<&str>) -> &str {
match sort_by {
Some("case_number") => "case_number",
Some("status") => "status",
_ => "created_at", // safe default
}
}
Search: Parameterized ILIKE clauses search across relevant text columns:
AND ($3::TEXT IS NULL
OR case_number ILIKE '%' || $3 || '%'
OR assigned_worker ILIKE '%' || $3 || '%')
The BFF (craig-web) passes search, sort_by, and sort_dir as query parameters to backend API calls and forwards them to Askama templates for rendering sortable headers and preserving state across pagination links via the extra_params() helper.
Standard Event Envelope
All messages on the RabbitMQ craig.events topic exchange use a common JSON envelope:
{
"id": "01942a3b-...",
"timestamp": "2025-03-15T14:30:00Z",
"source_service": "craig-cases",
"event_type": "case.created",
"payload": { ... }
}
The event_type field doubles as the AMQP routing key, enabling selective subscription.
Two routing keys are library-emitted rather than service-owned: every engine-hosting
service stages authz.cache_miss (source-attested policy absence, acting-worker
attribution: { ruleset_name, lookup_jurisdiction, lookup_resource_type, sub, is_service,
service_id, request_id }) and authz.cache_refreshed
({ ruleset_name, trigger: rmq_event|ttl_refresh|boot|warmup|lazy_load, old_version,
new_version }; new_version: null = upstream eviction) through the craig-authz engine’s
injected OutboxAuditSink (ADR-050 / #908) — source_service names the emitter, so these
appear under every service, not in any one Publishes table below.
Standard Roles
Services enforce role-based access using Keycloak realm roles:
| Role | Access Level |
|---|---|
|
Full system administration |
|
Unit/district case oversight, approval workflows |
|
Assigned cases, standard operations |
|
IV-E eligibility determinations, claiming |
|
Interstate compact requests and home studies |
|
View-only access across all modules |
|
The ADR-054 office-authority axis for subsidy approvals (office principals also hold a base operational role) |
DevStack
All infrastructure runs via Docker Compose for local development:
| Service | Image | Port |
|---|---|---|
PostgreSQL 18 |
|
5432 |
RabbitMQ 4.2 |
|
5672 / 15672 |
Keycloak 26.5 |
|
8180 |
Garage |
|
3900 / 3903 |
Each CRAIG service gets its own database (craig_rules, craig_cases, etc.) created by devstack/postgres/init.sql.
CI/CD
The GitLab CI pipeline (.gitlab-ci.yml) runs four stages — scan → promote → deploy →
triage. The full functional test battery (validate, integration, e2e, perf, security
regression) runs in the pre-push hook on every developer machine, not in CI; CI carries
the security/docs/lint gates plus a devstack-free ci-tests validate subset, image
promotion, the docs site, and the scheduled pentest/perf/cluster jobs. The
deployment guide's CI/CD Pipeline section and
troubleshooting's "Pipeline stages" entry carry the current
job-by-job detail; this section’s original five-stage design (check/build/integration/
e2e/deploy) was retired when the per-push battery moved local, and its job-level dind
variable overrides (KC_HOSTNAME, OIDC_ISSUER, WEB_EXTERNAL_URL) left with it — the
scheduled pentest/perf jobs run raw docker compose up against the compose defaults.
Horizontal Scalability
All CRAIG services are designed to scale horizontally behind a load balancer with no single-instance assumptions.
Stateless Request Handling
Services validate Keycloak JWTs locally (JWKS cached and auto-refreshed) and hold no server-side session state. Any instance can handle any request.
Event Subscription Patterns
The craig-mq crate provides two subscription methods for different scaling needs:
| Method | Queue Type | Use Case |
|---|---|---|
|
Durable, shared |
Competing consumers — only one instance processes each message. Use for event handlers that mutate the database. |
|
Exclusive, auto-delete |
Fan-out to all instances — every instance receives every matching message. Use for cache invalidation and local state updates. |
Cache Invalidation Pattern
Services with in-memory caches (e.g., compiled rule sets in craig-rules) must publish an invalidation event after any mutation so that peer instances converge on the database state:
-
After a successful write + local conditional cache apply, stage an invalidation event (e.g.,
rules.cache_invalidated) carrying the originatinginstance_id -
Each instance subscribes on a unique exclusive queue (
format!("{service}.cache.{uuid}")) so all instances receive the event -
On receipt, each PEER instance runs an identity-aware reconcile pass (#1188: a per-name conditional apply on craig-rules' trigger-enforced
(id, revision)token — never a wholesale reload, which could overwrite a concurrent local mutation with staler state); a probe failure fails the delivery so the MQ layer redelivers -
The originating instance skips its own event (its cache was already updated by the API handler) — the payload’s
instance_idis the discriminator -
Since #1216 the event staging IS transactional with the rule-set write (event and mutation commit or vanish together); craig-rules also runs a periodic reconcile sweep (
CRAIG_RULES__DECISION_REFRESH_SECONDS) bounding DELIVERY-side event loss to ≤ ~2×interval + pass duration under a healthy DB
Future services with in-memory caches should follow this same pattern.
Testing Infrastructure
Status: Completed
Comprehensive integration test harness for all CRAIG services, inspired by OpenStack Tempest’s design principles adapted for Rust. Tests interact exclusively through public REST APIs (black-box), use Keycloak bearer tokens for authentication, and verify RabbitMQ event publication.
Design Principles
| Principle | Description |
|---|---|
Public API only |
Integration tests interact through REST endpoints — never touch PostgreSQL directly. Validates the same contract real consumers use. |
Credential isolation |
Pre-configured Keycloak test users with different role combinations cover all access patterns. Tests never modify identity data. |
Typed service clients |
Per-service HTTP clients ( |
Cleanup stack |
LIFO teardown — register cleanup callbacks as resources are created, execute in reverse order on test completion. |
Self-skipping tests |
Integration tests check devstack availability at runtime. If unreachable, tests skip gracefully. |
Unique resource names |
UUID v7 suffixes on all test-created resources enable safe parallel execution. |
Crate: craig-test-lib
| Property | Value |
|---|---|
Location |
|
Type |
Library (dev-dependency for services) |
Dependencies |
|
crates/craig-test-lib/
├── Cargo.toml
└── src/
├── lib.rs (re-exports, devstack_available() guard)
├── config.rs (TestConfig: URLs and credentials from env vars)
├── token.rs (KeycloakTokenProvider: ROPC grant + token caching)
├── client.rs (ServiceClient: generic HTTP client with auth)
├── clients/
│ ├── mod.rs
│ ├── rules.rs (RulesClient: rule set CRUD, evaluate, evaluations)
│ ├── cases.rs (CasesClient: persons, referrals, investigations, cases, plans, contacts, court orders)
│ ├── placement.rs (PlacementClient: foster homes, placements, kinship, matching)
│ ├── exchange.rs (ExchangeClient: partners, agreements, transactions, ICPC)
│ ├── financial.rs (FinancialClient: payments, adjustments, rates, claims)
│ └── reporting.rs (ReportingClient: quality issues, AFCARS, NCANDS, metrics)
├── harness.rs (TestHarness: fixture with cleanup stack + client factories)
├── events.rs (EventCollector: RabbitMQ temporary queue for event verification)
└── builders.rs (Fluent test data builders with valid defaults)
TestConfig — Loaded from CRAIG_TEST__* env vars with devstack defaults (http://localhost:8180, http://localhost:8001, etc.).
KeycloakTokenProvider — Obtains JWT tokens via Resource Owner Password Credentials grant against the craig-api Keycloak client.
Caches tokens per user; re-fetches within 30 seconds of expiry.
ServiceClient — Generic HTTP client: get(), post(), put(), delete() with bearer token injection.
Returns ApiResponse<T> carrying both HTTP status and optional deserialized body (enables negative testing).
TestHarness — Fixture struct holding TestConfig, KeycloakTokenProvider, and a cleanup stack.
Factory methods produce authenticated per-service clients for each test user.
EventCollector — Creates a temporary exclusive auto-delete RabbitMQ queue bound to the craig.events topic exchange.
Collects events and provides wait_for_event() and assertion helpers.
Test Users
| User | Password | Roles | Test Purpose |
|---|---|---|---|
|
|
admin |
Rule set CRUD, admin-only endpoints |
|
|
caseworker, supervisor |
Supervisor actions, case plan approval |
|
|
caseworker, eligibility_worker |
Standard caseworker, eligibility-specific |
Integration Test Plan
craig-rules (~46 tests)
| File | Count | Description |
|---|---|---|
|
2 |
Healthcheck (unauthenticated), response shape |
|
8 |
No token → 401, invalid token → 401, role-based access for list/create/delete/evaluations |
|
14 |
CRUD, pagination, duplicate name → 409, invalid JDM → 400, soft-delete, boundary cases |
|
8 |
Valid/invalid input, audit trail, context tracking, post-update evaluation, edge cases |
|
6 |
List with role guards, filter by context_type/rule_set_name/context_id, pagination |
|
4 |
Import valid/invalid JDM, export, roundtrip |
|
4 |
|
craig-cases (~62 tests)
| File | Count | Description |
|---|---|---|
|
1 |
Healthcheck |
|
4 |
Authentication and authorization negative cases |
|
8 |
CRUD, search by name/DOB/SSN, pagination, 404 |
|
8 |
CRUD, allegations, filters, validation, 404 |
|
8 |
CRUD, status transitions (valid/invalid), filters, 404 |
|
17 |
CRUD, status transitions, household, filters |
|
8 |
CRUD, approval (supervisor vs caseworker), tasks, transitions |
|
3 |
Create, list, pagination |
|
3 |
Create, list, pagination |
|
6 |
Verify 6 domain events (referral, intake, case CRUD, plan) |
|
3 |
Full lifecycle, cross-service safety assessment, case closure |
craig-placement (~34 tests)
| File | Count | Description |
|---|---|---|
|
1 |
Healthcheck |
|
4 |
Authentication and role-based access (caseworker vs supervisor) |
|
10 |
CRUD, license transitions (valid/invalid), training, capacity/county/status filters |
|
8 |
CRUD, status transitions, placement history, case_id filter |
|
4 |
Create, list, evaluate, filter |
|
3 |
Search by child, capacity filtering, needs-based matching |
|
4 |
Verify 4 domain events (placement created/ended/requested, license expiring) |
Unit Test Additions (~33 tests)
| Crate | Count | Tests |
|---|---|---|
|
14 |
|
|
7 |
|
|
5 |
|
|
4 |
Transition edge cases (same-state, backwards) |
|
3 |
Transition edge cases (same-state, skip-state) |
Actual Test Counts
| Category | Count |
|---|---|
Ruleset evaluation (GA + TX) |
67 |
|
18 |
|
8 |
|
5 |
|
16 |
|
12 |
|
7 |
|
43 |
|
64 |
|
30 |
|
11 |
|
26 |
|
11 |
|
22 |
|
10 |
|
25 |
|
10 |
|
19 |
|
21 |
|
28 |
|
43 |
|
1 (ignored) |
Total |
497 |
Verification
-
cargo fmt --check --all— clean -
cargo clippy --workspace --locked — -D warnings— clean -
cargo test --workspace(devstack stopped) — unit + ruleset tests pass, integration tests self-skip, 1 ignored doc test -
Start devstack (
cargo xtask dev start) and all services -
cargo test --workspace— all 497 tests pass (496 + 1 ignored doc test)
Performance Testing
Status: Completed (core program) — the k6 runner (cargo xtask perf), the tests/k6/
script tree, all four profiles, and the #1134 class thresholds shipped; residual scoped
items (compose perf profile, deep scenario coverage, profiling tooling) stay open on the
Roadmap checklist. See Testing Reference (CRAIG) § Performance Testing for the
as-built state. The spec below is the original design record.
Automated performance baselines and regression detection for all CRAIG services using Grafana k6. Rather than building a custom framework (e.g. OpenStack Rally), CRAIG uses industry-standard tooling that integrates directly into CI and requires no additional infrastructure.
Why k6
| Factor | Rationale |
|---|---|
CI-native |
CLI tool with exit-code-based threshold assertions — a failing threshold fails the CI job, no dashboard required. |
Docker-ready |
Official |
JavaScript scripts |
Low barrier for contributors; test scripts are readable, version-controlled, and shareable. |
Built-in metrics |
HTTP request duration (p50/p95/p99), iteration rate, error rate, data sent/received — no external collectors needed. |
Extensible |
xk6 extensions available for RabbitMQ, PostgreSQL, and custom protocols if needed later. |
Directory Structure
perf/
├── lib/
│ ├── auth.js (Keycloak ROPC token acquisition, caching, refresh)
│ ├── config.js (base URLs, thresholds, VU counts from env vars)
│ └── helpers.js (response assertion helpers, UUID generation)
├── rules/
│ ├── smoke.js (1-2 VUs, all endpoints, verify correctness)
│ ├── load.js (target concurrency, 5-minute sustained)
│ └── stress.js (ramp to breaking point, measure degradation)
├── cases/
│ ├── smoke.js
│ ├── load.js
│ ├── stress.js
│ └── workflow.js (full lifecycle: referral → investigation → case → plan)
├── placement/
│ ├── smoke.js
│ ├── load.js
│ └── stress.js
├── cross-service/
│ ├── safety-assessment.js (cases → rules engine round-trip)
│ └── intake-to-placement.js (full pipeline: referral → case → placement)
└── soak/
└── moderate-load.js (30-minute sustained test across all services)
Shared Auth Module (perf/lib/auth.js)
All k6 scripts authenticate against Keycloak using the same ROPC grant as the integration tests:
import http from 'k6/http';
const tokenCache = {};
export function getToken(user, password) {
const cacheKey = `${user}:${password}`;
if (tokenCache[cacheKey] && tokenCache[cacheKey].expires > Date.now()) {
return tokenCache[cacheKey].token;
}
const resp = http.post(`${__ENV.OIDC_INTERNAL_URL}/realms/craig/protocol/openid-connect/token`, {
grant_type: 'password',
client_id: 'craig-api',
username: user,
password: password,
});
const body = JSON.parse(resp.body);
tokenCache[cacheKey] = {
token: body.access_token,
expires: Date.now() + (body.expires_in - 30) * 1000,
};
return body.access_token;
}
export function authHeaders(user, password) {
return { headers: { Authorization: `Bearer ${getToken(user, password)}` } };
}
Test Scenarios
Per-Service Tests
| Service | Script | Scenarios |
|---|---|---|
craig-rules |
|
Rule set list (paginated), single rule set GET, rule evaluation (cache hit), rule evaluation (cache miss after create), CRUD cycle. Measures cache warm-up impact on p95. |
craig-cases |
|
Person search by name/DOB/SSN, referral creation, investigation status transitions, case list with county/worker filters, case plan approval workflow, contact and court order creation. Measures pagination performance at page depths 1, 10, 100. |
craig-placement |
|
Foster home search (by county, capacity, license status), placement creation and lifecycle transitions, kinship option creation with evaluation, matching endpoint with varying filter combinations. Measures query performance across filter cardinalities. |
All services |
|
20 VUs per service, 30-minute duration. Monitors for memory leaks (response time drift), connection pool exhaustion (sudden error spikes), and Keycloak token refresh under load. |
Cross-Service Workflow Tests
| Script | Workflow |
|---|---|
|
Create person → create referral → create investigation → submit safety assessment (craig-cases calls craig-rules synchronously) → verify evaluation result. Measures the full HTTP round-trip latency of the cross-service call. Target: p95 < 500ms. |
|
Full pipeline: create person → referral → investigation (with safety assessment) → open case → add household → create foster home → run matching → create placement. Measures end-to-end latency for the critical path. Target: p95 < 2000ms for the complete pipeline. |
Performance Thresholds
Thresholds are defined per scenario and enforced by k6’s --thresholds mechanism — exceeding any threshold causes the k6 process to exit non-zero, failing the CI job.
| Endpoint Category | p95 Latency | p99 Latency | Error Rate |
|---|---|---|---|
Single-entity GET (by ID) |
< 50ms |
< 100ms |
< 0.1% |
List/search (paginated) |
< 200ms |
< 500ms |
< 0.1% |
Create/update (single entity) |
< 100ms |
< 250ms |
< 0.1% |
Rule evaluation (cache hit) |
< 100ms |
< 200ms |
< 0.1% |
Cross-service workflow (safety assessment) |
< 500ms |
< 1000ms |
< 0.1% |
Full lifecycle pipeline |
< 2000ms |
< 3000ms |
< 0.1% |
| These thresholds assume the devstack running on a single machine. Production thresholds will be tighter once deployed to dedicated infrastructure with properly sized connection pools. |
Example k6 Threshold Configuration
export const options = {
stages: [
{ duration: '30s', target: 20 }, // ramp up
{ duration: '5m', target: 20 }, // steady state
{ duration: '30s', target: 0 }, // ramp down
],
thresholds: {
'http_req_duration{endpoint:get_case}': ['p(95)<50', 'p(99)<100'],
'http_req_duration{endpoint:list_cases}': ['p(95)<200', 'p(99)<500'],
'http_req_duration{endpoint:create_case}': ['p(95)<100', 'p(99)<250'],
'http_req_duration{endpoint:evaluate}': ['p(95)<100', 'p(99)<200'],
'http_req_failed': ['rate<0.001'],
},
};
Profiling and Optimization
Performance testing reveals what is slow; profiling reveals why.
| Tool | Purpose | Integration |
|---|---|---|
Async runtime introspection |
Add |
|
Micro-benchmarks |
|
|
|
HTTP connection pool utilization |
Log pool statistics (idle connections, active connections) at |
PostgreSQL |
Query plan analysis |
Run against slow queries identified by load tests. Add targeted indexes. Document query plans for complex joins (e.g., household membership, placement matching). |
Connection Pool Tuning Methodology
-
Run the
soak/moderate-load.jstest for 30 minutes -
Monitor PostgreSQL connection count:
SELECT count(*) FROM pg_stat_activity WHERE datname LIKE 'craig_%' -
Monitor
reqwestpool behavior via debug logs -
Adjust
db_max_connectionsandpool_max_idle_per_hostbased on peak utilization + 20% headroom -
Re-run soak test to verify no connection exhaustion
-
Document optimal settings per deployment size (small: single instance, medium: 2-3 instances, large: 5+ instances)
CI/CD Integration
Two manual CI jobs run k6 against the devstack after the integration test job:
perf-smoke:
stage: integration
image: grafana/k6:latest
services:
- docker:27-dind
variables:
OIDC_INTERNAL_URL: "http://docker:8180"
RULES_URL: "http://docker:8001"
CASES_URL: "http://docker:8002"
PLACEMENT_URL: "http://docker:8003"
script:
# Boot devstack (reuse integration-test's approach)
- k6 run --quiet perf/rules/smoke.js
- k6 run --quiet perf/cases/smoke.js
- k6 run --quiet perf/placement/smoke.js
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
perf-load:
stage: integration
image: grafana/k6:latest
services:
- docker:27-dind
variables:
OIDC_INTERNAL_URL: "http://docker:8180"
RULES_URL: "http://docker:8001"
CASES_URL: "http://docker:8002"
PLACEMENT_URL: "http://docker:8003"
script:
- k6 run --out json=results/rules-load.json perf/rules/load.js
- k6 run --out json=results/cases-load.json perf/cases/load.js
- k6 run --out json=results/placement-load.js perf/placement/load.js
- k6 run --out json=results/cross-service.json perf/cross-service/safety-assessment.js
artifacts:
paths:
- results/
expire_in: 30 days
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
timeout: 30m
The perf-smoke and perf-load jobs will need the same devstack boot sequence as the integration-test job (docker compose with dind).
The YAML above is simplified — the full implementation will factor the devstack boot into a shared script or before_script.
|
Verification
-
Install k6 locally:
brew install k6/choco install k6/docker pull grafana/k6 -
Start devstack:
docker compose up -d --build -
Run smoke tests:
k6 run perf/rules/smoke.js— all checks pass, no errors -
Run load tests:
k6 run perf/cases/load.js— all thresholds met -
Run soak test:
k6 run perf/soak/moderate-load.js— no response time drift over 30 minutes -
Run cross-service test:
k6 run perf/cross-service/safety-assessment.js— p95 < 500ms -
Review k6 summary output — all thresholds green, error rate < 0.1%
-
If any threshold is red, profile with
tokio-consoleandEXPLAIN ANALYZE, optimize, re-run
Object Storage
Status: Completed
Non-machine-readable files — court orders, medical records, birth certificates, ICPC 100A forms, home study photos, generated AFCARS/NCANDS flat files — are stored in an S3-compatible object store rather than in PostgreSQL.
The relational database stores only metadata and an object_key (the path within the object store bucket).
This keeps the database lean, enables CDN-friendly serving, and avoids PostgreSQL large-object management overhead.
Design Decisions
-
Crate:
object_store(Apache Arrow) — production-proven (InfluxDB IOx, DataFusion, Delta Lake), minimal dependencies, unified async API across backends -
NOT RustFS: RustFS is a standalone S3-compatible server (like Garage), not a client library. For production deployments needing a self-hosted S3-compatible server, Garage or RustFS can serve as the backend — the
object_storecrate talks to any of them via the S3 protocol. -
Dev backend: Garage (S3-compatible, written in Rust, AGPL-3.0) for devstack;
LocalFileSystemfor unit tests -
Production backend: Any S3-compatible service — AWS S3, Garage, Backblaze B2, Wasabi, Google Cloud Storage, Azure Blob Storage
-
Bucket layout: One bucket per deployment (
craig-site), prefixed by service and entity type:cases/court-orders/{case_id}/{object_id}.pdf,exchange/icpc/{request_id}/{attachment_id}.pdf,reporting/afcars/{submission_id}.dat
Shared Crate: craig-store
Location: crates/craig-store/
/// Re-exports and thin wrapper around `object_store`.
/// All services that need file storage depend on this crate.
pub struct ObjectStoreConfig {
pub backend: StoreBackend, // local | s3
pub bucket: String, // e.g. "craig-dev"
pub local_root: String, // only for local backend
pub s3_endpoint: String, // only for s3 backend (Garage, etc.)
pub s3_region: String,
pub s3_access_key: String,
pub s3_secret_key: String,
pub max_upload_bytes: usize, // e.g. 50 * 1024 * 1024
}
pub enum StoreBackend { Local, S3 }
pub struct Store { /* Arc<dyn ObjectStore> */ }
impl Store {
pub fn from_config(config: &ObjectStoreConfig) -> Result<Self, StoreError>;
/// Upload bytes to the given path.
pub async fn put(&self, path: &str, data: Bytes) -> Result<(), StoreError>;
/// Download bytes by path. Returns StoreError::NotFound if missing.
pub async fn get(&self, path: &str) -> Result<Bytes, StoreError>;
/// Delete an object at the given path.
pub async fn delete(&self, path: &str) -> Result<(), StoreError>;
/// List object paths under a prefix.
pub async fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
/// Maximum allowed upload size in bytes.
pub fn max_upload_bytes(&self) -> usize;
}
Configuration
Each service that stores files loads object store settings from environment variables:
CRAIG_STORE__BACKEND=s3 # "local" for dev, "s3" for production
CRAIG_STORE__BUCKET=craig-dev
CRAIG_STORE__LOCAL_ROOT=./data/objects
CRAIG_STORE__S3_ENDPOINT=http://localhost:3900 # Garage in devstack
CRAIG_STORE__S3_REGION=garage
CRAIG_STORE__S3_ACCESS_KEY=GKdeadbeefdeadbeefdeadbeef
CRAIG_STORE__S3_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
DevStack: Garage
Garage (S3-compatible, Rust, AGPL-3.0) is defined in docker-compose.yml with config baked into the image via devstack/garage/Dockerfile.
The cargo xtask dev start command initializes Garage after startup using docker compose exec commands against the distroless container:
garage:
build: ./devstack/garage
ports:
- "3900:3900" # S3 API
- "3903:3903" # Admin API
volumes:
- garage-meta:/var/lib/garage/meta
- garage-data:/var/lib/garage/data
environment:
RUST_LOG: garage=info
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 5s
timeout: 3s
retries: 10
The launch scripts create the layout, import a deterministic API key, create the craig-dev bucket, and grant access — all idempotent (skipped if the bucket already exists).
Database Pattern
Services store object metadata in their own tables.
The object_key column replaces the existing file_url / document_url columns — it holds the path within the bucket, not a full URL.
Services construct download URLs at request time (pre-signed for S3, direct path for local).
Example migration for existing tables:
-- Exchange: ICPC attachments (already has file_url → rename to object_key)
ALTER TABLE icpc_attachments RENAME COLUMN file_url TO object_key;
-- Cases: court orders (already has document_url → rename to object_key)
ALTER TABLE court_orders RENAME COLUMN document_url TO object_key;
-- Exchange: data sharing agreements (already has document_url → rename to object_key)
ALTER TABLE data_sharing_agreements RENAME COLUMN document_url TO object_key;
Upload and Download API Pattern
Each service that manages files exposes upload/download endpoints following a standard pattern:
POST /v1/{service}/{entity}/{id}/attachments — multipart upload → store.put() → save metadata row
GET /v1/{service}/{entity}/{id}/attachments — list metadata rows
GET /v1/{service}/{entity}/{id}/attachments/{aid} — store.get() → stream bytes through service
DELETE /v1/{service}/{entity}/{id}/attachments/{aid} — store.delete() → delete metadata row
Upload handler pseudocode:
async fn upload_attachment(
Path((entity_id,)): Path<(Uuid,)>,
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<Attachment>> {
let field = multipart.next_field().await?;
let filename = field.file_name().unwrap_or("upload").to_string();
let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
let bytes = field.bytes().await?;
// Validate: max size (e.g. 50 MB), allowed MIME types
validate_upload(&content_type, bytes.len())?;
let object_id = Uuid::now_v7();
let key = format!("{service}/{entity_type}/{entity_id}/{object_id}/{filename}");
state.store.put(&key.into(), bytes).await?;
// Save metadata to PostgreSQL
let attachment = sqlx::query_as!(...)
.fetch_one(&state.db)
.await?;
Ok(Json(attachment))
}
Services That Store Files
| Service | Entity | File Types |
|---|---|---|
|
Court orders, case plan documents |
PDF, DOCX |
|
ICPC attachments (100A forms, medical records, birth certificates, home study reports), data sharing agreement documents |
PDF, images (JPEG/PNG) |
|
Foster home inspection photos, license documents, training certificates |
PDF, images (JPEG/PNG) |
|
Generated AFCARS/NCANDS flat files |
DAT, CSV |
|
Security review evidence, remediation artifacts |
PDF, DOCX, images |
Upload Validation
All upload endpoints enforce:
-
Max file size: 50 MB (configurable per service via
CRAIG_STORE__MAX_UPLOAD_BYTES) -
Allowed MIME types: Allowlist per entity type (PDF, JPEG, PNG, DOCX, CSV, DAT — no executables)
-
Filename sanitization: Strip path separators and null bytes; limit length to 255 characters
-
Content-type verification: Check magic bytes match the declared MIME type (defense against disguised executables)
Virus/malware scanning (e.g. ClamAV) is recommended for production deployments but is not included in the MVP.
Deployments can add a scanning step by wrapping the Store::put() call or using an S3 event trigger.
|
Phase Dependency
Object Storage is cross-cutting — it can be implemented independently and adopted by services incrementally.
The craig-store crate has no service dependencies (only object_store + config).
Services adopt it by adding a dependency on craig-store, migrating file_url/document_url columns to object_key, and wiring upload/download endpoints.
Recommended adoption order:
-
craig-storecrate + Garage devstack container -
craig-exchange— ICPC attachments (already hasicpc_attachmentstable withfile_url) -
craig-cases— court order documents (already hasdocument_url) -
craig-placement— foster home and license documents (new table) -
craig-reporting— generated AFCARS/NCANDS files (when Phase 7 is built) -
craig-security— evidence documents (when Phase 8 is built)
Web UI and CLI Integration
-
craig-web: File upload forms use
<input type="file">withenctype="multipart/form-data". The BFF proxies the multipart upload to the backend service. Download links use pre-signed URLs (S3) or stream through the BFF (local). -
craig-cli:
craig attachment upload <entity-type> <entity-id> <file-path>andcraig attachment download <entity-type> <entity-id> <attachment-id> [--output <path>]commands. Upload reads the file into memory and POSTs as multipart; download streams to stdout or a file.
Verification
-
Start devstack with
cargo xtask dev start→ verify Garage healthy and bucket created -
Upload a file via API → verify object appears in Garage bucket under the correct prefix
-
Download via API → verify file content matches
-
Delete via API → verify object removed from Garage
-
Switch to local backend → verify files written to
./data/objects/with correct directory structure -
Upload oversized file → verify 413 rejection
-
Upload disallowed MIME type → verify 415 rejection
Phase 1 — Foundation
Status: Completed
Deliverables
| Deliverable | Description |
|---|---|
Workspace root |
|
|
|
|
|
|
|
|
|
|
|
DevStack |
|
CI/CD |
6-stage GitLab CI pipeline (lint, test, build, integration, e2e, deploy) in |
Repository Structure
Cargo.toml rust-toolchain.toml .env.example .gitlab-ci.yml docker-compose.yml .githooks/pre-push devstack/ keycloak/craig-realm.json postgres/init.sql rabbitmq/definitions.json rabbitmq/rabbitmq.conf crates/ craig-common/ (Cargo.toml, src/lib.rs, error.rs, id.rs, pagination.rs, settings.rs, telemetry.rs) craig-auth/ (Cargo.toml, src/lib.rs, claims.rs, jwks.rs, middleware.rs) craig-db/ (Cargo.toml, src/lib.rs) craig-mq/ (Cargo.toml, src/lib.rs, envelope.rs, publisher.rs, subscriber.rs) craig-api/ (Cargo.toml, src/lib.rs) craig-test-lib/ (Cargo.toml, src/lib.rs, client.rs, config.rs, token.rs, harness.rs, clients/)
Phase 2 — Rules and Policy Engine
Status: Completed
Service: craig-rules
| Property | Value |
|---|---|
Binary |
|
Port |
8001 |
Database |
|
Key dependencies |
|
Database Schema
CREATE TABLE rule_sets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
name TEXT NOT NULL UNIQUE,
version TEXT NOT NULL,
description TEXT,
content JSONB NOT NULL, -- JDM rule set (GoRules JSON)
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL
);
CREATE TABLE rule_evaluations (
id UUID PRIMARY KEY DEFAULT uuidv7(),
rule_set_id UUID NOT NULL REFERENCES rule_sets(id),
rule_set_name TEXT NOT NULL,
rule_set_version TEXT NOT NULL,
input JSONB NOT NULL,
output JSONB NOT NULL,
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
evaluated_by TEXT NOT NULL,
context_type TEXT, -- e.g. "eligibility", "safety-assessment"
context_id UUID -- e.g. case ID or child ID
);
CREATE INDEX idx_rule_evaluations_rule_set_id ON rule_evaluations(rule_set_id);
CREATE INDEX idx_rule_evaluations_context ON rule_evaluations(context_type, context_id);
CREATE INDEX idx_rule_evaluations_evaluated_at ON rule_evaluations(evaluated_at);
REST API
| Method | Path | Description | Role |
|---|---|---|---|
|
|
List all rule sets (paginated) |
any |
|
|
Get rule set by ID |
any |
|
|
Create rule set (upload JDM JSON) |
admin |
|
|
Update rule set (creates new version) |
admin |
|
|
Deactivate rule set (soft delete) |
admin |
|
|
Evaluate input against a named rule set |
any |
|
|
List past evaluations (paginated, filterable) |
supervisor+ |
|
|
Import JDM from file |
admin |
|
|
Export JDM as file |
admin |
|
|
Health check |
(unauthenticated) |
RabbitMQ Events
Consumes:
| Routing Key | Action |
|---|---|
|
Evaluate intake screening rule set ( |
|
Evaluate placement matching rule set ( |
(The eligibility.submitted binding was retired by #1054 — no service has ever
emitted it; the IV-E eligibility rule set remains invocable via
POST /v1/rules/evaluate.)
Publishes:
| Routing Key | Payload |
|---|---|
|
|
|
|
zen-engine Integration
-
JDM rule sets are stored as JSONB in
rule_sets.contentand deserialized intozen_engine::model::DecisionContent -
Compiled
Decisionobjects are cached in an in-memoryHashMapand reloaded on rule set update -
Evaluation runs on a dedicated OS thread with a single-threaded tokio runtime because zen-engine’s evaluate future is
!Send(usesRcinternally) -
Every evaluation attempt that resolves a rule set writes to
rule_evaluationsfor audit trail per 45 CFR § 1355.53, disposition-typed since #1048:completedrows carry the output; zen runtime failures and worker loss land asruntime_error; a blownCRAIG_RULESEVAL_TIMEOUT_MSdispatch budget (#784) lands astimeout, converging tolate_completedif the evaluation later finishes; and JS function nodes are interrupt-bounded (#1046: v2{source}nodes atCRAIG_RULESFUNCTION_TIMEOUT_MS, v1 string-content nodes at zen’s hard 500ms) so a runaway script terminates as aruntime_errorrow rather than wedging the eval thread — see ADR-006 §Amendments #1048/#1046
Jurisdiction Configuration
Each service reads a JURISDICTION setting (e.g. CRAIG_RULES__JURISDICTION=georgia) that selects which rule set pack to activate.
Rule sets follow a naming convention: {jurisdiction}-{function} (e.g. georgia-ive-eligibility).
When a domain event arrives, the event handler constructs the rule set name automatically from the configured jurisdiction.
Sample rule set packs ship in rulesets/{jurisdiction}/.
Other jurisdictions create their own directory (e.g. rulesets/texas/, rulesets/navajo-nation/) and import their rule sets via the API.
Sample Rule Sets (Georgia DFCS)
The rulesets/georgia/ directory contains five Georgia DFCS policy-based rule sets built in zen-engine JDM format.
Each rule set is validated by integration tests in services/craig-rules/tests/georgia_ruleset_evaluation.rs and texas_ruleset_evaluation.rs.
| File | Description |
|---|---|
|
Title IV-E eligibility per 42 USC §672 and GA Policy Chapter 9 — 8 decision tables covering age, specified relative, deprivation (5 types), AFDC financial need (≤$1,850 income, ≤$10K resources), citizenship, judicial requirements (CTW 60 days + best interest + reasonable efforts), and final eligibility decision with FFP rate (0.6736) |
|
Safety assessment per GA Policy 19.11 — binary Safe/Unsafe decision based on present danger indicators, impending danger (5 threshold criteria), child vulnerability factors, and caregiver protective capacities |
|
Timeliness monitoring per ASFA and GA state policy — deadline lookup for 9 milestone types (CPS investigation 30d, family assessment 45d, CTW finding 60d, case plan 30d, case plan review 180d, permanency hearing 365d, TPR filing 455d, ICPC home study 84d) with dynamic warn/critical/overdue status using cross-field threshold comparisons |
|
Intake screening per GA Policy Chapter 3 — screen-in/screen-out criteria (maltreatment alleged, Georgia jurisdiction, child identifiable, caregiver maltreater, age <18), response priority assignment (IMMEDIATE/PRIORITY_24HR/STANDARD), and investigation track assignment (CPS_INVESTIGATION 30d / FAMILY_ASSESSMENT 45d) |
|
Placement matching per GA Policy Chapter 11 and 42 USC §671(a)(19)/(a)(31) — level of care determination (basic/therapeutic/residential), placement type preference hierarchy (relative kinship > fictive kin > foster family > residential), sibling joint placement assessment, and proximity/school stability scoring |
Sample Rule Sets (Texas DFPS)
The rulesets/texas/ directory contains five Texas DFPS policy-based rule sets.
Each rule set is validated by integration tests in services/craig-rules/tests/texas_ruleset_evaluation.rs.
Texas-specific differences from Georgia include: three-outcome safety model (SAFE/CONDITIONALLY SAFE/UNSAFE), P1/P2 priority system, extended foster care to age 21, lower 1996 AFDC income thresholds, 14-day adversary hearing, and 12-month mandatory dismissal deadline.
| File | Description |
|---|---|
|
Title IV-E eligibility per 42 USC §672, TFC Chapters 262-263, and DFPS CPS Handbook Section 11000 — 8 decision tables covering age (extended to 21 with participation requirements: secondary/post-secondary/employment 80hrs+/medical), specified relative, deprivation (6 types including abandonment), AFDC financial need (≤$566 TX 1996 levels, ≤$10K resources), citizenship, judicial requirements, and final eligibility with TX FMAP rate (0.6146) |
|
Safety assessment per CPS Handbook Sections 2280-2285 — THREE-outcome model (SAFE/CONDITIONALLY_SAFE/UNSAFE) with present danger, impending danger (4 characteristics: observable, out of control, imminent, severe), child vulnerability (includes non-verbal assessment), caregiver protective capacity across 3 dimensions (cognitive/affective/behavioral), and safety plan determination |
|
Timeliness monitoring per TFC 262-263 and ASFA — deadline lookup for 12 milestone types including Texas-specific: adversary hearing (14d per TFC 262.201), mandatory dismissal deadline (365d per TFC 263.401), alternative response (45d), status hearing (60d), family service plan (45d), medical exam (3d), relative notification (30d), plus federal milestones (permanency 365d, TPR 455d, ICPC 84d) |
|
Statewide Intake screening per TFC Chapter 261 — screen-in/screen-out with P1 (24hr: immediate risk, sexual abuse of child <6, non-mobile infant injury, physical abuse of child <3) and P2 (72hr: all other accepted reports) priority assignment; Investigation vs Alternative Response track (neglect/medical neglect eligible for AR at 45d vs INV at 30d) |
|
Placement matching per CPS Handbook 6100-6500 and 42 USC §671(a)(19)/(a)(31) — four-tier level of care (basic/moderate/specialized/intense), ICWA placement preferences (25 USC 1915), kinship verification, Family First QRTP requirements for intense level, sibling joint placement, and proximity/school stability scoring |
Verification
-
cargo test -p craig-rules— runs 67 integration tests validating all 10 rule sets (5 Georgia + 5 Texas) against zen-engine -
cargo run -p craig-rules— service starts, connects to DB and RabbitMQ -
curl http://localhost:8001/healthz— returns{"status":"ok"} -
Obtain Keycloak token and call
POST /v1/rules/evaluatewith sample input -
Verify
rule_evaluationstable contains audit record
Phase 3 — Case Management Service
Status: Completed
Service: craig-cases
| Property | Value |
|---|---|
Binary |
|
Port |
8002 |
Database |
|
Dependencies |
Phase 1 + Phase 2 |
Key dependencies |
workspace crates |
Repository Structure
services/craig-cases/
Cargo.toml
src/
main.rs
api/
mod.rs
persons.rs
referrals.rs
investigations.rs
cases.rs
case_plans.rs
contacts.rs
court_orders.rs
contact_attachments.rs
store/
mod.rs
models.rs
persons.rs
referrals.rs
investigations.rs
cases.rs
case_plans.rs
contacts.rs
court_orders.rs
contact_attachments.rs
events.rs
transitions.rs
migrations/
20240201000000_create_case_tables.sql
Database Schema
-- Hotline intake / referral
CREATE TABLE referrals (
id UUID PRIMARY KEY DEFAULT uuidv7(),
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reporter_type TEXT NOT NULL, -- mandatory, anonymous, voluntary, professional
reporter_first_name TEXT,
reporter_last_name TEXT,
reporter_phone TEXT,
reporter_relation TEXT,
county TEXT NOT NULL,
priority TEXT NOT NULL, -- immediate, 24-hour, 72-hour
screened_in BOOLEAN NOT NULL DEFAULT true,
screen_out_reason TEXT,
icwa_flag BOOLEAN NOT NULL DEFAULT false,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE allegations (
id UUID PRIMARY KEY DEFAULT uuidv7(),
referral_id UUID NOT NULL REFERENCES referrals(id),
victim_id UUID NOT NULL, -- person ID (child)
perpetrator_id UUID, -- person ID
abuse_type TEXT NOT NULL, -- physical, sexual, neglect, emotional, other
description TEXT,
disposition TEXT -- substantiated, unsubstantiated, inconclusive
);
-- Person registry (children, parents, caregivers, reporters)
CREATE TABLE persons (
id UUID PRIMARY KEY DEFAULT uuidv7(),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
date_of_birth DATE,
gender TEXT,
race TEXT,
ethnicity TEXT,
ssn_last_four TEXT,
icwa_eligible BOOLEAN NOT NULL DEFAULT false,
tribal_affiliation TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Investigation
CREATE TABLE investigations (
id UUID PRIMARY KEY DEFAULT uuidv7(),
referral_id UUID NOT NULL REFERENCES referrals(id),
assigned_worker TEXT NOT NULL,
assignment_area TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open, pending_review, closed
response_due_at TIMESTAMPTZ NOT NULL,
first_contact_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
disposition TEXT, -- substantiated, unsubstantiated
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Safety assessment (completed during investigation)
CREATE TABLE safety_assessments (
id UUID PRIMARY KEY DEFAULT uuidv7(),
investigation_id UUID NOT NULL REFERENCES investigations(id),
threats JSONB NOT NULL DEFAULT '[]',
protective_capacities JSONB NOT NULL DEFAULT '{}',
rule_set_name TEXT NOT NULL,
rule_set_version TEXT NOT NULL,
engine_decision TEXT NOT NULL, -- SAFE, SAFE_WITH_PLAN, UNSAFE
worker_override TEXT, -- null if no override
worker_override_reason TEXT,
assessed_by TEXT NOT NULL,
assessed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Case (opened after investigation substantiates)
CREATE TABLE cases (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_number TEXT NOT NULL UNIQUE,
referral_id UUID REFERENCES referrals(id),
investigation_id UUID REFERENCES investigations(id),
status TEXT NOT NULL DEFAULT 'open', -- open, closed, transferred
stage TEXT NOT NULL DEFAULT 'assessment', -- assessment, ongoing, permanency
county TEXT NOT NULL,
assigned_worker TEXT NOT NULL,
supervisor TEXT,
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
closure_reason TEXT,
icwa_flag BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Household members linked to a case
CREATE TABLE case_household (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_id UUID NOT NULL REFERENCES cases(id),
person_id UUID NOT NULL REFERENCES persons(id),
role TEXT NOT NULL, -- child, parent, caregiver, sibling, other
primary_caregiver BOOLEAN NOT NULL DEFAULT false,
active BOOLEAN NOT NULL DEFAULT true,
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Case plan
CREATE TABLE case_plans (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_id UUID NOT NULL REFERENCES cases(id),
permanency_goal TEXT NOT NULL, -- reunification, adoption, guardianship, APPLA, relative_placement
strengths TEXT,
needs TEXT,
status TEXT NOT NULL DEFAULT 'draft', -- draft, active, completed, superseded
approved_by TEXT,
approved_at TIMESTAMPTZ,
review_due_at TIMESTAMPTZ,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Tasks within a case plan
CREATE TABLE case_plan_tasks (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_plan_id UUID NOT NULL REFERENCES case_plans(id),
description TEXT NOT NULL,
responsible TEXT NOT NULL, -- parent, agency, provider, other
due_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending' -- pending, in_progress, completed
);
-- Contact / visitation log
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_id UUID NOT NULL REFERENCES cases(id),
contact_type TEXT NOT NULL, -- home_visit, office, phone, video, collateral
contact_with TEXT NOT NULL, -- who was contacted (person name or role)
occurred_at TIMESTAMPTZ NOT NULL,
duration_minutes INTEGER,
narrative TEXT,
recorded_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Court orders and legal status
CREATE TABLE court_orders (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_id UUID NOT NULL REFERENCES cases(id),
order_type TEXT NOT NULL, -- removal, shelter_care, adjudication, disposition, review, permanency_hearing
court_name TEXT,
judge TEXT,
order_date DATE NOT NULL,
effective_date DATE,
findings JSONB, -- contrary-to-welfare, reasonable-efforts, etc.
next_hearing_date DATE,
object_key TEXT, -- object store key (renamed from document_url)
recorded_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Contact attachments (uploaded via craig-store)
CREATE TABLE contact_attachments (
id UUID PRIMARY KEY DEFAULT uuidv7(),
contact_id UUID NOT NULL REFERENCES contacts(id),
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
object_key TEXT NOT NULL,
uploaded_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_referrals_county ON referrals(county);
CREATE INDEX idx_investigations_worker ON investigations(assigned_worker);
CREATE INDEX idx_investigations_status ON investigations(status);
CREATE INDEX idx_cases_worker ON cases(assigned_worker);
CREATE INDEX idx_cases_status ON cases(status);
CREATE INDEX idx_case_household_case ON case_household(case_id);
CREATE INDEX idx_case_household_person ON case_household(person_id);
CREATE INDEX idx_contacts_case ON contacts(case_id);
CREATE INDEX idx_court_orders_case ON court_orders(case_id);
REST API
| Method | Path | Description | Role |
|---|---|---|---|
|
|
Create hotline intake/referral |
caseworker+ |
|
|
List referrals (paginated, filterable by county) |
caseworker+ |
|
|
Get referral detail with allegations |
caseworker+ |
|
|
Add allegation to referral |
caseworker+ |
|
|
Open investigation from referral |
caseworker+ |
|
|
List investigations (filterable by worker, status) |
caseworker+ |
|
|
Get investigation detail |
caseworker+ |
|
|
Update investigation status/disposition |
caseworker+ |
|
|
Submit safety assessment (calls Rules Engine) |
caseworker+ |
|
|
Open case from investigation |
caseworker+ |
|
|
List cases (filterable by worker, status, county) |
caseworker+ |
|
|
Get case detail |
caseworker+ |
|
|
Update case (status, assignment, ICWA flag) |
caseworker+ |
|
|
Get household members |
caseworker+ |
|
|
Add household member |
caseworker+ |
|
|
Create case plan |
caseworker+ |
|
|
List case plans |
caseworker+ |
|
|
Update case plan |
caseworker+ |
|
|
Supervisor approval/countersign |
supervisor+ |
|
|
Add task to case plan |
caseworker+ |
|
|
Update task status |
caseworker+ |
|
|
Record contact/visitation |
caseworker+ |
|
|
List contacts |
caseworker+ |
|
|
Record court order |
caseworker+ |
|
|
List court orders |
caseworker+ |
|
|
Upload attachment to contact |
caseworker+ |
|
|
List contact attachments |
caseworker+ |
|
|
Download attachment |
caseworker+ |
|
|
Delete attachment |
caseworker+ |
|
|
Search persons (name, DOB, SSN last-four) |
caseworker+ |
|
|
Create person record |
caseworker+ |
|
|
Get person detail |
caseworker+ |
|
|
Update person record |
caseworker+ |
RabbitMQ Events
Publishes:
| Routing Key | Payload |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Consumes:
| Routing Key | Action |
|---|---|
|
Store safety assessment result, update investigation |
|
Update case record with current placement info |
|
Link eligibility determination to case |
Rules Engine Integration
-
Safety assessment: When a caseworker submits a safety assessment, the service calls
POST /v1/rules/evaluatewith the safety assessment rule set. The engine decision (Safe / Safe with Plan / Unsafe) is stored alongside any worker override. -
Case routing: On
case.referral_created, the Rules Engine can evaluate a complexity classification rule set to suggest assignment priority. -
Relay auth (#1060): every cases→rules evaluate relay (safety-assessment submit, person-match suggestions + their ruleset-metadata fetch, conversion auto-link) forwards the inbound
Authorizationbearer AND the Plan EX-Craig-Actorheader together via oneRelayAuthvalue — so when craig-web mediates, rules scopes theRuleEvaluation × Creategate on the acting WORKER andrule_evaluations.evaluated_bynames the worker, not the relay service. The auto-link consumer is the deliberate exception: it runs on a minted service bearer with no actor by construction, which is why the authz fixture’srule_evaluationservicecreaterow stays (#786; see Rule Set Patterns and Domain Knowledge). The screening-policy fetch does NOT forward the actor — its client is a cross-request TTL cache keyed by ruleset name only, so per-worker credentials must not influence what it caches.
Verification
-
cargo run -p craig-cases— service starts on port 8002 -
Create a referral via
POST /v1/cases/referrals— verify 200 response -
Open investigation → submit safety assessment → verify Rules Engine evaluation recorded
-
Open case → create case plan → verify the plan persists and supervisor approval countersigns it
-
Verify RabbitMQ events published for each operation
Phase 4 — Placement and Foster Care Service
Status: Completed — core foster homes, placements, kinship, and matching, plus the later Phase 4 expansion (IV-E eligibility via the rules engine, educational enrollment, health records, and major-changes monitoring all landed; see the Roadmap Phase 4 checklist)
Service: craig-placement
| Property | Value |
|---|---|
Binary |
|
Port |
8003 |
Database |
|
Dependencies |
Phase 1 + Phase 2 + Phase 3 |
Key dependencies |
workspace crates |
Database Schema
-- Licensed foster home / provider
CREATE TABLE foster_homes (
id UUID PRIMARY KEY DEFAULT uuidv7(),
name TEXT NOT NULL,
address TEXT NOT NULL,
county TEXT NOT NULL,
phone TEXT,
license_number TEXT UNIQUE,
license_status TEXT NOT NULL DEFAULT 'pending', -- pending, active, suspended, revoked, expired
license_type TEXT NOT NULL, -- foster, kinship, therapeutic, group
licensed_at DATE,
license_expires DATE,
max_capacity INTEGER NOT NULL DEFAULT 0,
current_occupancy INTEGER NOT NULL DEFAULT 0,
accepts_ages_min INTEGER DEFAULT 0,
accepts_ages_max INTEGER DEFAULT 18,
accepts_sibling_groups BOOLEAN NOT NULL DEFAULT false,
icwa_compliant BOOLEAN NOT NULL DEFAULT false,
primary_contact TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Training records for foster parents
CREATE TABLE foster_home_training (
id UUID PRIMARY KEY DEFAULT uuidv7(),
foster_home_id UUID NOT NULL REFERENCES foster_homes(id),
training_type TEXT NOT NULL, -- pre-service, in-service, CPR, first_aid, trauma_informed
completed_at DATE NOT NULL,
expires_at DATE,
hours NUMERIC(5,2) NOT NULL,
verified_by TEXT
);
-- Child placement record
CREATE TABLE placements (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_id UUID NOT NULL, -- references craig_cases.cases(id) cross-service
child_id UUID NOT NULL, -- references craig_cases.persons(id) cross-service
foster_home_id UUID REFERENCES foster_homes(id),
placement_type TEXT NOT NULL, -- foster, kinship, therapeutic, residential, trial_home_visit, pre_adoptive
status TEXT NOT NULL DEFAULT 'active', -- active, ended, planned
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
end_reason TEXT, -- reunification, adoption, aging_out, transfer, disruption
removal_date DATE, -- date of removal from home (for AFCARS)
removal_reason TEXT,
ctw_finding BOOLEAN, -- contrary-to-welfare judicial finding made
ctw_finding_date DATE,
ctw_days_from_removal INTEGER, -- computed: days between removal and CTW finding
reasonable_efforts BOOLEAN,
reasonable_efforts_date DATE,
permanency_goal TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Kinship / relative options evaluated during matching
CREATE TABLE kinship_options (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_id UUID NOT NULL,
child_id UUID NOT NULL,
relative_name TEXT NOT NULL,
relationship TEXT NOT NULL,
evaluated BOOLEAN NOT NULL DEFAULT false,
approved BOOLEAN,
rejection_reason TEXT,
evaluated_at TIMESTAMPTZ,
recorded_by TEXT NOT NULL
);
-- Sibling group tracking
CREATE TABLE sibling_placements (
id UUID PRIMARY KEY DEFAULT uuidv7(),
sibling_group UUID NOT NULL, -- shared group ID for siblings
placement_id UUID NOT NULL REFERENCES placements(id),
together BOOLEAN NOT NULL -- true if placed together, false if separated
);
CREATE INDEX idx_placements_case ON placements(case_id);
CREATE INDEX idx_placements_child ON placements(child_id);
CREATE INDEX idx_placements_foster_home ON placements(foster_home_id);
CREATE INDEX idx_placements_status ON placements(status);
CREATE INDEX idx_foster_homes_county ON foster_homes(county);
CREATE INDEX idx_foster_homes_status ON foster_homes(license_status);
CREATE INDEX idx_foster_home_training ON foster_home_training(foster_home_id);
CREATE INDEX idx_kinship_options_case ON kinship_options(case_id);
REST API
| Method | Path | Description | Role |
|---|---|---|---|
|
|
Search foster homes (county, capacity, type, age range) |
caseworker+ |
|
|
Get foster home detail with training records |
caseworker+ |
|
|
Create foster home record |
supervisor+ |
|
|
Update foster home (license status, capacity) |
supervisor+ |
|
|
Record training completion |
caseworker+ |
|
|
Create placement (assigns child to home) |
caseworker+ |
|
|
List placements (filterable by case, child, home, status) |
caseworker+ |
|
|
Get placement detail |
caseworker+ |
|
|
Update placement (end, update findings) |
caseworker+ |
|
|
Placement history timeline for a child |
caseworker+ |
|
|
Record kinship option evaluation |
caseworker+ |
|
|
List kinship options for a case |
caseworker+ |
|
|
Matching search (capacity, needs, ICWA, sibling) |
caseworker+ |
RabbitMQ Events
Publishes:
| Routing Key | Payload |
|---|---|
|
|
|
|
|
|
|
|
The matching endpoint (GET /v1/placement/matching) is a read-only, attribute-based search and
publishes nothing (#837 removed a dead placement.requested emission that reused child_id as a
case_id placeholder). The Rules Engine still binds placement.requested (see its Consumes list) but
currently has no producer; wiring a real producer from a placement mutation — or retiring that consumer —
is a tracked follow-up.
|
Consumes:
| Routing Key | Action |
|---|---|
|
Seed the |
|
Upsert |
Rules Engine Integration
-
License expiration monitoring: Evaluates foster home license and training expiration dates, publishes alerts
-
CTW timeliness: Monitors 60-day deadline for contrary-to-welfare judicial findings from removal date
-
Placement matching: Evaluates child needs against available home capabilities, ICWA requirements, and sibling grouping preferences
Verification
-
Create foster home → verify capacity tracking
-
Create placement → verify
placement.createdevent published (andplacement.activatedwhen created active / onplanned → active) -
Verify CTW timeliness monitoring triggers when days approach 60
-
Verify matching endpoint returns homes filtered by capacity and needs
Phase 5 — Data Exchange Service
Status: Completed
Service: craig-exchange
| Property | Value |
|---|---|
Binary |
|
Port |
8004 |
Database |
|
Dependencies |
Phase 1 + Phase 2 |
Key dependencies |
|
Database Schema
-- Data exchange partner configuration
CREATE TABLE exchange_partners (
id UUID PRIMARY KEY DEFAULT uuidv7(),
partner_name TEXT NOT NULL UNIQUE,
partner_type TEXT NOT NULL, -- cwca, financial, medicaid, can, tanf, child_support, external, court, education, health, tribal
direction TEXT NOT NULL, -- inbound, outbound, bidirectional
endpoint_url TEXT,
auth_type TEXT, -- oauth2, api_key, certificate, none
auth_config JSONB, -- reserved for outbound auth (unimplemented, deferred); stored write-only, never returned in API responses (#757); store a secret reference (not plaintext) when wired
exchange_format TEXT NOT NULL DEFAULT 'json', -- json, xml, flat_file, hl7
active BOOLEAN NOT NULL DEFAULT true,
data_sharing_agreement_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Data sharing agreements (per ACF Technical Bulletin #8)
CREATE TABLE data_sharing_agreements (
id UUID PRIMARY KEY DEFAULT uuidv7(),
partner_id UUID NOT NULL REFERENCES exchange_partners(id),
agreement_title TEXT NOT NULL,
effective_date DATE NOT NULL,
expiration_date DATE,
data_elements JSONB NOT NULL, -- list of data elements shared
legal_authority TEXT, -- CFR section or state statute
status TEXT NOT NULL DEFAULT 'draft', -- draft, active, expired, terminated
object_key TEXT, -- object store key (renamed from document_url)
approved_by TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Exchange transaction log (audit trail)
CREATE TABLE exchange_transactions (
id UUID PRIMARY KEY DEFAULT uuidv7(),
partner_id UUID NOT NULL REFERENCES exchange_partners(id),
direction TEXT NOT NULL, -- inbound, outbound
exchange_type TEXT NOT NULL, -- e.g. "medicaid_eligibility", "court_order", "afcars"
status TEXT NOT NULL DEFAULT 'pending', -- pending, success, failed, retry
request_payload JSONB,
response_payload JSONB,
error_message TEXT,
record_count INTEGER,
initiated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
initiated_by TEXT NOT NULL
);
-- ICPC (Interstate Compact on the Placement of Children) requests
CREATE TABLE icpc_requests (
id UUID PRIMARY KEY,
case_id UUID NOT NULL,
child_id UUID NOT NULL,
direction TEXT NOT NULL, -- outgoing, incoming
sending_state TEXT NOT NULL,
receiving_state TEXT NOT NULL,
request_type TEXT NOT NULL, -- foster_care, adoption, residential, parent
status TEXT NOT NULL DEFAULT 'draft', -- draft, submitted, home_study_requested, home_study_completed, approved, denied, placed, closed
submitted_at TIMESTAMPTZ,
deadline_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ICPC home studies (one per request)
CREATE TABLE icpc_home_studies (
id UUID PRIMARY KEY,
icpc_request_id UUID NOT NULL REFERENCES icpc_requests(id),
home_safety TEXT NOT NULL DEFAULT 'pending',
background_checks TEXT NOT NULL DEFAULT 'pending',
references_check TEXT NOT NULL DEFAULT 'pending',
space_capacity TEXT NOT NULL DEFAULT 'pending',
support_systems TEXT NOT NULL DEFAULT 'pending',
education_access TEXT NOT NULL DEFAULT 'pending',
medical_access TEXT NOT NULL DEFAULT 'pending',
rules_recommendation TEXT,
coordinator_decision TEXT,
override_justification TEXT,
completed_at TIMESTAMPTZ,
assessed_by TEXT
);
-- ICPC attachments (100A forms, medical records, etc.)
CREATE TABLE icpc_attachments (
id UUID PRIMARY KEY,
icpc_request_id UUID NOT NULL REFERENCES icpc_requests(id),
attachment_type TEXT NOT NULL,
object_key TEXT NOT NULL, -- object store key (via craig-store)
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
uploaded_by TEXT NOT NULL
);
CREATE INDEX idx_exchange_partners_type ON exchange_partners(partner_type);
CREATE INDEX idx_exchange_partners_active ON exchange_partners(active);
CREATE INDEX idx_agreements_partner ON data_sharing_agreements(partner_id);
CREATE INDEX idx_agreements_status ON data_sharing_agreements(status);
CREATE INDEX idx_exchange_transactions_partner ON exchange_transactions(partner_id);
CREATE INDEX idx_exchange_transactions_status ON exchange_transactions(status);
CREATE INDEX idx_icpc_requests_case ON icpc_requests(case_id);
CREATE INDEX idx_icpc_requests_status ON icpc_requests(status);
REST API
| Method | Path | Description | Role |
|---|---|---|---|
|
|
List exchange partners |
admin |
|
|
Configure exchange partner |
admin |
|
|
Update partner configuration |
admin |
|
|
Test connectivity to partner |
admin |
|
|
List data sharing agreements |
admin |
|
|
Create data sharing agreement |
admin |
|
|
Update agreement |
admin |
|
|
Trigger outbound exchange |
caseworker+ |
|
|
List exchange transactions (filterable) |
supervisor+ |
|
|
Get transaction detail with payloads |
supervisor+ |
|
|
Retry failed transaction |
admin |
|
|
List ICPC requests |
icpc_coordinator+ |
|
|
Create ICPC request |
icpc_coordinator+ |
|
|
Get ICPC request detail |
icpc_coordinator+ |
|
|
Update ICPC request (status transitions) |
icpc_coordinator+ |
|
|
Submit home study results |
icpc_coordinator+ |
|
|
Get home study detail |
icpc_coordinator+ |
|
|
Upload attachment (100A, medical records) |
icpc_coordinator+ |
|
|
List attachments |
icpc_coordinator+ |
|
|
Download attachment |
icpc_coordinator+ |
Adapter Pattern
Each of the 11 mandatory exchange types (45 CFR § 1355.52(e-f)) is implemented as an adapter module:
| Adapter | Exchange Partner | Requirement |
|---|---|---|
|
Child Welfare Contributing Agencies |
§ 1355.52(e)(1) — mandatory |
|
Title IV-B / IV-E payment systems |
§ 1355.52(e)(2) — mandatory |
|
Medicaid eligibility systems |
§ 1355.52(e)(3) — mandatory |
|
Child Abuse and Neglect systems |
§ 1355.52(e)(4) — mandatory if applicable |
|
Title IV-A TANF systems |
§ 1355.52(e)(5) — mandatory if applicable |
|
Title IV-D child support systems |
§ 1355.52(e)(6) — mandatory if applicable |
|
External data collection systems |
§ 1355.52(e)(7) — mandatory if applicable |
|
Court systems |
§ 1355.52(f)(1) — to the extent practicable |
|
Education systems |
§ 1355.52(f)(2) — to the extent practicable |
|
Health agency systems |
§ 1355.52(f)(3) — to the extent practicable |
|
Tribal entities |
§ 1355.54 — optional |
All adapters implement a common ExchangeAdapter trait and use the jurisdiction’s single unified data exchange standard per § 1355.52(f).
RabbitMQ Events
Publishes:
| Routing Key | Payload |
|---|---|
|
|
|
|
|
|
|
|
|
|
Consumes:
| Routing Key | Action |
|---|---|
|
Trigger outbound notifications to relevant partners |
|
Trigger Medicaid eligibility notification, education enrollment |
|
Trigger financial system notification |
Hardening — Seed Data, Tests & Documentation
Status: Complete — Sessions 1-5 complete (seed data, e2e tests, data model docs, developer guides, e2e manifest integration)
This phase is intentionally positioned before Phase 6 (Financial). The goal is to harden the existing platform so that new feature services can be built on a well-tested, well-documented foundation.
1. Seed Data Generator (craig-seed)
The original static SQL seed files (devstack/seed/sql/*.sql) were replaced with a scalable, optionally deterministic seed data generator.
The craig-seed Rust binary (in tools/craig-seed/) generates realistic, cross-service seed data at arbitrary scale.
Key Features
-
Random by default — different data each run when no seed is specified
-
Deterministic via
--seed N— identical output for the same seed + families combination -
Scalable via
--families N— each family generates a cascade of referrals, investigations, cases, placements, and exchange entities -
Cross-service FK integrity — all foreign key references valid across craig_cases, craig_placement, and craig_exchange databases
-
Deterministic UUIDv7s — custom generator using monotonic counter + seeded RNG for reproducible, time-ordered UUIDs
-
21 tests — determinism, FK consistency, scale (1/100/1000 families), UUID validity, SQL structure
Docker Integration
The craig-seed binary is built in the main Dockerfile (target: craig-seed) and invoked by devstack/seed/seed.sh.
Environment variables CRAIG_SEED (empty = random) and CRAIG_FAMILIES (default 12 in devstack) are passed through docker-compose.yml.
Usage
# Random seed, default 9 families
cargo xtask dev restart
# Deterministic, scaled up
CRAIG_SEED=42 CRAIG_FAMILIES=500 cargo xtask dev restart
# Local generation (without Docker)
cargo run -p craig-seed -- --seed 42 --families 9 --output-dir /tmp/seed
See the craig-seed design plan for full architecture details.
2. Test Audit
E2E Assertion Hardening
Review all existing e2e tests and strengthen assertions:
-
Replace
toBeVisible()checks on flash messages withtoContainText('specific value') -
After form submissions, verify the created/updated entity appears in the list/detail view
-
After state transitions, verify the new status badge text matches expected value
-
After delete operations, verify the entity is removed from the list
Negative and Error Path Tests
Add e2e tests for error conditions:
-
Authentication: Visit protected pages without login → verify redirect to Keycloak
-
Authorization: Attempt supervisor-only actions (close investigation) as caseworker → verify 403 or appropriate error
-
Not Found: Navigate to
/cases/00000000-0000-0000-0000-000000000000→ verify 404 page -
Form Validation: Submit forms with missing required fields → verify inline validation errors
-
State Machine Guards: Attempt invalid transitions (e.g. close an already-closed case) → verify error message
API Integration Test Gaps
Review and add integration tests for:
-
Pagination edge cases (empty results, last page, invalid page number)
-
Soft-delete behavior (deleted entities hidden from LIST; since #1579 the GET-by-id paths 404 tombstones — the #1215 posture)
-
Concurrent modification (optimistic locking if implemented)
-
Cross-service event publishing (verify RabbitMQ messages published after mutations)
3. Data Model Documentation
ER Diagrams
Create Mermaid ER diagrams for each service database:
-
docs/modules/ROOT/pages/data-model-cases.adoc— persons, referrals, allegations, investigations, cases, household_members, case_plans, plan_tasks, contacts, court_orders -
docs/modules/ROOT/pages/data-model-placement.adoc— foster_homes, placements, kinship_assessments -
docs/modules/ROOT/pages/data-model-exchange.adoc— exchange_partners, agreements, icpc_requests, transactions, home_studies -
docs/modules/ROOT/pages/data-model-rules.adoc— rule_sets, rule_evaluations
Each diagram should include column names, types, and foreign key relationships.
State Machine Diagrams
Create Mermaid state diagrams for all entities with status fields:
-
Referral:
new → screening → accepted / rejected -
Investigation:
open → pending_review → closed -
Case:
open → plan_active → closed -
Case Plan:
draft → active → approved → closed -
Placement:
active → ended -
Agreement:
draft → active → expired / terminated -
ICPC Request:
requested → accepted / denied → placed → closed -
Exchange Transaction:
pending → sent → acknowledged / failed
Domain Glossary
Create docs/modules/ROOT/pages/glossary.adoc with authoritative definitions for all domain terms:
allegation, case plan, CCWIS, contact, court order, disposition, foster home, household member, ICPC, investigation, kinship, permanency goal, placement, referral, rule set, safety assessment, etc.
4. Developer Onboarding Guide
Create docs/modules/ROOT/pages/developer-guide.adoc covering:
-
Getting started — prerequisites, clone, devstack startup, running tests
-
How to add an API endpoint — step-by-step walkthrough (model, migration, handler, route registration, OpenAPI, test)
-
How to add a new service — workspace member, Dockerfile, docker-compose entry, shared crate usage
-
How to add a web UI page — Askama template, route, middleware, htmx patterns, Alpine.js conventions
-
How to add a CLI command — clap subcommand,
cmd::*module, API client method, integration test -
How to add a JDM rule set — file format, jurisdiction naming, evaluation test, import via API
-
Testing guide — unit vs integration vs e2e,
craig-test-libusage, running specific test suites -
Architecture overview — service communication patterns, authentication flow, event bus topology
Architecture Decision Records
Create docs/modules/ROOT/pages/adrs/ directory with ADR files:
-
adr-001-rust-monorepo.adoc— why Rust, why monorepo, why Cargo workspace -
adr-002-uuid-v7.adoc— why UUID v7 over auto-increment or UUID v4 -
adr-003-rabbitmq-topology.adoc— topic exchange, routing key conventions, competing vs exclusive consumers -
adr-004-bff-pattern.adoc— why server-side rendering with BFF vs SPA -
adr-005-keycloak-oidc.adoc— why Keycloak, realm structure, role model -
adr-006-rules-engine.adoc— why zen-engine/JDM, jurisdiction configurability
5. Deployment and Operations Guide
Create docs/modules/ROOT/pages/deployment-guide.adoc covering:
-
Environment variables reference — complete list of all
CRAIG_*env vars across all services with descriptions and defaults -
Container deployment — Docker image builds, container registry, health checks
-
Database setup — PostgreSQL requirements, migration execution, backup strategy
-
RabbitMQ configuration — exchange/queue topology, user setup, HA considerations
-
Keycloak configuration — realm import, client setup, role mapping, OIDC endpoints
-
Monitoring — structured logging format, health check endpoints, readiness vs liveness probes
-
Scaling — horizontal scaling guidelines, connection pool sizing, stateless service guarantees
Verification
-
Devstack starts cleanly with expanded seed data — all services healthy
-
All existing e2e tests still pass with new seed data
-
New negative/error e2e tests pass
-
ER diagrams render correctly in Antora site
-
Developer guide is sufficient for a new contributor to add an endpoint end-to-end
-
Deployment guide covers all configuration needed for a fresh installation
Phase 6 — Financial and Claims Service
Status: Complete
Service: craig-financial
| Property | Value |
|---|---|
Binary |
|
Port |
8005 |
Database |
|
Dependencies |
Phase 1 + Phase 2 + Phase 3 + Phase 4 |
Key dependencies |
workspace crates |
Database Schema
-- Payment rate tables (jurisdiction-specific)
CREATE TABLE rate_tables (
id UUID PRIMARY KEY DEFAULT uuidv7(),
jurisdiction TEXT NOT NULL,
payment_type TEXT NOT NULL, -- foster_care, adoption_assistance, guardianship_assistance
age_min INTEGER NOT NULL,
age_max INTEGER NOT NULL,
daily_rate NUMERIC(10,2) NOT NULL,
effective_date DATE NOT NULL,
end_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Payment records
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT uuidv7(),
case_id UUID NOT NULL,
child_id UUID NOT NULL,
placement_id UUID NOT NULL,
foster_home_id UUID NOT NULL,
payment_type TEXT NOT NULL, -- foster_care, adoption_assistance, guardianship_assistance
period_start DATE NOT NULL,
period_end DATE NOT NULL,
daily_rate NUMERIC(10,2) NOT NULL,
day_count INTEGER NOT NULL,
gross_amount NUMERIC(10,2) NOT NULL,
adjustments NUMERIC(10,2) NOT NULL DEFAULT 0,
net_amount NUMERIC(10,2) NOT NULL,
ive_eligible BOOLEAN NOT NULL DEFAULT false,
ffp_rate NUMERIC(5,4) NOT NULL DEFAULT 0, -- e.g. 0.5000 for 50%
ffp_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, issued, cleared, voided
approved_by TEXT,
approved_at TIMESTAMPTZ,
issued_at TIMESTAMPTZ,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Payment adjustments
CREATE TABLE payment_adjustments (
id UUID PRIMARY KEY DEFAULT uuidv7(),
payment_id UUID NOT NULL REFERENCES payments(id),
reason TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
requested_by TEXT NOT NULL,
approved_by TEXT,
approved_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, denied
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- IV-E claiming records (for CFS-2 submission)
CREATE TABLE claiming_records (
id UUID PRIMARY KEY DEFAULT uuidv7(),
claiming_period TEXT NOT NULL, -- e.g. "2025-Q1"
payment_type TEXT NOT NULL,
total_expenditure NUMERIC(12,2) NOT NULL,
ive_eligible_amount NUMERIC(12,2) NOT NULL,
ffp_claimed NUMERIC(12,2) NOT NULL,
non_ive_amount NUMERIC(12,2) NOT NULL,
ccwis_operations_cost NUMERIC(12,2) NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft', -- draft, submitted, accepted
submitted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_payments_case ON payments(case_id);
CREATE INDEX idx_payments_child ON payments(child_id);
CREATE INDEX idx_payments_foster_home ON payments(foster_home_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_period ON payments(period_start, period_end);
CREATE INDEX idx_claiming_period ON claiming_records(claiming_period);
REST API
| Method | Path | Description | Role |
|---|---|---|---|
|
|
List payments (filterable by case, home, status, period) |
eligibility_worker+ |
|
|
Get payment detail with rate breakdown |
eligibility_worker+ |
|
|
Calculate payment for placement period |
eligibility_worker+ |
|
|
Approve payment for issuance |
supervisor+ |
|
|
Record disbursement (approved → issued) |
supervisor+ |
|
|
Record reconciliation clearance (issued → cleared) |
supervisor+ |
|
|
Deliberate manual void of an UNDISBURSED payment (pending/approved → voided; mandatory row-only reason; issued/cleared → typed 400 |
supervisor+ |
|
|
Request payment adjustment |
eligibility_worker+ |
|
|
Approve/deny adjustment |
supervisor+ |
|
|
Get current rate table |
eligibility_worker+ |
|
|
Create/update rate table |
admin |
|
|
List claiming records |
eligibility_worker+ |
|
|
Generate claiming report for period |
supervisor+ |
|
|
Get claiming detail with cost allocation |
eligibility_worker+ |
|
|
Submit claiming report |
admin |
RabbitMQ Events
Publishes:
| Routing Key | Payload |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Consumes:
| Routing Key | Action |
|---|---|
|
Generate the first-period payment (#979): prorated from the placement’s |
|
Void undisbursed payments for the placement (decoded via the shared |
|
Update IV-E eligibility flag on existing payments |
|
Update rate when child ages into new bracket |
Rules Engine Integration
-
Rate calculation: When a placement is created, the Rules Engine determines the applicable daily rate based on the child’s age, placement type, and jurisdiction rate table.
-
Age bracket transitions: The Rules Engine monitors child birthdays and triggers rate recalculations when a child ages into a new bracket.
-
Cost allocation: Claiming report generation invokes the Rules Engine to classify expenditures per the jurisdiction’s approved APD methodology (§ 1355.57).
Phase 7 — Reporting and Data Quality Service
Status: Complete
Service: craig-reporting
| Property | Value |
|---|---|
Binary |
|
Port |
8006 |
Database |
|
Dependencies |
Phase 1 + Phase 2 + Phase 3 |
Key dependencies |
workspace crates |
Database Schema
-- Data quality issues detected by automated monitoring
CREATE TABLE data_quality_issues (
id UUID PRIMARY KEY DEFAULT uuidv7(),
source_service TEXT NOT NULL, -- craig-cases, craig-placement, etc.
source_record_id UUID NOT NULL,
issue_type TEXT NOT NULL, -- missing_field, invalid_value, timeliness, consistency
field_name TEXT NOT NULL,
description TEXT NOT NULL,
severity TEXT NOT NULL, -- critical, warning, info
resolved BOOLEAN NOT NULL DEFAULT false,
resolved_at TIMESTAMPTZ,
resolved_by TEXT,
detected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- AFCARS submission records
CREATE TABLE afcars_submissions (
id UUID PRIMARY KEY DEFAULT uuidv7(),
reporting_period TEXT NOT NULL, -- e.g. "2025-Q1"
record_count INTEGER NOT NULL,
validation_errors INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft', -- draft, validated, reviewed, approved, transmitted
validated_at TIMESTAMPTZ,
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
approved_by TEXT,
approved_at TIMESTAMPTZ,
transmitted_at TIMESTAMPTZ,
acf_acknowledgment TEXT,
object_key TEXT, -- object store key for generated flat file (via craig-store)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- NCANDS submission records
CREATE TABLE ncands_submissions (
id UUID PRIMARY KEY DEFAULT uuidv7(),
reporting_year INTEGER NOT NULL, -- federal fiscal year (ending Sept 30)
record_count INTEGER NOT NULL,
validation_errors INTEGER NOT NULL DEFAULT 0,
child_fatalities INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
transmitted_at TIMESTAMPTZ,
acf_acknowledgment TEXT,
object_key TEXT, -- object store key for generated flat file (via craig-store)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Data quality metrics (tracked over time for trend analysis)
CREATE TABLE data_quality_metrics (
id UUID PRIMARY KEY DEFAULT uuidv7(),
measured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metric_type TEXT NOT NULL, -- afcars_readiness, ncands_readiness, field_completeness
metric_value NUMERIC(5,2) NOT NULL, -- percentage
details JSONB -- breakdown by field/category
);
CREATE INDEX idx_dq_issues_source ON data_quality_issues(source_service, source_record_id);
CREATE INDEX idx_dq_issues_type ON data_quality_issues(issue_type);
CREATE INDEX idx_dq_issues_unresolved ON data_quality_issues(resolved) WHERE resolved = false;
CREATE INDEX idx_afcars_period ON afcars_submissions(reporting_period);
CREATE INDEX idx_ncands_year ON ncands_submissions(reporting_year);
CREATE INDEX idx_dq_metrics_type ON data_quality_metrics(metric_type, measured_at);
REST API
| Method | Path | Description | Role |
|---|---|---|---|
|
|
Data quality dashboard (metrics, trends, critical errors) |
supervisor+ |
|
|
List data quality issues (filterable) |
caseworker+ |
|
|
Mark issue as resolved |
caseworker+ |
|
|
Generate AFCARS submission for period |
admin |
|
|
List AFCARS submissions |
supervisor+ |
|
|
Get submission detail with validation results |
supervisor+ |
|
|
Approve for transmission |
admin |
|
|
Transmit to ACF |
admin |
|
|
Generate NCANDS submission for fiscal year |
admin |
|
|
List NCANDS submissions |
supervisor+ |
|
|
Approve for transmission |
admin |
|
|
Transmit to ACF |
admin |
RabbitMQ Events
Consumes:
| Routing Key | Action |
|---|---|
|
Validate case data against federal quality standards |
|
Validate placement data for AFCARS completeness |
|
Validate eligibility determination data |
|
Validate financial data for claiming accuracy |
Publishes:
| Routing Key | Payload |
|---|---|
|
|
|
|
|
|
Rules Engine Integration
-
Continuous data quality monitoring: The Rules Engine evaluates incoming data events against federal and state data quality standards, generating
data_quality_issuesrecords for any violations. -
AFCARS field validation: Before generating a submission, the Rules Engine validates every record against AFCARS data element requirements.
-
Timeliness compliance: Monitors federal reporting deadlines (quarterly AFCARS, annual NCANDS by September 30).
Verification
-
Create case and placement data → verify data quality monitoring detects missing fields
-
Generate AFCARS submission → verify record count and validation errors
-
Approve and transmit → verify transmitted status and
afcars_transmittedevent -
Check data quality dashboard → verify metrics and trends
Phase 8 — Security and Compliance Service
Status: Complete
Service: craig-security
| Property | Value |
|---|---|
Binary |
|
Port |
8007 |
Database |
|
Dependencies |
Phase 1 |
Key dependencies |
workspace crates |
Database Schema
-- Audit log for all data access and modifications across services
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT uuidv7(),
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
user_id TEXT NOT NULL,
user_role TEXT NOT NULL,
service TEXT NOT NULL, -- source service name
action TEXT NOT NULL, -- create, read, update, delete
resource_type TEXT NOT NULL, -- case, placement, payment, etc.
resource_id UUID,
details JSONB, -- changed fields, query parameters, etc.
ip_address TEXT,
success BOOLEAN NOT NULL DEFAULT true
);
-- Biennial security review tracking (45 CFR § 95.621(f))
CREATE TABLE security_reviews (
id UUID PRIMARY KEY DEFAULT uuidv7(),
review_type TEXT NOT NULL, -- biennial, incident, ad_hoc
scheduled_date DATE NOT NULL,
completed_date DATE,
reviewer TEXT NOT NULL,
findings JSONB,
remediation_plan JSONB,
status TEXT NOT NULL DEFAULT 'scheduled', -- scheduled, in_progress, completed, overdue
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Archive-batch ledger (2 CFR § 200.334; #1129/ADR-058: one row per
-- archived batch, written inside the prune transaction — retention_until
-- is NULL until DFCS names a schedule, and the D14 purge predicate
-- (named+passed date, no legal_hold) guards every destructive statement)
CREATE TABLE archive_records (
id UUID PRIMARY KEY DEFAULT uuidv7(),
source_service TEXT NOT NULL,
source_table TEXT NOT NULL,
record_count INTEGER NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retention_until DATE,
purge_eligible BOOLEAN NOT NULL DEFAULT false,
purged_at TIMESTAMPTZ,
purged_by TEXT,
archived_by TEXT NOT NULL,
legal_hold BOOLEAN NOT NULL DEFAULT false,
data_key TEXT, -- + manifest_key, data_sha256, store identity,
first_record_id UUID, -- [min,max] id/time range, hot_window_days
last_record_id UUID -- (see data-model-security.adoc for the full shape)
);
-- NIST SP 800-53 control mapping
CREATE TABLE nist_controls (
id UUID PRIMARY KEY DEFAULT uuidv7(),
control_id TEXT NOT NULL UNIQUE, -- e.g. "AC-2", "AU-3"
control_family TEXT NOT NULL, -- Access Control, Audit, etc.
control_name TEXT NOT NULL,
implementation_status TEXT NOT NULL DEFAULT 'planned', -- planned, implemented, partial, not_applicable
implementation_notes TEXT,
evidence_key TEXT, -- object store key for evidence artifacts (via craig-store)
last_assessed DATE,
assessed_by TEXT
);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp);
CREATE INDEX idx_audit_log_service ON audit_log(service);
REST API
| Method | Path | Description | Role |
|---|---|---|---|
|
|
Query audit log (filterable by user, resource, date range) |
admin |
|
|
Audit trail for specific user |
admin |
|
|
Audit trail for specific resource |
supervisor+ |
|
|
List security reviews |
admin |
|
|
Schedule security review |
admin |
|
|
Update review status and findings |
admin |
|
|
List archive records |
admin |
|
|
One bounded archive-then-prune pass (#1129: consent-gated 403 while |
admin |
|
|
Destroy expired archives in bounded slices ( |
admin |
|
|
List NIST control mappings |
admin |
|
|
Update control implementation status |
admin |
RabbitMQ Events
Consumes:
All services publish events; the security service subscribes to # (all routing keys) to populate the audit log.
Publishes:
| Routing Key | Payload |
|---|---|
|
|
|
|
|
|
Phase 9 — CLI Client (craig)
Status: Complete
Binary: craig-cli
| Property | Value |
|---|---|
Binary |
|
Dependencies |
Phase 1 + any service APIs |
Key dependencies |
|
Command Structure
The CLI follows the craig <service> <action> [args] pattern, modeled after the openstack CLI:
craig login # authenticate and cache token craig token show # display current token info craig rules list # list rule sets craig rules get <id> # get rule set craig rules import <file> # import JDM file craig rules evaluate <name> <json> # evaluate against rule set craig person search --name <name> # search persons craig referral list # list referrals craig investigation create <json> # open investigation craig case list # list cases craig case get <id> # get case detail craig case create <json> # create case craig plan create <case-id> <json> # create case plan craig home list # list foster homes craig placement list # list placements craig placement match <child-id> # find matching homes craig kinship list --case <id> # list kinship options craig exchange list # list exchange partners craig agreement list # list data sharing agreements craig icpc list # list ICPC requests craig financial payments # list payments craig adjustment list # list payment adjustments craig claim list # list claiming records craig reporting quality # data quality dashboard craig reporting afcars list # list AFCARS submissions craig reporting ncands list # list NCANDS submissions craig completion bash # generate shell completion
Features
-
Output formats:
--format table(default),--format json -
Profiles:
~/.config/craig/profiles.tomlfor multi-jurisdiction deployments (different API URLs, Keycloak realms) -
Shell completion: generated via
clap_completefor bash, zsh, fish -
Token caching: Keycloak tokens cached in
~/.config/craig/token.json, auto-refreshed on expiry -
Pagination:
--page,--per-page,--allflags for list commands
Testing
The CLI has 31 unit tests (config, output, token handling) and 43 integration tests across 13 modules that exercise every command against the devstack APIs. Tests use craig-test-lib (TestHarness, builders, typed clients) and call cmd::*::run() at the library level — no subprocess spawning.
| Module | Tests |
|---|---|
|
6 |
|
4 |
|
3 |
|
3 |
|
5 |
|
3 |
|
3 |
|
3 |
|
3 |
|
2 |
|
3 |
|
4 |
|
1 |
Total |
43 |
Verification
-
cargo build -p craig-cli— compiles -
cargo test -p craig-cli --lib— 28 unit tests pass -
cargo test -p craig-cli --test cli— 43 integration tests pass (requires devstack) -
cargo clippy -p craig-cli --tests — -D warnings— clean -
craig login→ verify token obtained and cached -
craig rules list --format json→ verify JSON output -
Tab completion works for subcommands
Phase 10 — Caseworker Web UI
Status: Complete — BFF with OIDC auth, 25+ pages across 8 modules (intake, cases, placement, exchange, financial, rules, dashboard, login/error), write forms on all modules, WCAG accessibility, Georgia Orchard theming. The dashboard fans out live stats via parallel API calls (cases, open investigations, active placements, pending reviews) plus the ADR-035 composed panels; i18n remains deferred (#1332/#1487).
Application: craig-web
| Property | Value |
|---|---|
Application |
|
Framework |
Axum + Askama (server-side HTML) + htmx + Alpine.js (BFF pattern) |
Dependencies |
Phase 1 shared crates + all service REST APIs |
Key dependencies |
|
Architecture
The web UI follows the Backend-for-Frontend (BFF) pattern (similar to OpenStack Horizon). The Axum server handles OIDC authentication, stores JWTs server-side in cookie sessions, proxies API calls to backend services, and renders HTML via Askama templates. Client-side interactivity uses htmx for partial page updates and Alpine.js for in-page state (inline forms, toggles).
craig-web/
src/
main.rs # Axum server entry, router setup, middleware
config.rs # CRAIG_WEB__* env var configuration
state.rs # AppState: config + shared reqwest::Client
auth.rs # Keycloak OIDC code+PKCE flow, session management
api.rs # ApiClient (reqwest → service REST endpoints, Bearer injection)
routes/
mod.rs # Shared: error pages, flash messages, breadcrumbs, PageContext
login.rs # Login/logout/callback (OIDC)
dashboard.rs # Caseworker dashboard (placeholder — live stats pending)
intake.rs # Referrals, persons, investigations, safety assessments
cases.rs # Cases, household, plans, tasks, contacts, court orders
placement.rs # Foster homes, training, placements, matching
exchange.rs # Exchange partners, agreements, ICPC requests, transactions
financial.rs # Payments, rates, claims, adjustments
rules.rs # Rule set management (admin)
templates/
base.html # Layout shell: nav sidebar, header, breadcrumbs, flash messages
login.html # Login page
dashboard.html # Dashboard tiles (role-based layout)
error.html # 404/error page
intake/ # Referral list, detail, investigation detail
cases/ # Case list, detail, household, plan detail, contact forms
placement/ # Foster home list/detail, placement list/detail
exchange/ # Partner list/detail, ICPC detail, agreement detail
financial/ # Payment list/detail, rate list/new, claim list/detail
rules/ # Rule set list, detail, evaluation
static/
themes/ # CSS custom properties, per-deployment overrides
Key Design Decisions
-
BFF pattern — server-side rendering, no client-side routing or WASM
-
Built entirely on the public REST APIs — no privileged backend access
-
Keycloak OIDC with PKCE flow for authentication (public
craig-uiclient) -
Server-side JWT storage — browser receives only an opaque session cookie
-
Role-based navigation and content — UI elements hidden based on session user roles
-
Flash messages for post-redirect-get feedback (success/error/info)
-
CSS theming via custom properties (
tokens.css) with per-deployment overrides -
Inline form pattern: Alpine.js
x-datatoggles for sub-resource creation on detail pages
Remaining Work
-
Dashboard live stats — wire dashboard tiles to backend API aggregation counts (open cases, pending placements, pending payments, overdue tasks) per logged-in user’s role and caseload
-
Multi-language support — i18n for template strings
-
Reporting UI pages — add reporting module pages (quality dashboard, AFCARS/NCANDS management) using
craig-reportingAPI
Verification
-
cargo build -p craig-web— compiles -
Login via Keycloak → verify session created, redirect to dashboard
-
Navigate all CRUD pages → verify data loads from REST APIs
-
Create/update records via forms → verify flash messages and redirects
-
Verify role-based navigation (admin features hidden for caseworker)
-
404 page renders for invalid routes
Phase 11 — Constituent and Provider Portals
Status: Planned
Application: craig-portals
| Property | Value |
|---|---|
Application |
|
Framework |
TBD (options: Axum+Askama BFF like Phase 10, or SPA framework) |
Dependencies |
Phase 10 (uses same REST APIs and Keycloak auth) |
Key dependencies |
TBD |
Portal Types
| Portal | Functionality | Data Scope |
|---|---|---|
Family Portal |
Case plan tasks (responsible=family), caseworker contact, messaging, provider finder, document upload |
Single family’s case only |
Foster Parent Portal |
Placed child info (medical, school, visitation, emergency), payment summary, training alerts |
Only children placed in their home |
Provider Portal |
Contracted cases, service record submission, overdue report alerts, structured data exchange (CCWIS partners) |
Only contracted cases |
Component Architecture
craig-portals/
src/
main.rs # Dioxus app entry, portal-type routing
auth.rs # Keycloak OIDC (separate client per portal type or role-based routing)
api.rs # API client (same REST endpoints, role-scoped responses)
family/
dashboard.rs # Family case plan view
provider_finder.rs # Location/service-type filtered provider directory
messages.rs # Secure messaging with caseworker
foster_parent/
dashboard.rs # Per-child overview
payments.rs # Payment history and upcoming
training.rs # Training deadlines and renewal alerts
provider/
dashboard.rs # Contracted case list
service_records.rs # Submit and manage service records
exchange.rs # Structured data submission for CCWIS partners
Key Design Decisions
-
May reuse the Axum+Askama BFF pattern from Phase 10, or adopt a SPA framework — decision deferred
-
Role-based routing: Keycloak roles (
family,foster_parent,provider) determine which portal view loads -
Data scoping enforced server-side — the REST APIs return only records the authenticated user has access to
-
No privileged backend access — all portals use the same public REST APIs
-
Prevents duplicate data entry per § 1355.52 — provider service records feed directly into the case record
Verification
-
Login as family user → verify only assigned case visible
-
Login as foster parent → verify only placed children visible
-
Login as provider → verify only contracted cases visible
-
Submit service record → verify it appears in caseworker’s case view
-
Accessibility audit (Section 508 / WCAG 2.1 AA)
Phase 12 — Public Intake Service
Status: Complete (Phase 1 + Phase 2 field expansion) — Public-facing child abuse reporting with web form, partner REST API, SDK, and caseworker review workflow. See Public Child Abuse Reporting plan for full design.
Components
| Component | Details |
|---|---|
Service |
|
Public Web Form |
Single-page Alpine.js form ( |
Partner API |
|
SDK (Rust) |
|
SDK (TypeScript) |
|
SDK (Python) |
|
Browser signing |
|
JWS Verification |
Detached ES256 signature verification on partner submissions, CRAIG-hosted signer key registry (ADR-010) |
Web BFF |
|
CLI |
|
Database
| Table | Description |
|---|---|
|
Submitted reports with reporter info, concern details, children/adults JSONB, narrative JSONB, review status, optional JWS signature |
|
Partner API keys with name, hashed key, active flag |
|
File metadata (name, type, size, object key) linked to reports |
|
ECDSA P-256 public keys for JWS integrity verification (pending/approved/revoked) |
Endpoints
20 total endpoints:
-
Public (no auth): POST report, GET status, POST/GET attachments, POST/GET signer keys
-
Partner (API key): POST report (optional JWS verification), GET status
-
Authenticated (JWT): list/detail/claim/convert/screen-out reports, list/create/revoke API keys, list/approve/revoke signer keys, verify report signature
Phase 2: 3rd Party Field Expansion
Phase 2 expands the data model for comprehensive 3rd party reporting, broken into 4 sub-phases:
-
Phase 2a (complete): 7 new reference enums, typed JSONB structs (
ChildEntry,AdultEntry,Narrative), 15+ new columns onpublic_reports,CreateReportInputstruct -
Phase 2b (complete): Typed enum validation for JSONB arrays, conditional logic (military branch, maltreater relationship), file upload endpoint with
craig-store, SDK expansion -
Phase 2c (complete): Single-page Alpine.js form with conditional reporter fields, dynamic children/adults arrays, max-10 enforcement, validation error display
-
Phase 2d (complete): 9 expanded-field integration tests, 7 attachment integration tests, 11 Playwright E2E tests, services.md + CHANGELOG updated
-
Phase 2e (complete): JWS integrity verification — signer key registry (
signer_keystable), detached ES256 verification on partner submissions, verification audit endpoint, key management web page, browser signing library. See JWS & Multi-Language SDKs plan and ADR-010. -
Phase 2f (complete): Multi-language SDKs — TypeScript (
@craig/intake-sdk), Python (craig-intake-sdk), Rust SDK signing support, shared canonicalization test vectors
Verification
-
Submit a public report via form, verify conditional fields and confirmation code
-
Submit via partner API, verify all Phase 2 fields stored and returned
-
Upload attachments to a report, verify file stored and listed
-
Claim and convert a report to referral, verify cross-service call
-
Screen out a report, verify status transition and reason
-
Verify SDK builder creates valid requests with Phase 2 fields
Phase 13 — Mobile & Offline-Capable Client
Status: Planning — Architecture decision pending (ADR-009). See Mobile & Offline Client plan.
Overview
A field-ready client application for caseworkers operating in rural areas and other low-connectivity environments. Built with Tauri 2.0 (recommended), sharing Rust code with the CRAIG workspace. Encrypted local storage via SQLCipher, bidirectional sync with the CRAIG backend, and offline authentication.
Components
| Component | Details |
|---|---|
|
Tauri 2.0 application — desktop (Windows, macOS, Linux) + mobile (iOS, Android) |
|
Shared crate — sync engine, conflict resolution, offline queue |
Local DB |
SQLCipher (AES-256 encrypted SQLite) — stores working set of assigned cases |
Frontend |
Web tech (Alpine.js or lightweight framework) rendered in Tauri WebView |
Offline Data Scope
| Data | Offline Access | Sync Direction |
|---|---|---|
Assigned cases (summary) |
Read |
Pull only |
Persons on assigned cases |
Read |
Pull only |
Contact notes |
Read + Write |
Bidirectional |
Safety assessments |
Read + Write |
Bidirectional |
Case plan tasks |
Read + Write |
Bidirectional |
Court orders |
Read only |
Pull only |
Placement info |
Read only |
Pull only |
Reference data (enums, admin units) |
Read only |
Pull only (infrequent) |
Attachments/documents |
Cached on demand |
Pull only |
Key Design Decisions
-
Sync strategy: Per-entity
versioncolumn +updated_atcursor. Pull changes since last sync, push local modifications, field-level three-way merge for conflicts. -
Security: SQLCipher AES-256 encryption at rest. Keycloak OIDC with cached tokens (72-hour offline expiry). Biometric/PIN device lock.
-
Conflict resolution: Auto-merge non-overlapping field changes. Safety-critical fields (safety assessments, immediate danger) always require manual review.
Server-Side Requirements
-
Sync endpoints on craig-cases, craig-placement:
GET /v1/{entity}?updated_since={cursor} -
versioncolumn (monotonic counter) on synced tables -
Batch sync endpoint for efficient pull of multiple entity types
Verification
-
Create contact note offline, reconnect, verify sync to server
-
Modify same case from web UI and client simultaneously, verify conflict resolution
-
Verify SQLCipher encryption — local DB unreadable without key
-
Verify 72-hour offline token expiry forces re-authentication
-
Verify biometric unlock on mobile
Phase Dependencies
The following diagram shows the dependency chain between phases:
Phase 1 (Foundation)
└── Phase 2 (Rules Engine)
├── Phase 3 (Cases)
│ ├── Phase 4 (Placement)
│ │ └── Phase 6 (Financial)
│ └── Phase 7 (Reporting)
└── Phase 5 (Data Exchange)
└── Phase 8 (Security)
└── Phase 9 (CLI) ← can start after any service exists
└── Phase 10 (Web UI) ← can start after any service exists
└── Phase 11 (Portals)
└── Phase 12 (Public Intake) ← depends on Phase 1; Phase 2c depends on Phase 10 (Web UI)
└── Phase 13 (Mobile & Offline Client) ← depends on Phase 3 (Cases), Phase 4 (Placement); shares crates with Phase 1
Testing Infrastructure ← cross-cutting, can start after Phase 1; grows with each service phase
Performance Testing ← cross-cutting, can start after Testing Infrastructure; grows with each service phase
Object Storage ← cross-cutting, no service dependencies; adopted by services incrementally
Phases 5, 8, and 9 can begin as soon as Phase 1+2 are complete.
Phases 3 and 4 are the critical path for most downstream functionality.
Testing Infrastructure is cross-cutting — it spans all service phases and grows incrementally as new services are added.
Performance Testing builds on top of Testing Infrastructure and can begin as soon as any service has integration tests.
Object Storage is cross-cutting — the craig-store crate and Garage devstack container can be built independently.
Services adopt it incrementally by adding a craig-store dependency and wiring upload/download endpoints.