Plan: FFE/SBM Account Transfer (canopy-exchange)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Define FfeAccountTransferAdapter trait methods for inbound and outbound transfers |
Not started |
2 |
Create |
Not started |
3 |
Implement outbound transfer: serialize determination + demographics into ACPT XML payload |
Not started |
4 |
Implement inbound transfer: parse ACPT XML, create application in canopy-applications, trigger Medicaid determination |
Not started |
5 |
Wire event publishing for |
Not started |
6 |
Implement 30-day response deadline tracking and escalation |
Not started |
7 |
Integration tests with mock exchange endpoint |
Not started |
Epic: &31
Branch: feature/ffe-account-transfer
Labels: type::feature, priority::high, program::medicaid, program::chip, service::exchange, workflow::needs-spec, federal-partner::cms
This is a skeleton plan. It documents the architectural boundaries, data model, and service interactions that constrain shared service designs being built during Month 1 (Foundation). Detailed ACPT XML field mappings, Georgia Access endpoint specifications, and implementation-ready steps will be added before work begins on this plan.
Context
42 CFR 435.1200 requires state Medicaid agencies to accept and send electronic account transfers with health insurance exchanges to implement the ACA’s "No Wrong Door" policy (ACA §1413). When an applicant applies at the exchange and may be Medicaid-eligible, the exchange must transfer the account to the state Medicaid agency. Conversely, when a Medicaid agency determines an applicant ineligible for Medicaid, it must transfer the account to the exchange for QHP/APTC screening.
Georgia operates a State-Based Marketplace on the Federal Platform (SBM-FP) called Georgia Access, which uses the federal hub’s Account Transfer Protocol (ACPT) XML schema for all transfers. Canopy must implement both inbound and outbound transfers using this protocol.
42 CFR 435.1200(c) specifies the data elements that must be included in an account transfer: application information, demographic data, household composition, income information, citizenship and immigration status, verification data already obtained, current enrollment status, and MEC (minimum essential coverage) status.
The FfeAccountTransferAdapter trait already exists as a stub in canopy-exchange with no methods defined. This plan defines the methods and data flows that the adapter must support.
Regulatory basis
-
42 CFR 435.1200 — Account transfers between agencies
-
ACA §1413 — No Wrong Door / streamlined enrollment
-
42 CFR 435.1200(c) — Required data elements in account transfers
-
42 CFR 435.1200(d) — 30-day response timeline for inbound transfers
-
45 CFR 155.345 — Exchange-side transfer obligations
Scope
In scope:
-
FfeAccountTransferAdaptertrait methods for inbound and outbound transfers -
ACPT XML serialization and deserialization (federal hub schema)
-
Outbound transfer payload assembly: application ID, demographics, household composition, MAGI-based income, citizenship/immigration status, verification data obtained, enrollment status, MEC status
-
Inbound transfer processing: parse ACPT XML, create or match application in canopy-applications, trigger Medicaid eligibility determination via canopy-medicaid
-
account_transferstable tracking transfer lifecycle and audit trail -
30-day response deadline tracking for inbound transfers (42 CFR 435.1200(d))
-
Event publishing:
exchange.transfer_sent,exchange.transfer_received(IDs and status only — no PHI, no income data) -
Error handling for malformed transfers, duplicate transfers, and timeout scenarios
Out of scope:
-
Direct integration with Georgia Access endpoints (requires Georgia Access onboarding and credentials — infrastructure dependency)
-
FDSH (Federal Data Services Hub) queries — covered in canopy-verification
-
Medicaid/CHIP eligibility determination logic — covered in
medicaid-eligibilityplan -
Real-time eligibility check API for exchange (not required for SBM-FP model)
-
QHP/APTC determination (exchange-side responsibility)
-
Batch transfer processing (Georgia Access uses real-time ACPT)
Dependencies
This plan depends on:
-
persons-household-model (must be complete): demographics, household composition, citizenship/immigration status data model in canopy-persons
-
application-intake (must be complete): application creation API in canopy-applications for inbound transfers
-
medicaid-eligibility (must be complete): Medicaid determination API in canopy-medicaid to trigger upon inbound transfer
-
reference-extensions (must be complete):
DeterminationStatusenum variants for Medicaid outcomes -
eligibility-orchestrator (must be complete): canopy-eligibility orchestration for triggering cross-program determinations
Design
Transfer data flow
Outbound transfer (Canopy → Exchange):
-
canopy-medicaid determines applicant ineligible for Medicaid/CHIP
-
canopy-eligibility checks if applicant may qualify for QHP/APTC
-
canopy-exchange assembles ACPT XML payload from canopy-persons demographics, canopy-applications data, and canopy-medicaid determination result
-
canopy-exchange sends transfer to Georgia Access via ACPT endpoint
-
canopy-exchange records transfer in
account_transferstable -
canopy-exchange publishes
exchange.transfer_sentevent (transfer ID and application ID only)
Inbound transfer (Exchange → Canopy):
-
Georgia Access sends ACPT XML to canopy-exchange inbound endpoint
-
canopy-exchange parses and validates ACPT payload
-
canopy-exchange calls canopy-persons to create or match person records
-
canopy-exchange calls canopy-applications to create application
-
canopy-exchange records transfer in
account_transferstable withresponse_due_date(received_at + 30 days) -
canopy-exchange publishes
exchange.transfer_receivedevent (transfer ID and application ID only) -
canopy-eligibility triggers Medicaid determination for the new application
Database schema (canopy-exchange database)
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Account transfer tracking table
-- Records all inbound and outbound transfers with the health insurance exchange
-- Per ADR-001: canopy-exchange owns this table; no other service queries it directly
CREATE TABLE account_transfers (
id UUID PRIMARY KEY,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
transfer_status TEXT NOT NULL CHECK (transfer_status IN (
'pending', 'sent', 'received', 'accepted', 'rejected', 'error', 'timed_out'
)),
source_system TEXT NOT NULL, -- e.g., 'georgia_access', 'canopy'
target_system TEXT NOT NULL, -- e.g., 'canopy', 'georgia_access'
application_id UUID NOT NULL, -- FK concept to canopy-applications (not enforced cross-service per ADR-001)
transfer_payload_hash TEXT NOT NULL, -- SHA-256 of the ACPT XML payload for integrity verification
sent_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
response_due_date TIMESTAMPTZ, -- received_at + 30 days for inbound transfers (42 CFR 435.1200(d))
response_sent_at TIMESTAMPTZ,
error_detail TEXT, -- NULL unless transfer_status = 'error'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX account_transfers_application_id ON account_transfers (application_id);
CREATE INDEX account_transfers_status ON account_transfers (transfer_status) WHERE transfer_status IN ('pending', 'received');
CREATE INDEX account_transfers_response_due ON account_transfers (response_due_date) WHERE response_due_date IS NOT NULL AND response_sent_at IS NULL;
FfeAccountTransferAdapter trait
// SPDX-License-Identifier: AGPL-3.0-or-later
use uuid::Uuid;
/// Adapter trait for health insurance exchange account transfers.
///
/// Implementations handle serialization to/from ACPT XML and
/// communication with the exchange endpoint (Georgia Access SBM-FP
/// or HealthCare.gov federal hub).
///
/// Per ADR-001, this adapter communicates with canopy-persons and
/// canopy-applications via internal HTTP APIs, never direct DB access.
///
/// Per ADR-004, transfer payloads contain MAGI-based income data only —
/// no FTI, IEVS, or HIPAA-scoped clinical data. Income figures in
/// account transfers are applicant-attested or MAGI-calculated, not
/// sourced from restricted federal data matches.
pub trait FfeAccountTransferAdapter: Send + Sync {
/// Send an outbound account transfer to the exchange.
///
/// Called when canopy-medicaid determines an applicant ineligible
/// and the applicant may qualify for QHP/APTC at the exchange.
async fn send_transfer(
&self,
application_id: Uuid,
determination_id: Uuid,
) -> Result<Uuid, TransferError>;
/// Receive and process an inbound account transfer from the exchange.
///
/// Called when the exchange determines an applicant may be
/// Medicaid/CHIP-eligible and transfers the account.
/// Returns the newly created application ID in canopy-applications.
async fn receive_transfer(
&self,
payload: &[u8],
) -> Result<InboundTransferResult, TransferError>;
/// Check for inbound transfers approaching the 30-day response deadline.
///
/// Called by a scheduled task to identify transfers that need
/// expedited processing to meet the 42 CFR 435.1200(d) timeline.
async fn check_pending_deadlines(&self) -> Result<Vec<PendingDeadline>, TransferError>;
}
pub struct InboundTransferResult {
pub transfer_id: Uuid,
pub application_id: Uuid,
pub person_ids: Vec<Uuid>,
}
pub struct PendingDeadline {
pub transfer_id: Uuid,
pub application_id: Uuid,
pub response_due_date: time::OffsetDateTime,
pub days_remaining: i32,
}
Data restrictions
Per ADR-004, account transfer payloads must NOT contain:
-
FTI — income data in transfers is MAGI-based (applicant-attested or calculated), not sourced from IRS
-
IEVS data — SNAP-only, not authorized for exchange transfers
-
PHI — no clinical/diagnostic data in eligibility transfers; only coverage status
-
SSA SOLQ/BINDEX data — restricted to CMA-authorized programs
Events published to canopy.events contain only: transfer ID, application ID, direction, and timestamp. No demographic data, income data, or transfer payload content.
Steps
Step 1: Define FfeAccountTransferAdapter trait methods
Files:
-
services/canopy-exchange/src/adapter.rs(modify) — addsend_transfer,receive_transfer,check_pending_deadlinesmethods to existing trait stub
Define the trait as shown in the Design section. Implement a NoopFfeAccountTransferAdapter that returns TransferError::NotConfigured for all methods (used when exchange integration is disabled per ADR-005 deployment profiles).
Step 2: Create account_transfers table
Files:
-
services/canopy-exchange/migrations/YYYYMMDD_account_transfers.sql(new)
Create the account_transfers table as shown in the Design section. UUID PKs, TIMESTAMPTZ for all date fields.
Step 3: Implement outbound transfer
Files:
-
services/canopy-exchange/src/outbound.rs(new) — payload assembly, ACPT XML serialization, send logic -
services/canopy-exchange/src/acpt.rs(new) — ACPT XML schema types and serialization
Assemble outbound payload from canopy-persons (demographics, household) and canopy-medicaid (determination result) via internal HTTP APIs. Serialize to ACPT XML. Record in account_transfers. Publish exchange.transfer_sent event.
Step 4: Implement inbound transfer
Files:
-
services/canopy-exchange/src/inbound.rs(new) — ACPT XML parsing, person/application creation, determination trigger
Parse inbound ACPT XML. Create person records in canopy-persons and application in canopy-applications via internal HTTP APIs. Record in account_transfers with response_due_date. Publish exchange.transfer_received event.
Step 5: Wire events
Files:
-
services/canopy-exchange/src/events.rs(new or modify)
Publish exchange.transfer_sent and exchange.transfer_received to canopy.events topic exchange. Payloads contain IDs and timestamps only — no demographic or income data per ADR-004.
Integration Tests
All tests use testcontainers-rs for PostgreSQL.
All tests use cargo nextest run -p canopy-exchange.
Test scenarios
| # | Scenario | Expected result |
|---|---|---|
1 |
Outbound transfer: Medicaid-ineligible applicant with complete demographics |
Transfer record created with status |
2 |
Inbound transfer: valid ACPT XML with new applicant |
Person and application created, transfer record with |
3 |
Inbound transfer: duplicate transfer (same payload hash) |
Rejected with appropriate error, no duplicate application created |
4 |
Inbound transfer: malformed ACPT XML |
Transfer record created with status |
5 |
Deadline check: transfer received 25 days ago with no response |
Returned in pending deadlines with days_remaining = 5 |
6 |
Deadline check: transfer with response already sent |
Not returned in pending deadlines |
7 |
Outbound transfer: verify no FTI/IEVS/PHI fields in payload |
Payload contains only MAGI-based income, demographics, and coverage status |
Files Touched
| File | Change |
|---|---|
|
Modify: add trait methods to FfeAccountTransferAdapter |
|
New: account_transfers table |
|
New: outbound transfer assembly and send logic |
|
New: inbound transfer parsing and application creation |
|
New: ACPT XML schema types and serialization/deserialization |
|
New or modify: exchange.transfer_sent and exchange.transfer_received event publishing |
|
New: 30-day response deadline tracking |
|
New: integration test scenarios |
Verification
-
cargo nextest run -p canopy-exchange— all transfer tests pass -
Verify outbound transfer payload contains required 42 CFR 435.1200(c) data elements
-
Verify inbound transfer creates application and triggers Medicaid determination within canopy-eligibility
-
Verify no FTI, IEVS, PHI, or SSA SOLQ/BINDEX data appears in any transfer payload or event
-
Verify 30-day deadline tracking correctly identifies approaching deadlines
-
Verify duplicate inbound transfers are rejected without creating duplicate applications
Documentation Updates
-
Service Catalog +
api/canopy-exchange.adoc/data-models/canopy-exchange.adoc— add the account_transfers table, document the FfeAccountTransferAdapter trait methods, and flip canopy-exchange’s status when implementation begins (the canonical home;.claude/only points here) -
CHANGELOG.adoc— entry under== Unreleased -
docs/modules/ROOT/pages/plans/ffe-account-transfer.adoc— update status table steps to COMPLETE