Implementation Guide

On this page
Table of Contents

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:

  1. Loads configuration from environment variables (CRAIG_<SERVICE>__*)

  2. Connects to its own PostgreSQL database (craig_<service>)

  3. Connects to the shared RabbitMQ message bus (craig.events topic exchange)

  4. Validates Keycloak OIDC tokens via the craig-auth middleware

  5. Exposes a versioned REST API under /v1/<service>/…​

  6. Exposes an unauthenticated health check at GET /healthz

Shared Crates

Crate Purpose

craig-common

Configuration loading, ApiError type (RFC 9457 Problem Details), UUID v7 IDs, pagination, structured logging

craig-auth

Keycloak OIDC discovery, JWKS caching, Bearer token validation middleware, role-based access helpers

craig-db

DbPool wrapper around sqlx::PgPool, health check, migration runner

craig-mq

RabbitMQ connection via lapin, Publisher and Subscriber helpers, EventEnvelope message format

craig-api

Axum server builder with standard middleware stack (CORS, compression, tracing, auth), health endpoint

craig-store

Object storage abstraction wrapping the Apache object_store crate — uniform put/get/delete/list API across local filesystem (dev) and S3-compatible backends (production)

craig-reference

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

admin

Full system administration

supervisor

Unit/district case oversight, approval workflows

caseworker

Assigned cases, standard operations

eligibility_worker

IV-E eligibility determinations, claiming

icpc_coordinator

Interstate compact requests and home studies

readonly

View-only access across all modules

county_director / regional_director / state_office

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

postgres:18-alpine

5432

RabbitMQ 4.2

rabbitmq:4.2-management-alpine

5672 / 15672

Keycloak 26.5

quay.io/keycloak/keycloak:26.5

8180

Garage

dxflrs/garage:v2.2.0

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

Subscriber::subscribe()

Durable, shared

Competing consumers — only one instance processes each message. Use for event handlers that mutate the database.

Subscriber::subscribe_exclusive()

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:

  1. After a successful write + local conditional cache apply, stage an invalidation event (e.g., rules.cache_invalidated) carrying the originating instance_id

  2. Each instance subscribes on a unique exclusive queue (format!("{service}.cache.{uuid}")) so all instances receive the event

  3. 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

  4. The originating instance skips its own event (its cache was already updated by the API handler) — the payload’s instance_id is the discriminator

  5. 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 (RulesClient, CasesClient, PlacementClient) wrapping reqwest with bearer token injection.

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. cargo test is always safe.

Unique resource names

UUID v7 suffixes on all test-created resources enable safe parallel execution.

Crate: craig-test-lib

Property Value

Location

crates/craig-test-lib/

Type

Library (dev-dependency for services)

Dependencies

reqwest, serde, serde_json, tokio, uuid, chrono, lapin, craig-common

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

password

admin

Rule set CRUD, admin-only endpoints

jane.doe

password

caseworker, supervisor

Supervisor actions, case plan approval

bob.smith

password

caseworker, eligibility_worker

Standard caseworker, eligibility-specific

Integration Test Plan

craig-rules (~46 tests)

File Count Description

health.rs

2

Healthcheck (unauthenticated), response shape

auth.rs

8

No token → 401, invalid token → 401, role-based access for list/create/delete/evaluations

rule_sets.rs

14

CRUD, pagination, duplicate name → 409, invalid JDM → 400, soft-delete, boundary cases

evaluate.rs

8

Valid/invalid input, audit trail, context tracking, post-update evaluation, edge cases

evaluations.rs

6

List with role guards, filter by context_type/rule_set_name/context_id, pagination

import_export.rs

4

Import valid/invalid JDM, export, roundtrip

events.rs

4

rules.evaluated event, cache invalidation on create/update/delete

craig-cases (~62 tests)

File Count Description

health.rs

1

Healthcheck

auth.rs

4

Authentication and authorization negative cases

persons.rs

8

CRUD, search by name/DOB/SSN, pagination, 404

referrals.rs

8

CRUD, allegations, filters, validation, 404

investigations.rs

8

CRUD, status transitions (valid/invalid), filters, 404

cases.rs

17

CRUD, status transitions, household, filters

case_plans.rs

8

CRUD, approval (supervisor vs caseworker), tasks, transitions

contacts.rs

3

Create, list, pagination

court_orders.rs

3

Create, list, pagination

events.rs

6

Verify 6 domain events (referral, intake, case CRUD, plan)

workflow.rs

3

Full lifecycle, cross-service safety assessment, case closure

craig-placement (~34 tests)

File Count Description

health.rs

1

Healthcheck

auth.rs

4

Authentication and role-based access (caseworker vs supervisor)

foster_homes.rs

10

CRUD, license transitions (valid/invalid), training, capacity/county/status filters

placements.rs

8

CRUD, status transitions, placement history, case_id filter

kinship.rs

4

Create, list, evaluate, filter

matching.rs

3

Search by child, capacity filtering, needs-based matching

events.rs

4

Verify 4 domain events (placement created/ended/requested, license expiring)

Unit Test Additions (~33 tests)

Crate Count Tests

craig-common

14

ApiError → correct HTTP status for each variant, Internal redacts detail, ProblemDetails RFC 9457 shape, pagination edge cases, settings defaults and redaction

craig-auth

7

has_role edge cases (empty, multiple, case-sensitive), Claims deserialization, require_role pass/fail

craig-mq

5

EventEnvelope::new() UUID v7 and timestamp, serialization roundtrip, EVENTS_EXCHANGE constant

craig-cases

4

Transition edge cases (same-state, backwards)

craig-placement

3

Transition edge cases (same-state, skip-state)

Actual Test Counts

Category Count

Ruleset evaluation (GA + TX)

67

craig-common unit tests (errors, pagination, settings, IDs)

18

craig-auth unit tests (claims, middleware)

8

craig-mq unit tests (envelope)

5

craig-store unit tests

16

craig-cases transition unit tests

12

craig-placement transition unit tests

7

craig-rules integration tests

43

craig-cases integration tests

64

craig-placement integration tests

30

craig-exchange transition unit tests

11

craig-exchange integration tests

26

craig-financial transition unit tests

11

craig-financial integration tests

22

craig-reporting transition unit tests

10

craig-reporting integration tests

25

craig-security transition unit tests

10

craig-security integration tests

19

craig-seed unit tests

21

craig-cli unit tests (config, output, auth)

28

craig-cli integration tests

43

craig-test-lib doc tests

1 (ignored)

Total

497

Verification

  1. cargo fmt --check --all — clean

  2. cargo clippy --workspace --locked — -D warnings — clean

  3. cargo test --workspace (devstack stopped) — unit + ruleset tests pass, integration tests self-skip, 1 ignored doc test

  4. Start devstack (cargo xtask dev start) and all services

  5. 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 grafana/k6 image runs in dind alongside the devstack, no binary installation needed.

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

rules/load.js

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

cases/load.js

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

placement/load.js

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

soak/moderate-load.js

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

cross-service/safety-assessment.js

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.

cross-service/intake-to-placement.js

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

tokio-console

Async runtime introspection

Add console-subscriber behind a tokio-console cargo feature flag. Enables per-task polling duration, waker counts, and resource utilization analysis. Never enabled in production builds.

Criterion.rs

Micro-benchmarks

benches/ directory in performance-critical crates. Target hot paths: zen-engine rule evaluation, JWT signature validation, pagination offset calculation, EventEnvelope serialization.

reqwest::Client pool metrics

HTTP connection pool utilization

Log pool statistics (idle connections, active connections) at debug level during load tests. Tune pool_max_idle_per_host and pool_idle_timeout based on observed patterns.

PostgreSQL EXPLAIN ANALYZE

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
  1. Run the soak/moderate-load.js test for 30 minutes

  2. Monitor PostgreSQL connection count: SELECT count(*) FROM pg_stat_activity WHERE datname LIKE 'craig_%'

  3. Monitor reqwest pool behavior via debug logs

  4. Adjust db_max_connections and pool_max_idle_per_host based on peak utilization + 20% headroom

  5. Re-run soak test to verify no connection exhaustion

  6. 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

  1. Install k6 locally: brew install k6 / choco install k6 / docker pull grafana/k6

  2. Start devstack: docker compose up -d --build

  3. Run smoke tests: k6 run perf/rules/smoke.js — all checks pass, no errors

  4. Run load tests: k6 run perf/cases/load.js — all thresholds met

  5. Run soak test: k6 run perf/soak/moderate-load.js — no response time drift over 30 minutes

  6. Run cross-service test: k6 run perf/cross-service/safety-assessment.js — p95 < 500ms

  7. Review k6 summary output — all thresholds green, error rate < 0.1%

  8. If any threshold is red, profile with tokio-console and EXPLAIN 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_store crate talks to any of them via the S3 protocol.

  • Dev backend: Garage (S3-compatible, written in Rust, AGPL-3.0) for devstack; LocalFileSystem for 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

craig-cases

Court orders, case plan documents

PDF, DOCX

craig-exchange

ICPC attachments (100A forms, medical records, birth certificates, home study reports), data sharing agreement documents

PDF, images (JPEG/PNG)

craig-placement

Foster home inspection photos, license documents, training certificates

PDF, images (JPEG/PNG)

craig-reporting

Generated AFCARS/NCANDS flat files

DAT, CSV

craig-security

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:

  1. craig-store crate + Garage devstack container

  2. craig-exchange — ICPC attachments (already has icpc_attachments table with file_url)

  3. craig-cases — court order documents (already has document_url)

  4. craig-placement — foster home and license documents (new table)

  5. craig-reporting — generated AFCARS/NCANDS files (when Phase 7 is built)

  6. craig-security — evidence documents (when Phase 8 is built)

Web UI and CLI Integration

  • craig-web: File upload forms use <input type="file"> with enctype="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> and craig 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

  1. Start devstack with cargo xtask dev start → verify Garage healthy and bucket created

  2. Upload a file via API → verify object appears in Garage bucket under the correct prefix

  3. Download via API → verify file content matches

  4. Delete via API → verify object removed from Garage

  5. Switch to local backend → verify files written to ./data/objects/ with correct directory structure

  6. Upload oversized file → verify 413 rejection

  7. Upload disallowed MIME type → verify 415 rejection

Phase 1 — Foundation

Status: Completed

Deliverables

Deliverable Description

Workspace root

Cargo.toml (workspace with shared dependency versions), rust-toolchain.toml (stable Rust, rustfmt, clippy)

craig-common

ServiceSettings (config crate, env var loading), ApiError (RFC 9457), Id (UUID v7), PageRequest/PageResponse, telemetry init

craig-db

DbPool (sqlx::PgPool wrapper), connect(), health_check(), run_migrations()

craig-mq

connect(), Publisher, Subscriber, EventEnvelope, EVENTS_EXCHANGE constant

craig-auth

JwksProvider (OIDC discovery + JWKS refresh), AuthLayer, auth_middleware, Claims struct, require_role()

craig-api

AppState, ApiServer::router() (middleware stack), ApiServer::serve(), /healthz

DevStack

docker-compose.yml, postgres init SQL, RabbitMQ definitions, Keycloak realm with test users

CI/CD

6-stage GitLab CI pipeline (lint, test, build, integration, e2e, deploy) in .gitlab-ci.yml

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/)

Verification

  1. docker compose up -d — PostgreSQL, RabbitMQ, and Keycloak start healthy

  2. cargo build — workspace compiles

  3. cargo test — all unit tests pass

  4. cargo clippy — -D warnings — no lint warnings

Phase 2 — Rules and Policy Engine

Status: Completed

Service: craig-rules

Property Value

Binary

services/craig-rules/

Port

8001

Database

craig_rules

Key dependencies

zen-engine 0.54, workspace crates

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

GET

/v1/rules/sets

List all rule sets (paginated)

any

GET

/v1/rules/sets/{id}

Get rule set by ID

any

POST

/v1/rules/sets

Create rule set (upload JDM JSON)

admin

PUT

/v1/rules/sets/{id}

Update rule set (creates new version)

admin

DELETE

/v1/rules/sets/{id}

Deactivate rule set (soft delete)

admin

POST

/v1/rules/evaluate

Evaluate input against a named rule set

any

GET

/v1/rules/evaluations

List past evaluations (paginated, filterable)

supervisor+

POST

/v1/rules/sets/{id}/import

Import JDM from file

admin

GET

/v1/rules/sets/{id}/export

Export JDM as file

admin

GET

/healthz

Health check

(unauthenticated)

RabbitMQ Events

Consumes:

Routing Key Action

case.intake_created

Evaluate intake screening rule set (intake-screening) — payload { investigation_id, referral_id }; context is the new investigation (#1054)

placement.requested

Evaluate placement matching rule set (placement-matching)

(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

rules.evaluated

{ rule_set_name, rule_set_version, evaluation_id, context_type, context_id } (#1130 — a POINTER payload: the input/output documents live once in rule_evaluations, addressable by evaluation_id; pre-#1130 they rode every event and were copied a third time into audit_log by the craig-security wildcard subscriber. Success-only by design — failed attempts write disposition-typed rule_evaluations rows instead of events, ADR-006 §Amendment #1048)

rules.authz_fallback_admit

{ sub, is_service, resource_type, resource_id, action, jurisdiction, reason: "bootstrap_policy_missing" } (#786 — staged on every bootstrap fallback admit: source-attested PolicyMissing + admin-any-action or service-Read/List; resource_id is the gated path id, Uuid::nil() where the gate passes nil, null on the list sites. #1060: sub names the ACTING worker when a Plan E X-Craig-Actor rode the request — the field craig-security’s attribution chain reads — while is_service keeps describing the BEARER whose roles decided the admit)

zen-engine Integration

  • JDM rule sets are stored as JSONB in rule_sets.content and deserialized into zen_engine::model::DecisionContent

  • Compiled Decision objects are cached in an in-memory HashMap and 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 (uses Rc internally)

  • Every evaluation attempt that resolves a rule set writes to rule_evaluations for audit trail per 45 CFR § 1355.53, disposition-typed since #1048: completed rows carry the output; zen runtime failures and worker loss land as runtime_error; a blown CRAIG_RULESEVAL_TIMEOUT_MS dispatch budget (#784) lands as timeout, converging to late_completed if the evaluation later finishes; and JS function nodes are interrupt-bounded (#1046: v2 {source} nodes at CRAIG_RULESFUNCTION_TIMEOUT_MS, v1 string-content nodes at zen’s hard 500ms) so a runaway script terminates as a runtime_error row 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

rulesets/georgia/georgia-ive-eligibility.json

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)

rulesets/georgia/georgia-safety-assessment.json

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

rulesets/georgia/georgia-timeliness.json

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

rulesets/georgia/georgia-intake-screening.json

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)

rulesets/georgia/georgia-placement-matching.json

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

rulesets/texas/texas-ive-eligibility.json

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)

rulesets/texas/texas-safety-assessment.json

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

rulesets/texas/texas-timeliness.json

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)

rulesets/texas/texas-intake-screening.json

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)

rulesets/texas/texas-placement-matching.json

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

  1. cargo test -p craig-rules — runs 67 integration tests validating all 10 rule sets (5 Georgia + 5 Texas) against zen-engine

  2. cargo run -p craig-rules — service starts, connects to DB and RabbitMQ

  3. curl http://localhost:8001/healthz — returns {"status":"ok"}

  4. Obtain Keycloak token and call POST /v1/rules/evaluate with sample input

  5. Verify rule_evaluations table contains audit record

Phase 3 — Case Management Service

Status: Completed

Service: craig-cases

Property Value

Binary

services/craig-cases/

Port

8002

Database

craig_cases

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

POST

/v1/cases/referrals

Create hotline intake/referral

caseworker+

GET

/v1/cases/referrals

List referrals (paginated, filterable by county)

caseworker+

GET

/v1/cases/referrals/{id}

Get referral detail with allegations

caseworker+

POST

/v1/cases/referrals/{id}/allegations

Add allegation to referral

caseworker+

POST

/v1/cases/investigations

Open investigation from referral

caseworker+

GET

/v1/cases/investigations

List investigations (filterable by worker, status)

caseworker+

GET

/v1/cases/investigations/{id}

Get investigation detail

caseworker+

PUT

/v1/cases/investigations/{id}

Update investigation status/disposition

caseworker+

POST

/v1/cases/investigations/{id}/safety-assessment

Submit safety assessment (calls Rules Engine)

caseworker+

POST

/v1/cases/cases

Open case from investigation

caseworker+

GET

/v1/cases/cases

List cases (filterable by worker, status, county)

caseworker+

GET

/v1/cases/cases/{id}

Get case detail

caseworker+

PUT

/v1/cases/cases/{id}

Update case (status, assignment, ICWA flag)

caseworker+

GET

/v1/cases/cases/{id}/household

Get household members

caseworker+

POST

/v1/cases/cases/{id}/household

Add household member

caseworker+

POST

/v1/cases/cases/{id}/plans

Create case plan

caseworker+

GET

/v1/cases/cases/{id}/plans

List case plans

caseworker+

PUT

/v1/cases/plans/{id}

Update case plan

caseworker+

PUT

/v1/cases/plans/{id}/approve

Supervisor approval/countersign

supervisor+

POST

/v1/cases/plans/{id}/tasks

Add task to case plan

caseworker+

PUT

/v1/cases/tasks/{id}

Update task status

caseworker+

POST

/v1/cases/cases/{id}/contacts

Record contact/visitation

caseworker+

GET

/v1/cases/cases/{id}/contacts

List contacts

caseworker+

POST

/v1/cases/cases/{id}/court-orders

Record court order

caseworker+

GET

/v1/cases/cases/{id}/court-orders

List court orders

caseworker+

POST

/v1/cases/contacts/{id}/attachments

Upload attachment to contact

caseworker+

GET

/v1/cases/contacts/{id}/attachments

List contact attachments

caseworker+

GET

/v1/cases/contacts/{id}/attachments/{aid}

Download attachment

caseworker+

DELETE

/v1/cases/contacts/{id}/attachments/{aid}

Delete attachment

caseworker+

GET

/v1/cases/persons

Search persons (name, DOB, SSN last-four)

caseworker+

POST

/v1/cases/persons

Create person record

caseworker+

GET

/v1/cases/persons/{id}

Get person detail

caseworker+

PUT

/v1/cases/persons/{id}

Update person record

caseworker+

RabbitMQ Events

Publishes:

Routing Key Payload

case.referral_created

{ referral_id, county, priority, icwa_flag }

case.intake_created

{ investigation_id, referral_id } — triggers Rules Engine intake screening rule set

case.investigation_closed

{ investigation_id, disposition }

case.created

{ case_id, case_number, county, assigned_worker }

case.updated

{ case_id, field, old_value, new_value }

case.closed

{ case_id, closure_reason }

case.plan_created

{ case_plan_id, case_id, permanency_goal }

case.plan_approved

{ case_plan_id, case_id, approved_by } — supervisor countersign; promotes a draft plan to active (#821)

case.plan_superseded

{ case_plan_id, case_id, superseded_by } — the previously-active plan an approval displaced (#821)

case.plan_updated

{ case_plan_id, case_id, status } (#821)

case.task_created

{ task_id, case_plan_id, responsible } (#821)

case.task_updated

{ task_id, case_plan_id, status } (#821)

case.task_deleted

{ task_id, case_plan_id } (#821)

case.person_created

{ person_id } — no PII in the payload (#821)

case.person_updated

{ person_id } — no PII in the payload (#821)

case.person_pii_exported

{ person_ids, person_count, include_full_ssn, requestor, purpose } — the federal-export READ-audit (#1064 / ADR-051); UUIDs only, no PII. The SOLQ custody release (#1463 / ADR-065 §D2) rides the SAME key discriminated by purpose = ssa_solq_screening, extended with { screening_run_id, screening_member_id, requested_by }

case.person_ssn_release_refused

{ person_id, requestor, purpose, refusal_reason: digest_mismatch|digest_version_rotated|missing_ssn, screening_run_id, screening_member_id, requested_by } — the value-bound custody release’s categorical refusal audit (#1463 / ADR-065 §D2); UUIDs + reason token only, never a digest

case.person_ssn_admin_action

{ person_id, action: revoked|cleared, reason } — the revoke/clear reason’s durable audit sink (#1064 / ADR-051; worker-authored text, kept out of tracing)

Consumes:

Routing Key Action

rules.evaluated (context_type=safety-assessment)

Store safety assessment result, update investigation

placement.created

Update case record with current placement info

eligibility.evaluated

Link eligibility determination to case

Rules Engine Integration

  • Safety assessment: When a caseworker submits a safety assessment, the service calls POST /v1/rules/evaluate with 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 Authorization bearer AND the Plan E X-Craig-Actor header together via one RelayAuth value — so when craig-web mediates, rules scopes the RuleEvaluation × Create gate on the acting WORKER and rule_evaluations.evaluated_by names 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’s rule_evaluation service create row 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

  1. cargo run -p craig-cases — service starts on port 8002

  2. Create a referral via POST /v1/cases/referrals — verify 200 response

  3. Open investigation → submit safety assessment → verify Rules Engine evaluation recorded

  4. Open case → create case plan → verify the plan persists and supervisor approval countersigns it

  5. 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

services/craig-placement/

Port

8003

Database

craig_placement

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

GET

/v1/placement/homes

Search foster homes (county, capacity, type, age range)

caseworker+

GET

/v1/placement/homes/{id}

Get foster home detail with training records

caseworker+

POST

/v1/placement/homes

Create foster home record

supervisor+

PUT

/v1/placement/homes/{id}

Update foster home (license status, capacity)

supervisor+

POST

/v1/placement/homes/{id}/training

Record training completion

caseworker+

POST

/v1/placement/placements

Create placement (assigns child to home)

caseworker+

GET

/v1/placement/placements

List placements (filterable by case, child, home, status)

caseworker+

GET

/v1/placement/placements/{id}

Get placement detail

caseworker+

PUT

/v1/placement/placements/{id}

Update placement (end, update findings)

caseworker+

GET

/v1/placement/placements/{id}/history

Placement history timeline for a child

caseworker+

POST

/v1/placement/kinship

Record kinship option evaluation

caseworker+

GET

/v1/placement/kinship?case_id={id}

List kinship options for a case

caseworker+

GET

/v1/placement/matching?child_id={id}

Matching search (capacity, needs, ICWA, sibling)

caseworker+

RabbitMQ Events

Publishes:

Routing Key Payload

placement.created

{ placement_id, case_id, child_id, foster_home_id?, placement_type, status, started_at } — a placement ROW exists (any status); lifecycle/audit signal, NOT the billing trigger (#979)

placement.activated

craig_placement_contracts::events::PlacementActivatedPayload{ placement_id, case_id, child_id, foster_home_id?, placement_type, started_at }; emitted when a placement becomes financially active (created active, or planned → active). Shared typed contract compiled by producer AND the craig-financial consumer (#979)

placement.ended

craig_placement_contracts::events::PlacementEndedPayload{ placement_id, end_reason?, ended_at? }; emitted when a placement transitions to ended. #1123: ended_at is the ROW’s business end instant (operators record endings after the fact, so the envelope’s staging timestamp differs on every backdated end — the event record itself now carries the terminal fact); present-only, so pre-#1123 envelopes degrade, never fail. Shared typed contract compiled by producer AND the craig-financial consumer since #1070 F9 (it replaced an ad-hoc producer-side json! whose end_reason the consumer silently dropped); end_reason is OMITTED when the row has none, and a consumer reading an older envelope degrades to reason-blind behavior, never a deserialization failure

foster_home.license_expiring

{ foster_home_id, expires_at }

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

case.created

Seed the case_assignments projection (revision 0; #1084: the supervisor half rides along) the create handlers derive assigned_worker_sub from, and heal rows born while the case was unknown (#1213)

case.assignment_changed

Upsert case_assignments (full (worker, supervisor) pair snapshot, #1084) and reassign the denormalized assigned_worker_sub on placements/kinship_options under the #1214 revision guard

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

  1. Create foster home → verify capacity tracking

  2. Create placement → verify placement.created event published (and placement.activated when created active / on planned → active)

  3. Verify CTW timeliness monitoring triggers when days approach 60

  4. Verify matching endpoint returns homes filtered by capacity and needs

Phase 5 — Data Exchange Service

Status: Completed

Service: craig-exchange

Property Value

Binary

services/craig-exchange/

Port

8004

Database

craig_exchange

Dependencies

Phase 1 + Phase 2

Key dependencies

reqwest, workspace crates

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

GET

/v1/exchange/partners

List exchange partners

admin

POST

/v1/exchange/partners

Configure exchange partner

admin

PUT

/v1/exchange/partners/{id}

Update partner configuration

admin

POST

/v1/exchange/partners/{id}/test

Test connectivity to partner

admin

GET

/v1/exchange/agreements

List data sharing agreements

admin

POST

/v1/exchange/agreements

Create data sharing agreement

admin

PUT

/v1/exchange/agreements/{id}

Update agreement

admin

POST

/v1/exchange/send

Trigger outbound exchange

caseworker+

GET

/v1/exchange/transactions

List exchange transactions (filterable)

supervisor+

GET

/v1/exchange/transactions/{id}

Get transaction detail with payloads

supervisor+

POST

/v1/exchange/transactions/{id}/retry

Retry failed transaction

admin

GET

/v1/exchange/icpc

List ICPC requests

icpc_coordinator+

POST

/v1/exchange/icpc

Create ICPC request

icpc_coordinator+

GET

/v1/exchange/icpc/{id}

Get ICPC request detail

icpc_coordinator+

PUT

/v1/exchange/icpc/{id}

Update ICPC request (status transitions)

icpc_coordinator+

POST

/v1/exchange/icpc/{id}/home-study

Submit home study results

icpc_coordinator+

GET

/v1/exchange/icpc/{id}/home-study

Get home study detail

icpc_coordinator+

POST

/v1/exchange/icpc/{id}/attachments

Upload attachment (100A, medical records)

icpc_coordinator+

GET

/v1/exchange/icpc/{id}/attachments

List attachments

icpc_coordinator+

GET

/v1/exchange/icpc/{id}/attachments/{aid}

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

cwca

Child Welfare Contributing Agencies

§ 1355.52(e)(1) — mandatory

financial

Title IV-B / IV-E payment systems

§ 1355.52(e)(2) — mandatory

medicaid

Medicaid eligibility systems

§ 1355.52(e)(3) — mandatory

can

Child Abuse and Neglect systems

§ 1355.52(e)(4) — mandatory if applicable

tanf

Title IV-A TANF systems

§ 1355.52(e)(5) — mandatory if applicable

child_support

Title IV-D child support systems

§ 1355.52(e)(6) — mandatory if applicable

external

External data collection systems

§ 1355.52(e)(7) — mandatory if applicable

court

Court systems

§ 1355.52(f)(1) — to the extent practicable

education

Education systems

§ 1355.52(f)(2) — to the extent practicable

health

Health agency systems

§ 1355.52(f)(3) — to the extent practicable

tribal

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

exchange.sent

{ transaction_id, partner_type, exchange_type, record_count }

exchange.received

{ transaction_id, partner_type, exchange_type, record_count }

exchange.failed

{ transaction_id, partner_type, error_message }

icpc.created

{ icpc_request_id, case_id, child_id, direction, sending_state, receiving_state }

icpc.status_changed

{ icpc_request_id, old_status, new_status }

Consumes:

Routing Key Action

case.created

Trigger outbound notifications to relevant partners

placement.created

Trigger Medicaid eligibility notification, education enrollment

eligibility.evaluated

Trigger financial system notification

Verification

  1. Configure a test exchange partner → verify connectivity test endpoint

  2. Trigger outbound exchange → verify transaction logged with payload

  3. Simulate inbound exchange → verify event published to RabbitMQ

  4. Retry failed transaction → verify status updated

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 with toContainText('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-lib usage, 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

  1. Devstack starts cleanly with expanded seed data — all services healthy

  2. All existing e2e tests still pass with new seed data

  3. New negative/error e2e tests pass

  4. ER diagrams render correctly in Antora site

  5. Developer guide is sufficient for a new contributor to add an endpoint end-to-end

  6. Deployment guide covers all configuration needed for a fresh installation

Phase 6 — Financial and Claims Service

Status: Complete

Service: craig-financial

Property Value

Binary

services/craig-financial/

Port

8005

Database

craig_financial

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

GET

/v1/financial/payments

List payments (filterable by case, home, status, period)

eligibility_worker+

GET

/v1/financial/payments/{id}

Get payment detail with rate breakdown

eligibility_worker+

POST

/v1/financial/payments/calculate

Calculate payment for placement period

eligibility_worker+

PUT

/v1/financial/payments/{id}/approve

Approve payment for issuance

supervisor+

PUT

/v1/financial/payments/{id}/issue

Record disbursement (approved → issued)

supervisor+

PUT

/v1/financial/payments/{id}/clear

Record reconciliation clearance (issued → cleared)

supervisor+

PUT

/v1/financial/payments/{id}/void

Deliberate manual void of an UNDISBURSED payment (pending/approved → voided; mandatory row-only reason; issued/cleared → typed 400 disbursed-payment-void — disbursed money is corrected by adjustment, never clawed back, #1028)

supervisor+

POST

/v1/financial/payments/{id}/adjustments

Request payment adjustment

eligibility_worker+

PUT

/v1/financial/adjustments/{id}/approve

Approve/deny adjustment

supervisor+

GET

/v1/financial/rates

Get current rate table

eligibility_worker+

POST

/v1/financial/rates

Create/update rate table

admin

GET

/v1/financial/claims

List claiming records

eligibility_worker+

POST

/v1/financial/claims/generate

Generate claiming report for period

supervisor+

GET

/v1/financial/claims/{id}

Get claiming detail with cost allocation

eligibility_worker+

PUT

/v1/financial/claims/{id}/submit

Submit claiming report

admin

RabbitMQ Events

Publishes:

Routing Key Payload

financial.payment_created

{ payment_id, case_id?, child_id, amount, ive_eligible } (case_id OMITTED — not null — when the payment has no case: post-case subsidy rows, #1068; craig-security’s resource extraction is presence-based, #1090)

financial.payment_approved

{ payment_id, case_id, child_id, amount, ive_eligible, user_id } — staged in the approval transaction (#978); user_id is the acting worker’s sub

financial.payment_issued

{ payment_id, case_id, child_id, amount, ive_eligible, user_id } — staged in the issue (disbursement) transaction (#978)

financial.payment_cleared

{ payment_id, case_id, child_id, amount, ive_eligible, user_id } — staged in the clearance transaction (#978)

financial.payments_voided

{ payment_ids, count, cause, placement_id?, agreement_id?, month? } — one bulk event per set-based void (#953), reshaped by #1068: cause is placement_ended (scope: placement_id) or subsidy_reconciliation (scope: agreement_id + month — an ISO DATE, the month’s first day; the run-level subsidy_generation_completed.month is the YYYY-MM token — two deliberate encodings: row-level events carry the DB key, run-level the operator token); emitted only when the void changed rows. #1029: craig-security parses it explicitly as one update on payment per void pass (the event’s own grain; resource id per the #1090 payment family — placement_id for the placement cause, none for the reconcile cause; previously the unknown-key fallback recorded a raw payments_voided/financial split), and craig-reporting deliberately routes it (and every non-payment-lifecycle financial type) past the payment-shape validator

financial.payment_voided

{ payment_id, case_id?, child_id, created_by } — ONE attributed event per deliberate manual void (#1028; the system bulk voids keep payments_voided above). Ids + actor only: no money (the #1078 convention) and deliberately NO reason text — the operator’s mandatory free-text reason is protected-note class and lives on the payment row (void_reason), never the wire (#1095 doctrine). created_by is the acting worker’s sub (the attribution chain reads it first). craig-security parses update on payment (the #1090 payment family resolves payment_id); craig-reporting routes it past the payment-shape validator (ids-only)

financial.payment_generation_skipped

{ placement_id, case_id, child_id, reason } — a placement.activated no-payment fail-safe fired (#1058; the #979 conditions): reason is missing_dob | unknown_person | missing_foster_home | dob_after_start | no_applicable_rate (#1359 extended the #1058 set: the DOB-after-start record defect and the no-rate-band CONFIG gap were the last two silent-zero-payment paths). Staged on the attempt tx (exactly once per consumed activation envelope). craig-reporting ingests it into data_quality_issues (issue_type no_payment_activation, severity warning, anchored on the placement; no_applicable_rate maps to field rate_configuration so the queue separates record fixes from rate-table provisioning — unknown future tokens still land fail-open with the token as the field); craig-security parses create on quality_issue (the payment family resolves the denormalized case_id — no payment exists)

financial.claim_submitted

{ claiming_record_id, period, ffp_claimed }

financial.claim_accepted

{ claiming_record_id, period, created_by } — federal acceptance recorded on a IV-E claiming record (#1079): ids/period/actor only, no money (the claim’s figures were announced at submit; acceptance records the federal decision). created_by is the acting worker’s sub — the #1095 attribution chain reads it, so the audit row names the worker. Both claim types resolve claiming_record_id through craig-security’s claim id family (#1079 — previously claim_submitted fell to the generic family, whose keys its payload never carried, so those audit rows recorded a NULL resource id)

financial.adjustment_created

{ adjustment_id, payment_id, created_by } — a payment correction was requested (#1078; pre-#1078 adjustments staged NO events, so corrections were invisible to the audit tier). Staged in the same ADR-062 claim transaction as the insert — a replay stages nothing. The free-text reason (PII risk) and amount (money) deliberately never ride the bus. craig-security parses create on payment_adjustment, resolving adjustment_id BEFORE the denormalized payment_id (the adjustment id family)

financial.adjustment_resolved

{ adjustment_id, payment_id, resolution, created_by } — one per terminal transition (#1078): resolution is approved | denied, created_by the RESOLVING worker’s sub. Both terminal paths stage it on the ADR-062 §G3 status-pinned row (approve additionally under the F-015 payment lock), so a concurrent double-resolve stages exactly one. Parsed as update on payment_adjustment

financial.subsidy_generation_completed

{ run_id, month, initiator, generated, already_existed, derivation_mismatch, skipped_exclusivity, skipped_multi_program, skipped_superseded, skipped_not_active, skipped_before_payments_begin, skipped_before_cutover, errors, voided_stale, queue_drained } — one per generation run (#1068/ADR-053); counters only, no PII; initiator is scheduled | manual. #1069 M1 adds skipped_superseded (birth-month claims demoted by an incumbent); #1070 F2 adds skipped_before_payments_begin (months before a guardianship agreement’s stored money boundary); #1071 D5 adds skipped_before_cutover (months before an imported agreement’s payment_cutover_month — SHINES owns them); #1105 adds queue_drained (the #1081 reconcile-queue pairs repaired this run — the report carried it from ADR-054 U1 but the event omitted it). All additive, so older consumers are unaffected; the payload is test-pinned as a superset of the serialized GenerationReport (it additionally carries initiator), so future report counters must land on the event in the same change

financial.subsidy_agreement_created

{ agreement_id, program, effective_from, initial_status, created_by, approval_level, client_request_id } — native enrollment (#1069 M2, generalized by #1070/ADR-056): #1070 adds the ADDITIVE initial_status key (active for the one-shot ERR arm, pending for the two-step guardianship arms — parser-safe, the craig-security arm is unchanged) and makes effective_from arm-specific (ERR: the coverage start; guardianship: the signing date). craig-security parses it as a create on subsidy_agreement and resolves agreement_id as the resource (client_request_id is deliberately outside the id family — the audit row names the enrollment, never the idempotency key); no money and no evidence keys in the payload. Deployment ordering: the craig-security parser arm deploys BEFORE or WITH the emitter (parser-first), so no created event is ever audited through the unknown-type fallback (which would record it with a raw action/resource split instead of create/subsidy_agreement)

financial.subsidy_agreement_transitioned

{ agreement_id, interval_id, from_status, to_status, business_date, created_by, reason_code? } — one per status transition (#1095/ADR-054 U2): the NEW head interval’s identity + the from/to status tokens + the business date; ids/tokens/dates only, the interval’s protected note never rides. reason_code is present-only (#1090). TWO emitters share the type: the human mutation surface (created_by = the acting worker’s sub) and the #1096 sweep’s enforcement legs, which ADD run_id (run correlation) and — leg-1 suspensions — trigger_review_id (deliberately NOT review_id: attribution stays in the agreement id family, the trigger is correlation-only; created_by = system for scheduled legs or the executor’s sub). craig-security parses update on subsidy_agreement, resolving agreement_id

financial.subsidy_terms_amended

{ term_id, agreement_id, previous_term_id, effective_from, created_by } — one per appended terms revision (#1095): term_id IS the new revision and craig-security’s subsidy id family resolves it BEFORE agreement_id, so the audit row names the revision, not its parent; previous_term_id carries the closed predecessor for lineage. No money fields ride the bus. Parsed as update on subsidy_agreement

financial.subsidy_review_scheduled

{ review_id, agreement_id, review_type, cycle, due_on, created_by } — one per NEW open review slot (#1095): chain identity (type + cycle) + the statutory due date. Parsed as create on subsidy_review, resolving review_id

financial.subsidy_review_completed

{ review_id, agreement_id, review_type, outcome, completed_on, created_by, resulting_term_id?, resulting_interval_id? } — one per completed review (#1095); the resulting_* linkage ids are present-only (a continued paper review links nothing). Parsed as update on subsidy_review

financial.subsidy_review_rescheduled

{ review_id, successor_review_id, agreement_id, review_type, cycle, new_due_on, reason, created_by } — one per supersession (#1095): review_id is the SUPERSEDED slot (the audited resource, resolved first), successor_review_id its same-cycle replacement carrying new_due_on; reason is the audited supersession reason (operational text, never case narrative). Parsed as update on subsidy_review

financial.subsidy_review_sweep_completed

{ run_id, as_of, state, initiated, mode_materialize, mode_auto_suspend, mode_auto_terminate, mode_per_diem_handoff, leg1_actionable, leg2_actionable, leg3_actionable, materialized, suspended, terminated, handoff_terminated, leg3_moved_children, skipped_drift, errors } — one per finished sweep run (#1096/ADR-054 U4); counters only, no PII, no agreement identities (craig-security resolves NO resource id — the generation-completed shape). #1069 M3 adds mode_per_diem_handoff, leg3_actionable, handoff_terminated, leg3_moved_children (serde-defaulted for pre-leg consumers). Leg-3 ENFORCEMENT terminations ride the financial.subsidy_agreement_transitioned row above with run_id for run correlation — attribution stays on the agreement

financial.subsidy_agreement_imported

{ agreement_id, child_id, program, head_status, reviews_materialized, import_batch_id, cutover_month, created_by, approval_level } — ONE per record MATERIALIZED at batch finalize (#1071/ADR-057 D11); created_by is the FINALIZING operator (the audit sink’s actor chain reads it); NO external_reference (unclassified source id — DB-only), no notes, no money. craig-security parses it as an import on subsidy_agreement resolving agreement_id (the parser arm deployed with MR-A, parser-first)

financial.subsidy_import_batch_finalized

{ batch_id, expected_count, materialized, superseded } — one counts-only summary per FINALIZED batch (#1071 D11), staged in the same transaction that flips the batch finalized; craig-security parses it as system on subsidy_import with NO resource id (the generation-completed shape)

Consumes:

Routing Key Action

placement.activated

Generate the first-period payment (#979): prorated from the placement’s started_at, age-band rate from the child’s authoritative craig-cases DOB as of that date; missing foster home / missing DOB = logged no-op. Planned placements do not bill (placement.created is no longer consumed)

placement.ended

Void undisbursed payments for the placement (decoded via the shared PlacementEndedPayload since #1070 F9). Reason-aware WARN arms (#1070, WARN-only — automation is #1113): a guardianship ending names the handoff steps for a stranded active ERR and the ready-to-activate pending family agreement (or the support gap when none exists); a non-guardianship ending with a pending sg/nrsg agreement names withdraw/decline; an absent end_reason degrades to reason-blind behavior

eligibility.evaluated

Update IV-E eligibility flag on existing payments

rules.evaluated (context_type=rate)

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).

Verification

  1. Create rate table → create placement → verify payment auto-calculated

  2. Approve and issue payment → verify event published

  3. Generate claiming report → verify IV-E and non-IV-E amounts computed

  4. Verify FFP rate applied correctly (50% for eligible)

Phase 7 — Reporting and Data Quality Service

Status: Complete

Service: craig-reporting

Property Value

Binary

services/craig-reporting/

Port

8006

Database

craig_reporting

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

GET

/v1/reporting/quality/dashboard

Data quality dashboard (metrics, trends, critical errors)

supervisor+

GET

/v1/reporting/quality/issues

List data quality issues (filterable)

caseworker+

PUT

/v1/reporting/quality/issues/{id}/resolve

Mark issue as resolved

caseworker+

POST

/v1/reporting/afcars/generate

Generate AFCARS submission for period

admin

GET

/v1/reporting/afcars

List AFCARS submissions

supervisor+

GET

/v1/reporting/afcars/{id}

Get submission detail with validation results

supervisor+

PUT

/v1/reporting/afcars/{id}/approve

Approve for transmission

admin

POST

/v1/reporting/afcars/{id}/transmit

Transmit to ACF

admin

POST

/v1/reporting/ncands/generate

Generate NCANDS submission for fiscal year

admin

GET

/v1/reporting/ncands

List NCANDS submissions

supervisor+

PUT

/v1/reporting/ncands/{id}/approve

Approve for transmission

admin

POST

/v1/reporting/ncands/{id}/transmit

Transmit to ACF

admin

RabbitMQ Events

Consumes:

Routing Key Action

case.*

Validate case data against federal quality standards

placement.*

Validate placement data for AFCARS completeness

eligibility.*

Validate eligibility determination data

financial.*

Validate financial data for claiming accuracy

Publishes:

Routing Key Payload

reporting.quality_issue_detected

{ issue_id, source_service, source_record_id, severity }

reporting.afcars_transmitted

{ submission_id, reporting_period, record_count }

reporting.ncands_transmitted

{ submission_id, reporting_year, record_count }

Rules Engine Integration

  • Continuous data quality monitoring: The Rules Engine evaluates incoming data events against federal and state data quality standards, generating data_quality_issues records 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

  1. Create case and placement data → verify data quality monitoring detects missing fields

  2. Generate AFCARS submission → verify record count and validation errors

  3. Approve and transmit → verify transmitted status and afcars_transmitted event

  4. Check data quality dashboard → verify metrics and trends

Phase 8 — Security and Compliance Service

Status: Complete

Service: craig-security

Property Value

Binary

services/craig-security/

Port

8007

Database

craig_security

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

GET

/v1/security/audit

Query audit log (filterable by user, resource, date range)

admin

GET

/v1/security/audit/user/{user_id}

Audit trail for specific user

admin

GET

/v1/security/audit/resource/{type}/{id}

Audit trail for specific resource

supervisor+

GET

/v1/security/reviews

List security reviews

admin

POST

/v1/security/reviews

Schedule security review

admin

PUT

/v1/security/reviews/{id}

Update review status and findings

admin

GET

/v1/security/archive

List archive records

admin

POST

/v1/security/archive/run

One bounded archive-then-prune pass (#1129: consent-gated 403 while RETENTION_ARCHIVE__ENABLED=false; returns per-table reports + more)

admin

POST

/v1/security/archive/purge

Destroy expired archives in bounded slices (?limit=, 1..=100; D14 predicate; consent-gated)

admin

GET

/v1/security/nist

List NIST control mappings

admin

PUT

/v1/security/nist/{control_id}

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

security.review_due

{ review_id, review_type, scheduled_date }

security.archive_completed

{ archive_id, record_count }

security.breach_detected

{ details, severity, detected_at }

Verification

  1. Perform operations in other services → verify audit log entries appear

  2. Schedule security review → verify reminder events

  3. Execute archiving → verify records archived and purge eligibility tracked

  4. Query audit log by user/resource → verify correct filtering

Phase 9 — CLI Client (craig)

Status: Complete

Binary: craig-cli

Property Value

Binary

services/craig-cli/

Dependencies

Phase 1 + any service APIs

Key dependencies

clap 4, reqwest, tabled, serde_json, dirs

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.toml for multi-jurisdiction deployments (different API URLs, Keycloak realms)

  • Shell completion: generated via clap_complete for bash, zsh, fish

  • Token caching: Keycloak tokens cached in ~/.config/craig/token.json, auto-refreshed on expiry

  • Pagination: --page, --per-page, --all flags 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

rules.rs — create, list, get, update, delete, evaluate

6

person.rs — create+get, search, update, ICWA flag

4

referral.rs — create+get, list, add allegation

3

investigation.rs — create, update status, safety assessment

3

case.rs — create+get, list, update, household, contact+court order

5

plan.rs — create plan, approve (supervisor), add+update task

3

home.rs — create+get, list, record training

3

placement.rs — create, update, history

3

exchange.rs — create+list partners, send, list transactions

3

agreement.rs — create, update (draft→active)

2

icpc.rs — create, home study, attach+list

3

auth.rs — RBAC: caseworker create/delete rules, supervisor/caseworker approve plan

4

workflow.rs — full lifecycle: persons→referral→allegation→investigation→case→household→plan→approve

1

Total

43

Verification

  1. cargo build -p craig-cli — compiles

  2. cargo test -p craig-cli --lib — 28 unit tests pass

  3. cargo test -p craig-cli --test cli — 43 integration tests pass (requires devstack)

  4. cargo clippy -p craig-cli --tests — -D warnings — clean

  5. craig login → verify token obtained and cached

  6. craig rules list --format json → verify JSON output

  7. 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

services/craig-web/

Framework

Axum + Askama (server-side HTML) + htmx + Alpine.js (BFF pattern)

Dependencies

Phase 1 shared crates + all service REST APIs

Key dependencies

axum 0.8, askama 0.13, tower-sessions 0.14, reqwest 0.12

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-ui client)

  • 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-data toggles 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-reporting API

Verification

  1. cargo build -p craig-web — compiles

  2. Login via Keycloak → verify session created, redirect to dashboard

  3. Navigate all CRUD pages → verify data loads from REST APIs

  4. Create/update records via forms → verify flash messages and redirects

  5. Verify role-based navigation (admin features hidden for caseworker)

  6. 404 page renders for invalid routes

Phase 11 — Constituent and Provider Portals

Status: Planned

Application: craig-portals

Property Value

Application

services/craig-portals/

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

  1. Login as family user → verify only assigned case visible

  2. Login as foster parent → verify only placed children visible

  3. Login as provider → verify only contracted cases visible

  4. Submit service record → verify it appears in caseworker’s case view

  5. 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

craig-intake (port 8008; stateless edge — no database, per ADR-017)

Public Web Form

Single-page Alpine.js form (/report) — reporter info, incident details, children/adults arrays, conditional sections

Partner API

/partner/v1/ with API key authentication for third-party system integration

SDK (Rust)

craig-intake-sdk crate (AGPL-3.0) — typed client with builder pattern, retry logic, optional JWS signing

SDK (TypeScript)

@craig/intake-sdk npm package — typed client with builder, JWS signing via jose

SDK (Python)

craig-intake-sdk PyPI package — async client (httpx), JWS signing via joserfc

Browser signing

craig-sign.js ES module — WebCrypto ECDSA P-256 key generation and JWS signing (zero dependencies)

JWS Verification

Detached ES256 signature verification on partner submissions, CRAIG-hosted signer key registry (ADR-010)

Web BFF

/report routes (public, no auth) + /intake routes (authenticated caseworker review) + /report/keys (key registration)

CLI

craig intake command group (8 subcommands)

Database

Table Description

public_reports

Submitted reports with reporter info, concern details, children/adults JSONB, narrative JSONB, review status, optional JWS signature

api_keys

Partner API keys with name, hashed key, active flag

report_attachments

File metadata (name, type, size, object key) linked to reports

signer_keys

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 on public_reports, CreateReportInput struct

  • 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_keys table), 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

  1. Submit a public report via form, verify conditional fields and confirmation code

  2. Submit via partner API, verify all Phase 2 fields stored and returned

  3. Upload attachments to a report, verify file stored and listed

  4. Claim and convert a report to referral, verify cross-service call

  5. Screen out a report, verify status transition and reason

  6. 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

craig-client

Tauri 2.0 application — desktop (Windows, macOS, Linux) + mobile (iOS, Android)

craig-sync

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 version column + updated_at cursor. 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}

  • version column (monotonic counter) on synced tables

  • Batch sync endpoint for efficient pull of multiple entity types

Verification

  1. Create contact note offline, reconnect, verify sync to server

  2. Modify same case from web UI and client simultaneously, verify conflict resolution

  3. Verify SQLCipher encryption — local DB unreadable without key

  4. Verify 72-hour offline token expiry forces re-authentication

  5. 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.

Edit this page · latest