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 ExchangeAdapter impl 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

case_data, service_record, placement_notification

Cases, contacts, placements, persons

JSON (configurable per partner)

Financial

payment_submission, claim_submission, rate_update

Payments, adjustments, claims, rates, IV-E eligibility

JSON with AFCARS codes

Medicaid

eligibility_request, enrollment_notification

Persons, placements, income data

JSON (FHIR-like structure for modern systems)

Child Abuse

report_submission, disposition_notification

Referrals, allegations, investigations, dispositions

JSON with NCANDS codes

TANF

family_referral, benefit_status

Cases, household members, income

JSON

Child Support

case_referral, support_order

Cases, persons (parents), court orders

JSON

External Data

data_submission, form_submission

Any entity (generic pass-through with validation)

JSON

Court

case_notice, order_submission, hearing_schedule

Cases, court orders, hearing dates

JSON

Education

enrollment_notification, records_request

Education records, persons (children), placements

JSON

Health

health_notification, immunization_record, appointment_record

Health records, persons (children), immunizations

JSON (FHIR-aligned structure)

Tribal

icwa_notice, placement_notification, kinship_verification

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:

  1. Identifies the partner from the API key

  2. Selects the adapter via adapter_for(partner_type)

  3. Calls adapter.transform_inbound(payload) to normalize to CRAIG format

  4. Creates an inbound exchange_transaction record

  5. Publishes exchange.received event to RabbitMQ

  6. Returns 202 Accepted with 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 — add adapter_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 to craig-reference)

  • services/craig-exchange/src/adapters/mapping/ncands.rs — NCANDS code translation (delegate to craig-reference)

  • services/craig-exchange/src/api/transactions.rs — update send_exchange handler to use factory instead of NoopAdapter

  • services/craig-exchange/src/main.rs — remove hardcoded NoopAdapter extension

  • 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 — add craig-exchange-mock service on port 9090

  • tools/craig-seed/ — seed a partner with endpoint_url: http://craig-exchange-mock:9090/exchange/cwca

Steps 3-13: Individual Adapter Implementations

Each adapter follows the same pattern (see Design section above):

  1. Create services/craig-exchange/src/adapters/{name}.rs

  2. Implement struct with ExchangeAdapter trait

  3. Create mapping module services/craig-exchange/src/adapters/mapping/{name}.rs

  4. Add unit tests for mapping functions

  5. Add integration test sending via the adapter to mock server

  6. 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 with receive_exchange handler

  • services/craig-exchange/src/store/transactions.rs — add create_inbound_transaction() function

  • services/craig-exchange/src/api/mod.rs — register route POST /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)

Step 16: Documentation

  • .claude/docs/services.md — update endpoint counts, add exchange types

  • CHANGELOG.adoc — entry per adapter batch

  • docs/modules/ROOT/pages/roadmap.adoc — tick 11 adapter items

  • .claude/CLAUDE.md — update Phase 5 stats

  • This plan — status to Complete, move to archive

Files Touched

File Change

services/craig-exchange/src/adapters/mod.rs

Factory function, module declarations

services/craig-exchange/src/adapters/{11 files}.rs

11 adapter implementations

services/craig-exchange/src/adapters/mapping/*.rs

Shared + per-adapter mapping modules

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

Use factory instead of NoopAdapter

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

New inbound webhook handler

services/craig-exchange/src/store/transactions.rs

Inbound transaction creation

services/craig-exchange/src/main.rs

Remove hardcoded adapter

services/craig-exchange/migrations/

Expand partner_type constraint

services/craig-exchange-mock/

New mock server binary (Cargo.toml + main.rs)

docker-compose.yml

Add mock server container

services/craig-cli/src/cmd/exchange.rs

Enhanced send + test-adapter commands

services/craig-web/templates/exchange/

Adapter status UI

tools/craig-seed/

Seed partners for each adapter type

tests/

Unit tests for mappings, integration tests for round-trip

Verification

  1. cargo fmt --all + cargo clippy --workspace — -D warnings

  2. cargo nextest run --workspace --lib — unit tests (mapping tests)

  3. cargo xtask dev reload (schema + mock server)

  4. cargo nextest run --workspace — integration tests (adapter round-trip via mock)

  5. cargo xtask e2e — 5x local runs, all pass

  6. Verify: curl -X POST http://localhost:8004/v1/exchange/send -H "Authorization: Bearer $TOKEN" -d '{"partner_id":"…​","exchange_type":"case_data","payload":{…​}}' returns success

  7. Verify: mock server received and validated the transformed payload

  8. Verify: curl http://localhost:9090/received shows 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

Edit this page · latest