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 account_transfers table migration in canopy-exchange

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 exchange.transfer_sent and exchange.transfer_received

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

NOTE

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:

  • FfeAccountTransferAdapter trait 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_transfers table 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-eligibility plan

  • 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): DeterminationStatus enum 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):

  1. canopy-medicaid determines applicant ineligible for Medicaid/CHIP

  2. canopy-eligibility checks if applicant may qualify for QHP/APTC

  3. canopy-exchange assembles ACPT XML payload from canopy-persons demographics, canopy-applications data, and canopy-medicaid determination result

  4. canopy-exchange sends transfer to Georgia Access via ACPT endpoint

  5. canopy-exchange records transfer in account_transfers table

  6. canopy-exchange publishes exchange.transfer_sent event (transfer ID and application ID only)

Inbound transfer (Exchange → Canopy):

  1. Georgia Access sends ACPT XML to canopy-exchange inbound endpoint

  2. canopy-exchange parses and validates ACPT payload

  3. canopy-exchange calls canopy-persons to create or match person records

  4. canopy-exchange calls canopy-applications to create application

  5. canopy-exchange records transfer in account_transfers table with response_due_date (received_at + 30 days)

  6. canopy-exchange publishes exchange.transfer_received event (transfer ID and application ID only)

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

Events

Event Payload fields

exchange.transfer_sent

transfer_id, application_id, target_system, sent_at

exchange.transfer_received

transfer_id, application_id, source_system, received_at

Steps

Step 1: Define FfeAccountTransferAdapter trait methods

Files:

  • services/canopy-exchange/src/adapter.rs (modify) — add send_transfer, receive_transfer, check_pending_deadlines methods 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.

Step 6: Deadline tracking

Files:

  • services/canopy-exchange/src/deadlines.rs (new)

Implement check_pending_deadlines to query account_transfers for inbound transfers where response_due_date is approaching and response_sent_at is NULL. This feeds into notice generation for supervisory escalation.

Step 7: Integration tests

Files:

  • services/canopy-exchange/tests/transfer_tests.rs (new)

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 sent, exchange.transfer_sent event published

2

Inbound transfer: valid ACPT XML with new applicant

Person and application created, transfer record with response_due_date = received_at + 30 days

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 error, error_detail populated

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

services/canopy-exchange/src/adapter.rs

Modify: add trait methods to FfeAccountTransferAdapter

services/canopy-exchange/migrations/YYYYMMDD_account_transfers.sql

New: account_transfers table

services/canopy-exchange/src/outbound.rs

New: outbound transfer assembly and send logic

services/canopy-exchange/src/inbound.rs

New: inbound transfer parsing and application creation

services/canopy-exchange/src/acpt.rs

New: ACPT XML schema types and serialization/deserialization

services/canopy-exchange/src/events.rs

New or modify: exchange.transfer_sent and exchange.transfer_received event publishing

services/canopy-exchange/src/deadlines.rs

New: 30-day response deadline tracking

services/canopy-exchange/tests/transfer_tests.rs

New: integration test scenarios

Verification

  1. cargo nextest run -p canopy-exchange — all transfer tests pass

  2. Verify outbound transfer payload contains required 42 CFR 435.1200(c) data elements

  3. Verify inbound transfer creates application and triggers Medicaid determination within canopy-eligibility

  4. Verify no FTI, IEVS, PHI, or SSA SOLQ/BINDEX data appears in any transfer payload or event

  5. Verify 30-day deadline tracking correctly identifies approaching deadlines

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

Edit this page · default