Data Exchange Adapters
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Adapter factory + dispatch infrastructure |
Done (2026-03-21) — MR !46 |
2 |
Mock exchange server (devstack) |
Done (2026-03-21) — MR !46 |
3 |
CWCA adapter |
Done (2026-03-21) — MR !46 |
4 |
Financial payments/claims adapter |
Done (2026-03-21) — MR !46 |
5 |
Medicaid eligibility adapter |
Done (2026-03-21) — MR !46 |
6 |
Child abuse/neglect adapter |
Done (2026-03-21) — MR !46 |
7 |
TANF/Title IV-A adapter |
Done (2026-03-21) — MR !46 |
8 |
Child support/Title IV-D adapter |
Done (2026-03-21) — MR !46 |
9 |
External data collection adapter |
Done (2026-03-21) — MR !46 |
10 |
Court system adapter |
Done (2026-03-21) — MR !46 |
11 |
Education system adapter |
Done (2026-03-21) — MR !46 |
12 |
Health agency adapter |
Done (2026-03-21) — MR !46 |
13 |
Tribal entity adapter |
Done (2026-03-21) — MR !46 |
14 |
Inbound webhook handler |
Done (2026-03-21) — MR !46 |
15 |
CLI + Web UI for adapter management |
Done (2026-03-21) — MR !46 |
16 |
Documentation, issue, commit, push, MR |
Done (2026-03-21) — MR !46 |
Epic: &12
Issues: #75–#85
Branch: feature/exchange-adapters
Context
CCWIS regulations (45 CFR 1355.52(e-f)) mandate bi-directional data exchange with 11 external systems.
The craig-exchange service already provides the ExchangeAdapter trait, NoopAdapter, partner/agreement/transaction CRUD, state machines, ICPC workflows, and event publishing.
What’s missing is the actual adapter implementations that transform CRAIG’s internal data into each external system’s format.
Architecture: Format Adapters with Mock Server
Each adapter is a format transformer — it converts CRAIG’s internal JSON into the target system’s wire format (and vice versa for inbound).
The send() method transforms the payload, then POSTs it to the partner’s configured endpoint_url.
In devstack, a mock HTTP server validates payloads and returns realistic responses.
This approach delivers:
-
Real data mapping code (the valuable part for federal compliance)
-
Testable transformations (unit tests on mapping functions)
-
End-to-end integration tests via mock server
-
Documented schemas that a real partner could implement against
Production connectivity is deployment-time config — partners provide real endpoint URLs, auth credentials, and format preferences.
Scope
In scope:
-
Adapter factory: dispatch
ExchangeAdapterimpl by partner type -
11 adapter modules with outbound data transformation
-
Inbound webhook endpoint for receiving data from external systems
-
Mock exchange server in devstack (validates payloads per adapter type)
-
Unit tests for each mapping module
-
Integration tests exercising full send/receive round-trip via mock
-
CLI commands for adapter testing
-
Web UI adapter status page
Out of scope:
-
Real external system connectivity (deployment-time config)
-
Adapter certification with state agencies (operational process)
-
HL7 MLLP transport (adapters use HTTP/REST; HL7 content is serialized over HTTP)
-
SFTP file-based exchange (future enhancement — current architecture is REST)
Design
Adapter Factory
Replace the hardcoded NoopAdapter in main.rs with a factory that selects the adapter based on partner type.
File: services/craig-exchange/src/adapters/mod.rs
/// Adapter factory — returns the appropriate adapter for a partner type.
pub fn adapter_for(partner_type: &str, exchange_format: &str) -> Box<dyn ExchangeAdapter> {
match partner_type {
"cwca_provider" => Box::new(CwcaAdapter::new(exchange_format)),
"financial" => Box::new(FinancialAdapter::new()),
"medicaid" => Box::new(MedicaidAdapter::new()),
"child_abuse_registry" => Box::new(ChildAbuseAdapter::new()),
"tanf" => Box::new(TanfAdapter::new()),
"child_support" => Box::new(ChildSupportAdapter::new()),
"external_data" => Box::new(ExternalDataAdapter::new()),
"court_system" => Box::new(CourtAdapter::new()),
"education" => Box::new(EducationAdapter::new()),
"health_agency" => Box::new(HealthAdapter::new()),
"tribal" => Box::new(TribalAdapter::new()),
_ => Box::new(NoopAdapter),
}
}
The transaction send handler looks up the partner, calls adapter_for(), then calls adapter.send().
Adapter Module Structure
Each adapter lives in its own file under services/craig-exchange/src/adapters/:
adapters/
mod.rs — trait + factory
noop.rs — existing NoopAdapter
cwca.rs — CWCA adapter
financial.rs — Title IV-B/IV-E
medicaid.rs — Medicaid eligibility
child_abuse.rs — Central registry
tanf.rs — TANF/Title IV-A
child_support.rs — Title IV-D
external_data.rs — External data collection
court.rs — Court system
education.rs — Education system
health.rs — Health agency
tribal.rs — Tribal entity
mapping/
mod.rs — shared mapping utilities
person.rs — person data mapping (used by all adapters)
afcars.rs — AFCARS code translation (reuses craig-reference)
ncands.rs — NCANDS code translation (reuses craig-reference)
Adapter Implementation Pattern
Each adapter struct implements ExchangeAdapter and contains a mapping module that transforms CRAIG JSON ↔ external format.
pub struct CwcaAdapter {
format: String, // "json", "xml", "flat_file"
}
impl CwcaAdapter {
pub fn new(format: &str) -> Self {
Self { format: format.to_string() }
}
/// Transform CRAIG internal JSON into CWCA exchange format.
fn transform_outbound(&self, payload: &Value) -> Result<Value, String> {
let exchange_type = payload["exchange_type"].as_str().unwrap_or("case_data");
match exchange_type {
"case_data" => mapping::cwca::map_case_data(payload),
"service_record" => mapping::cwca::map_service_record(payload),
"placement_notification" => mapping::cwca::map_placement(payload),
_ => Err(format!("unsupported CWCA exchange type: {exchange_type}")),
}
}
/// Transform inbound CWCA data into CRAIG internal format.
pub fn transform_inbound(&self, payload: &Value) -> Result<Value, String> {
mapping::cwca::parse_inbound(payload)
}
}
impl ExchangeAdapter for CwcaAdapter {
async fn test_connectivity(&self, endpoint_url: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client.get(format!("{endpoint_url}/health"))
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(format!("status: {}", resp.status()))
}
async fn send(&self, endpoint_url: &str, payload: &Value) -> Result<Value, String> {
let transformed = self.transform_outbound(payload)?;
let client = reqwest::Client::new();
let resp = client.post(endpoint_url)
.json(&transformed)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("HTTP {}: {body}", resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
}
Data Mapping Per Adapter
Each adapter transforms CRAIG’s internal entity JSON into the target format.
The exchange_type field on the transaction determines which mapping function to call.
Shared Person Mapping (used by all adapters)
// mapping/person.rs
pub fn map_person(person: &Value) -> Value {
json!({
"person_id": person["id"],
"first_name": person["first_name"],
"last_name": person["last_name"],
"date_of_birth": person["date_of_birth"],
"gender": person["gender"],
"ssn_last_four": person["ssn_last_four"],
"race": person["race"],
"ethnicity": person["ethnicity"],
})
}
Adapter-Specific Exchange Types and Mappings
| Adapter | Exchange Types | CRAIG Entities Mapped | Target Format |
|---|---|---|---|
CWCA |
|
Cases, contacts, placements, persons |
JSON (configurable per partner) |
Financial |
|
Payments, adjustments, claims, rates, IV-E eligibility |
JSON with AFCARS codes |
Medicaid |
|
Persons, placements, income data |
JSON (FHIR-like structure for modern systems) |
Child Abuse |
|
Referrals, allegations, investigations, dispositions |
JSON with NCANDS codes |
TANF |
|
Cases, household members, income |
JSON |
Child Support |
|
Cases, persons (parents), court orders |
JSON |
External Data |
|
Any entity (generic pass-through with validation) |
JSON |
Court |
|
Cases, court orders, hearing dates |
JSON |
Education |
|
Education records, persons (children), placements |
JSON |
Health |
|
Health records, persons (children), immunizations |
JSON (FHIR-aligned structure) |
Tribal |
|
Cases, persons (with tribal affiliation), placements, ICWA flags |
JSON |
Mock Exchange Server
A lightweight HTTP server in the devstack that validates inbound exchange payloads and returns realistic responses.
File: services/craig-exchange/src/mock_server.rs (or a separate binary/container)
Simpler approach: a single Axum binary added to docker-compose as craig-exchange-mock on port 9090.
// Routes:
// POST /exchange/{adapter_type} — validate payload, return mock response
// GET /health — connectivity test endpoint
// GET /received — list received payloads (for test assertions)
The mock validates:
-
Required fields present per adapter type
-
AFCARS/NCANDS codes valid (for financial/child_abuse adapters)
-
Person data contains required identifiers
-
Returns a realistic
{ "status": "accepted", "reference_id": "…" }response
Docker Compose: Add craig-exchange-mock service on port 9090, configured as a partner endpoint in seed data.
Inbound Webhook
Endpoint: POST /v1/exchange/receive
Authenticated via partner API key (same as intake partner API pattern). The webhook:
-
Identifies the partner from the API key
-
Selects the adapter via
adapter_for(partner_type) -
Calls
adapter.transform_inbound(payload)to normalize to CRAIG format -
Creates an inbound
exchange_transactionrecord -
Publishes
exchange.receivedevent to RabbitMQ -
Returns
202 Acceptedwith transaction ID
pub async fn receive_exchange(
State(app): State<AppState>,
Extension(partner): Extension<ValidatedPartner>, // from API key middleware
Json(payload): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let adapter = adapter_for(&partner.partner_type, &partner.exchange_format);
let normalized = adapter.transform_inbound(&payload)
.map_err(|e| ApiError::BadRequest(format!("invalid payload: {e}")))?;
let tx = store::transactions::create_inbound_transaction(
app.db.inner(), partner.id, &normalized, payload.clone()
).await.map_err(|e| ApiError::Internal(e.to_string()))?;
events::publish_exchange_received(&publisher, tx.id, partner.id).await;
Ok(Json(json!({ "status": "accepted", "transaction_id": tx.id })))
}
Partner Type Extension
The existing partner_type enum needs expansion to cover all 11 adapter types.
Migration: Update CHECK constraint on exchange_partners.partner_type:
ALTER TABLE exchange_partners DROP CONSTRAINT IF EXISTS exchange_partners_partner_type_check;
ALTER TABLE exchange_partners ADD CONSTRAINT exchange_partners_partner_type_check
CHECK (partner_type IN (
'state_agency', 'court_system', 'federal_agency', 'tribal_authority', 'private_provider',
'cwca_provider', 'financial', 'medicaid', 'child_abuse_registry',
'tanf', 'child_support', 'external_data', 'education', 'health_agency'
));
Steps
Step 1: Adapter Factory + Dispatch Infrastructure
Files:
-
services/craig-exchange/src/adapters/mod.rs— addadapter_for()factory, module declarations -
services/craig-exchange/src/adapters/mapping/mod.rs— shared mapping utilities -
services/craig-exchange/src/adapters/mapping/person.rs— person data mapping -
services/craig-exchange/src/adapters/mapping/afcars.rs— AFCARS code translation (delegate tocraig-reference) -
services/craig-exchange/src/adapters/mapping/ncands.rs— NCANDS code translation (delegate tocraig-reference) -
services/craig-exchange/src/api/transactions.rs— updatesend_exchangehandler to use factory instead ofNoopAdapter -
services/craig-exchange/src/main.rs— remove hardcodedNoopAdapterextension -
services/craig-exchange/migrations/— expand partner_type CHECK constraint
The factory replaces the Extension(NoopAdapter) pattern.
Instead of injecting a single adapter, the handler calls adapter_for() per transaction.
Steps 2: Mock Exchange Server
Files:
-
services/craig-exchange-mock/— new minimal Axum binary -
services/craig-exchange-mock/Cargo.toml— dependencies: axum, tokio, serde_json, uuid -
services/craig-exchange-mock/src/main.rs— routes:POST /exchange/{type},GET /health,GET /received -
docker-compose.yml— addcraig-exchange-mockservice on port 9090 -
tools/craig-seed/— seed a partner withendpoint_url: http://craig-exchange-mock:9090/exchange/cwca
Steps 3-13: Individual Adapter Implementations
Each adapter follows the same pattern (see Design section above):
-
Create
services/craig-exchange/src/adapters/{name}.rs -
Implement struct with
ExchangeAdaptertrait -
Create mapping module
services/craig-exchange/src/adapters/mapping/{name}.rs -
Add unit tests for mapping functions
-
Add integration test sending via the adapter to mock server
-
Seed a partner configured for this adapter type
Each adapter has 2-4 exchange types (outbound mapping functions) and 1-2 inbound parsing functions.
Estimated per adapter: ~150-200 lines of mapping code + ~50 lines of tests.
Step 14: Inbound Webhook Handler
Files:
-
services/craig-exchange/src/api/receive.rs— new module withreceive_exchangehandler -
services/craig-exchange/src/store/transactions.rs— addcreate_inbound_transaction()function -
services/craig-exchange/src/api/mod.rs— register routePOST /v1/exchange/receive
Step 15: CLI + Web UI
CLI:
-
craig exchange test-adapter --partner-id UUID— calls test_connectivity via the factory -
craig exchange send --partner-id UUID --type exchange_type --payload-file data.json— enhanced send with adapter selection
Web UI:
-
Adapter status page showing which partners have which adapter type configured
-
Test connectivity button per partner (already exists, just wire through factory)
Files Touched
| File | Change |
|---|---|
|
Factory function, module declarations |
|
11 adapter implementations |
|
Shared + per-adapter mapping modules |
|
Use factory instead of NoopAdapter |
|
New inbound webhook handler |
|
Inbound transaction creation |
|
Remove hardcoded adapter |
|
Expand partner_type constraint |
|
New mock server binary (Cargo.toml + main.rs) |
|
Add mock server container |
|
Enhanced send + test-adapter commands |
|
Adapter status UI |
|
Seed partners for each adapter type |
|
Unit tests for mappings, integration tests for round-trip |
Verification
-
cargo fmt --all+cargo clippy --workspace — -D warnings -
cargo nextest run --workspace --lib— unit tests (mapping tests) -
cargo xtask dev reload(schema + mock server) -
cargo nextest run --workspace— integration tests (adapter round-trip via mock) -
cargo xtask e2e— 5x local runs, all pass -
Verify:
curl -X POST http://localhost:8004/v1/exchange/send -H "Authorization: Bearer $TOKEN" -d '{"partner_id":"…","exchange_type":"case_data","payload":{…}}'returns success -
Verify: mock server received and validated the transformed payload
-
Verify:
curl http://localhost:9090/receivedshows the exchange
Documentation Updates
-
.claude/docs/services.md— add exchange types, inbound endpoint, mock server -
CHANGELOG.adoc— entries under== Unreleased -
.claude/CLAUDE.md— update Phase 5 stats (endpoints, adapters) -
docs/modules/ROOT/pages/roadmap.adoc— tick all 11 adapter items -
docs/modules/ROOT/pages/data-model-exchange.adoc— document exchange types per adapter -
This plan — status to Complete, move to archive