- providers table seeded with 3 NPIs (Montrose, Delta, Salida) - payers + payer_configs (per-tx config) join table - clearhouse singleton (dzinesco identity, SFTP block, filename block) - config/payers.yaml loader with Pydantic schema validation - cyclone/edi/filenames.py: HCPF X12 File Naming Standards helpers - cyclone/clearhouse/sftp.py: stub that copies to ./var/sftp/staging/... - cyclone/secrets.py: macOS Keychain wrapper via keyring - 11 new validation rules R200-R210 (CO MAP + HCPF naming) - 6 new API endpoints (/api/clearhouse/*, /api/config/*, /api/admin/reload-config) - migration 0007 - 72 new tests (646 total passing, was 574) See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
21 KiB
Sub-project 9 — Multi-Payer, Multi-NPI, SFTP Stub: Design Spec
Date: 2026-06-20
Status: Draft, awaiting user sign-off
Branch: sp9-multi-payer-npi
Aesthetic direction: No new UI. Backend + payers.yaml + providers table + clearhouse config + SFTP stub. Existing Inbox/drawer surfaces show the new data with zero frontend redesign.
1. Scope
Replace the single hard-coded PayerConfig factory dict (currently in api.py:97) with:
providerstable — the 3 Touch of Care billing-provider NPIs (Montrose 1881068062, Delta 1851446637, Salida 1467507269), all at the same Montrose corporate office, allTOC, Inc., tax ID 721587149, taxonomy 251E00000X.payerstable +payer_configsjoin table — one row per (payer_id, transaction_type) so 837P and 835 can carry different config (e.g., differentBHT06, different SBR rules, different naming).clearhousesingle-row config — dzinesco's identity: TPID 11525703, submitter, receiver, file-naming block, MT timezone.payers.yaml— replaces the in-codePAYER_FACTORIESdict. Loaded once at boot; reloadable viaPOST /api/admin/reload-config.- File-naming helpers —
build_outbound_filenameandparse_inbound_filenameper the HCPF X12 File Naming Standards Quick Guide. Mountain Time timestamps, 17-digit ms precision, "1of1" only. - SFTP submit stub —
POST /api/clearhouse/submitaccepts a batch of generated 837 files and copies them to a localstaging_dirinstead of opening an SFTP connection. The structure of the stub matches the future real call so swapping inparamikoin SP13 is one-file. - macOS Keychain for secrets — SFTP password/SSH key path is fetched from the Keychain by name; never written to disk or YAML. (Stubs return a fake secret until the real Keychain entry is created.)
Out of scope (deferred to other SPs):
- SP10 — 277CA parser + "Payer-Rejected" lane in the Inbox.
- SP11 — Tamper-evident hash-chained
audit_logtable. - SP12 — SQLCipher encryption at rest + Keychain-stored DB key.
- SP13 — Replace the SFTP stub with real
paramikoconnection tomft.gainwelltechnologies.comand actually push to/CO XIX/PROD/coxix_prod_11525703/FromHPE. - Real SFTP credentials in Keychain — schema and call sites are in place; the actual secret is created manually by the operator.
2. Goals
- One source of truth for payer logic. Every payer-specific value (claim-frequency code, SBR09 default, allowed BHT06, file-name format) lives in
payers.yaml, not inapi.py. - One provider per NPI. 837 files pick the right Billing Provider by NPI; the existing
prv_billing_npifield onClaimOutputbecomes a foreign key intoproviders. - MT-clock filenames. All outbound filenames use Mountain Time, 17-digit
yyyymmddhhmmssSSSprecision, and the literal "1of1". - Inbound filenames parsed and routable. 999 / TA1 / 271 / 277 / 277CA / 835 files are routed to the right handler based on the
<FileType>tag and the<OrigTx>token. - SFTP stub is structurally identical to the real call. SP13 swaps
SftpClient.write()'s implementation, nothing else.
3. Locked decisions
3.1 Approach — YAML-driven config, not code-driven
Today, api.py builds PayerConfig for CO Medicaid from a 60-line dict literal. That dict mixes:
- Static receiver identity (ISA08, GS03, NM1*40)
- BHT06 selection (CH vs RP)
- SBR09 default
- Submitter contact (PER segment)
- Payer-identifier (NM1*PR NM109)
- Filename rules
All of it lives in code. SP9 moves it to payers.yaml keyed by payer_id (e.g. CO_TXIX) and the transaction_type (837P or 835). Code becomes a thin loader + validator.
The original dict stays as the fallback default if a payer is missing from payers.yaml (allows ad-hoc testing). On boot, the loader:
- Reads
config/payers.yamlfrom the repo root - Validates each
(payer_id, transaction_type)block against a Pydantic schema - If validation fails, fails the boot with a precise error
- If a block is missing for a payer that the DB has claims for, the existing dict is used for that payer (warn)
3.2 Schema — 3 new tables, 1 new config
-- Migration 0007
CREATE TABLE providers (
npi TEXT PRIMARY KEY, -- 10 digits
label TEXT NOT NULL, -- 'Montrose' | 'Delta' | 'Salida'
legal_name TEXT NOT NULL, -- 'TOC, Inc.'
tax_id TEXT NOT NULL, -- '721587149'
taxonomy_code TEXT NOT NULL, -- '251E00000X'
address_line1 TEXT NOT NULL,
address_line2 TEXT, -- nullable (e.g. 'Suite A')
city TEXT NOT NULL,
state TEXT NOT NULL, -- 'CO'
zip TEXT NOT NULL, -- '814014063'
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE payers (
payer_id TEXT PRIMARY KEY, -- 'CO_TXIX'
name TEXT NOT NULL, -- 'Colorado Medicaid'
receiver_name TEXT NOT NULL, -- 'COLORADO MEDICAL ASSISTANCE PROGRAM' (NM1*40)
receiver_id TEXT NOT NULL, -- 'COMEDASSISTPROG' (ISA08/GS03)
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE payer_configs (
payer_id TEXT NOT NULL,
transaction_type TEXT NOT NULL, -- '837P' | '835' | '277CA' | '999' | 'TA1'
config_json TEXT NOT NULL, -- see Section 3.3
PRIMARY KEY (payer_id, transaction_type),
FOREIGN KEY (payer_id) REFERENCES payers(payer_id)
);
CREATE TABLE clearhouse (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
name TEXT NOT NULL, -- 'dzinesco'
tpid TEXT NOT NULL, -- '11525703'
submitter_name TEXT NOT NULL, -- 'Dzinesco'
submitter_id_qual TEXT NOT NULL DEFAULT '46',-- '46' for TPID
submitter_contact_name TEXT NOT NULL, -- 'Tyler Martinez'
submitter_contact_email TEXT NOT NULL, -- 'tyler@dzinesco.com'
filename_block_json TEXT NOT NULL, -- see Section 3.5
sftp_block_json TEXT NOT NULL, -- see Section 3.6
updated_at TEXT NOT NULL
);
INSERT INTO clearhouse (id, name, tpid, submitter_name, submitter_contact_name,
submitter_contact_email, filename_block_json, sftp_block_json, updated_at)
VALUES (1, 'dzinesco', '11525703', 'Dzinesco', 'Tyler Martinez', 'tyler@dzinesco.com',
'{"tz":"America/Denver","outbound_template":"{tpid}-{tx}-{ts_mt}-1of1.{ext}",'
'"inbound_template":"TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12"}',
'{"host":"mft.gainwelltechnologies.com","port":22,'
'"username":"colorado-fts\\coxix_prod_11525703",'
'"paths":{"outbound":"/CO XIX/PROD/coxix_prod_11525703/FromHPE",'
'"inbound":"/CO XIX/PROD/coxix_prod_11525703/ToHPE"},'
'"stub":true,"staging_dir":"./var/sftp/staging","poll_seconds":300}',
'2026-06-20T00:00:00Z');
3.3 payer_configs.config_json shape
{
"submitter": {
"name": "Dzinesco",
"contact_name": "Tyler Martinez",
"contact_email": "tyler@dzinesco.com"
},
"receiver": {
"name": "COLORADO MEDICAL ASSISTANCE PROGRAM",
"id_qualifier": "46",
"id": "COMEDASSISTPROG"
},
"bht06_allowed": ["CH", "RP"], // claim-frequency codes; separate batches required
"bht06_default": "CH",
"sbr09_default": "MC", // MC=Medicaid; 16/MA/MB=crossover; ZZ=MCO encounter
"sbr09_allowed": ["MC", "16", "MA", "MB", "ZZ"],
"payer_id_qualifier": "PI",
"payer_id": "CO_TXIX", // production reality = SKCO0; spec = CO_TXIX
"pwk_supported": false, // CO MAP does not support PWK
"cas_2320_group_allowed": false, // 2320 CAS PI group only for MCO encounter denials
"claim_type_codes": { // CLM05 place-of-service values accepted
"11": "Office",
"12": "Home",
"99": "Other"
}
}
For 835, the same payer_id row gets a separate config block keyed 835 with a smaller schema (BPR, payer-name on N1*PR, etc.).
3.4 providers seed data (from production files)
Self-served from 136 real prod 837P files in docs/prodfiles/837p-from-axiscare/ and docs/prodfiles/FromHPE/. All 3 NPIs:
| NPI | Label | Legal name | Address | Taxonomy | Tax ID |
|---|---|---|---|---|---|
| 1881068062 | Montrose | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 | 251E00000X | 721587149 |
| 1851446637 | Delta | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 | 251E00000X | 721587149 |
| 1467507269 | Salida | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 | 251E00000X | 721587149 |
All 3 share the same address because all 3 are registered to the Montrose corporate office (per user confirmation 2026-06-20). Production files confirm NM1*85*2*TOC, Inc. + N3*1100 East Main St*Suite A + N4*Montrose*CO*814014063 + PRV*BI*PXC*251E00000X + REF*EI*721587149 is byte-identical across all 3 NPIs.
3.5 File-naming spec
Per the HCPF X12 File Naming Standards Quick Guide (https://hcpf.colorado.gov/tp-x12-filenaming):
Outbound (we send to /CO XIX/PROD/coxix_prod_11525703/FromHPE):
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to /CO XIX/PROD/coxix_prod_11525703/ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
Examples:
TP11525703-837P_M019048402-20260520231513488-1of1_999.x12(matches a real prod file)TP11525703-837P_M019044969-20260520180505477-1of1_TA1.x12(matches a real prod file)TP11525703-837P_M<icn>-<ts>-1of1_277.x12(future 277CA, per user decision to use 277 suffix)
{file_type} is one of 999, TA1, 271, 277, 277CA, 820, 834, 835, ENCR. The 277CA variant is distinguished by content (ST*277CA), not the filename.
Helpers in cyclone/edi/filenames.py:
def build_outbound_filename(tpid: str, tx: str, *, ext: str = "x12", now_mt: datetime | None = None) -> str
def parse_inbound_filename(name: str) -> InboundFilename # NamedTuple(tpid, orig_tx, tracking, ts, file_type)
MT timestamps: datetime.now(ZoneInfo("America/Denver")) with millisecond precision, formatted %Y%m%d%H%M%S%f and truncated to 17 digits (millis, not micros).
3.6 SFTP stub
POST /api/clearhouse/submit body:
{
"claim_ids": ["CLM-001", "CLM-002"],
"payer_id": "CO_TXIX"
}
Handler:
- Load
clearhouse.sftp_block_json. Ifstub == true, run the stub path. - For each claim, generate the 837 text via the SP7 serializer.
- Compute the outbound filename via
build_outbound_filename(...). - Write to
{staging_dir}/{outbound_path_prefix}/{filename}(preserves the full MFT path under staging for easy review). - Return a JSON receipt:
{ "ok": true, "submitted": [ {"claim_id": "CLM-001", "filename": "11525703-837P-20260620132243505-1of1.x12", "staging_path": "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-..."} ], "stub": true }
When stub == false (SP13), the only change is the body of SftpClient.write_file(host, user, keychain_ref, remote_path, local_bytes) — it calls paramiko.SSHClient().open_sftp().open(remote_path, "wb") instead of writing to the staging dir. Everything else (filename build, claim serialization, error handling, pubsub event) stays the same.
3.7 macOS Keychain for SFTP secret
A new module cyclone/secrets.py:
def get_secret(name: str) -> str | None:
"""Fetch a secret from macOS Keychain by service name. Returns None if absent."""
# Uses the `keyring` library. Service = "cyclone", username = name.
return keyring.get_password("cyclone", name)
The SFTP block references secrets by name ("sftp.gainwell.password"), never value. SP9 ships:
- A
secrets.pythat callskeyring.get_password("cyclone", "sftp.gainwell.password")and falls back to"<stub-secret>"if the Keychain entry doesn't exist. - A
scripts/setup_keychain.shthat the operator runs once to create the entry:security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'. - A
README.mdsection indocs/that documents the manual setup.
No real credential is ever stored in the repo, the DB, or payers.yaml.
4. Migration + file layout
New files:
backend/src/cyclone/secrets.py— Keychain wrapperbackend/src/cyclone/edi/filenames.py—build_outbound_filename,parse_inbound_filenamebackend/src/cyclone/edi/filenames.pyi— type stubsbackend/src/cyclone/clearhouse/sftp.py—SftpClient.write_file,SftpClient.list_inbound(stub)config/payers.yaml— payer config blocksscripts/setup_keychain.sh— manual Keychain setup helperbackend/tests/test_secrets.py— Keychain wrapper testsbackend/tests/test_filenames.py— filename helpersbackend/tests/test_sftp_stub.py— SFTP stub behaviorbackend/tests/test_payer_config_loading.py— YAML loader + schemabackend/tests/fixtures/payers.yaml— test fixture
Modified files:
backend/src/cyclone/api.py— replacePAYER_FACTORIESlookup withpayers.get_config(payer_id, tx); addPOST /api/clearhouse/submit,POST /api/admin/reload-config,GET /api/clearhousebackend/src/cyclone/store.py— addproviders,payers,payer_configs,clearhouseORM models and CRUD helpersbackend/src/cyclone/edi/serialize_837.py— read provider NPI fromproviderstable, not fromclaim.raw_segmentsalone; emit submitter + receiver fromclearhouse+payer_configsbackend/src/cyclone/edi/parse_837.py— extract provider NPI into aparsed.provider_npifield that joins toproviders.npibackend/src/cyclone/models.py— addProvider,Payer,PayerConfig,ClearhousePydantic modelsdocs/reference/co-medicaid.md— add section "SFTP submission paths" linking toclearhouse.sftp_block_json
New migration:
backend/src/cyclone/migrations/0007_providers_payers_clearhouse.sql— the 4-table schema from §3.2
5. API surface
| Method | Path | Body | Returns |
|---|---|---|---|
GET |
/api/clearhouse |
— | Clearhouse (name, tpid, sftp block, filename block) |
POST |
/api/clearhouse/submit |
{claim_ids, payer_id} |
{ok, submitted[], stub} |
GET |
/api/config/providers |
— | Provider[] (filtered ?is_active=true) |
GET |
/api/config/providers/{npi} |
— | Provider |
POST |
/api/config/providers |
Provider |
Provider (creates new) |
PATCH |
/api/config/providers/{npi} |
Partial<Provider> |
Provider |
GET |
/api/config/payers |
— | Payer[] |
GET |
/api/config/payers/{payer_id}/configs |
— | PayerConfig[] (one per tx) |
PATCH |
/api/config/payers/{payer_id}/configs/{tx} |
Partial<PayerConfig> |
PayerConfig |
POST |
/api/admin/reload-config |
— | {ok, loaded, errors[]} |
Note on path naming: The path prefix
/api/config/*is used for the SP9 config-driven providers/payers to avoid clashing with the existing/api/providersendpoint (which returns a paginated list of providers seen in actual claim data viastore.distinct_providers()). The two endpoints serve different purposes: the existing one powers the Inbox UI; the new one powers the clearhouse admin view.
The clearhouse submit is the only auth-sensitive endpoint (in production: an admin key). The provider/payer CRUD are read-only for the operator during this SP — no UI form for editing.
6. Validation rules (new for CO MAP spec)
Added to R_* enum in cyclone/validation/rules.py:
| Rule | Description | Severity |
|---|---|---|
R200 |
BHT06 must be in payer_configs.bht06_allowed for the claim's payer. |
Error |
R201 |
A single 837 envelope must not mix BHT06=CH and BHT06=RP claims. |
Error |
R202 |
SBR09 must be in payer_configs.sbr09_allowed for the claim's payer. |
Error |
R203 |
PRV*BI*PXC*<code> must equal the provider's taxonomy_code from the providers table. |
Error |
R204 |
NM1*85*XX*<npi> must match an active row in providers. |
Error |
R205 |
REF*EI*<tax_id> must equal the provider's tax_id from the providers table. |
Error |
R206 |
NM1*PR*PI*<payer_id> must equal payer_configs.payer_id for the claim's payer. |
Error |
R207 |
PWK segment must not appear for this payer (pwk_supported == false). |
Error |
R208 |
2320 loop CAS*PI* group only allowed for MCO encounter (SBR09=ZZ) denials. |
Error |
R209 |
Outbound filename must match the HCPF regex ^\d+-[A-Z0-9]+-\d{17}-1of1\.[A-Za-z0-9]+$ and use MT time. |
Error |
R210 |
Inbound filename must match the HCPF regex ^TP\d+-[A-Z0-9]+_M[A-Z0-9]+-\d{17}-1of1_[A-Z0-9]+\.x12$. |
Error |
All R200-R210 run on parse AND on serialize, so a 837 file with an unknown NPI fails the inbox lane.
7. Testing plan
-
test_filenames.py— 14 cases:- build_outbound_filename with explicit
now_mt=datetime(2026,6,20,13,22,43,505000, tzinfo=MT)→11525703-837P-20260620132243505-1of1.x12(17-digit, MT, "1of1") - build_outbound_filename with
now_mt=Nonedefaults todatetime.now(America/Denver)(snapshot test) - build_outbound_filename for all 8 transaction types in the HCPF doc
- parse_inbound_filename on a real prod 999 filename →
InboundFilename(tpid='11525703', orig_tx='837P', tracking='M019048402', ts='20260520231513488', file_type='999') - parse_inbound_filename on a real prod TA1 filename
- parse_inbound_filename rejects: missing TP prefix, wrong segment count, wrong file_type, non-numeric TPID
- Round-trip: build → parse returns the same tpid/orig_tx/file_type
- build_outbound_filename with explicit
-
test_payer_config_loading.py— 9 cases:- Load
config/payers.yaml→ dict of(payer_id, tx)→ config blocks - Missing required key in a block → boot fails with precise error
- Pydantic schema rejects unknown
bht06_allowedvalue - Reload endpoint re-reads the file and re-validates
- Fallback to hard-coded
PAYER_FACTORIESwhen a payer in the DB is missing from YAML (warn logged)
- Load
-
test_sftp_stub.py— 7 cases:- Submit 2 claims → 2 files written to
staging_dirat the full nested MFT path - Submit with
stub=falsein YAML → callsSftpClient.write_filewhich is the stub function (records the call) - Filename matches HCPF regex (R209)
- Pubsub emits a
clearhouse.submittedevent per claim keyringmissing or Keychain empty → stub secret returned, no errorPOST /api/clearhouse/submitreturns 200 + receipt withstub: true- Payer_id not in DB → 404
- Submit 2 claims → 2 files written to
-
test_secrets.py— 5 cases:get_secret("sftp.gainwell.password")returns the Keychain value (mocked)- Keychain entry absent → returns None
keyringlibrary missing (Linux dev box) → returns None, no exception
-
test_providers_seed.py— 3 cases:- Migration 0007 runs cleanly on a fresh DB
- Seed inserts the 3 NPIs with correct fields
- Constraint:
tax_idandtaxonomy_codeare consistent across all 3 providers (asserts the 251E00000X / 721587149 invariant)
-
test_validation_r200_r210.py— 11 cases (one per new rule):- R200: BHT06=CH works, BHT06=XX fails
- R201: mixed-batch envelope fails
- R202: SBR09=ZZ works (MCO encounter), SBR09=AB fails
- R203: PRV mismatch fails
- R204: unknown NPI fails
- R205: tax_id mismatch fails
- R206: wrong payer_id fails
- R207: PWK segment present fails
- R208: 2320 CASPI on SBR09=MC fails
- R209: filename with UTC timestamp fails (must be MT)
- R210: inbound filename with wrong number of segments fails
Target backend test count after SP9: 574 + 14 + 9 + 7 + 5 + 3 + 11 = 623 tests.
8. Out of scope (future SPs)
- SP10 — 277CA parser: a new
parse_277camodule, a new 277CA lane in the Inbox, and the matching(payer_id, "277CA")config block inpayer_configs. - SP11 —
audit_logtable withprev_hashchaining (hash-chained, append-only, 6-year retention per HIPAA §164.316(b)(2)). - SP12 — SQLCipher-backed SQLite; DB key stored in Keychain via
secrets.get_secret("cyclone.db.key"). - SP13 —
paramiko-backedSftpClient.write_file/list_inbound/read_file, with real Keychain secret, manual fail-over to a second MFT path, and a retry queue for transient network failures.
9. Open questions resolved this session
| # | Question | Resolution |
|---|---|---|
| 1 | SFTP outbound + inbound paths on mft.gainwelltechnologies.com |
/CO XIX/PROD/coxix_prod_11525703/FromHPE (out), /CO XIX/PROD/coxix_prod_11525703/ToHPE (in) — user-provided 2026-06-20 |
| 2 | 3 NPI street addresses + ZIPs | All 3 NPIs share Montrose corporate address (user confirmed 2026-06-20) — seed from prod files |
| 3 | 3 NPI taxonomy codes | 251E00000X for all 3, self-served from 136 prod files |
| 4 | 277CA filename suffix | Use 277 per HCPF doc; distinguish 277CA by ST*277CA content (user confirmed 2026-06-20) |
| 5 | Payer ID: SKCO0 (current) vs CO_TXIX (spec) | Use CO_TXIX per spec (user confirmed 2026-06-20) |
10. Open questions still pending (blockers for SP10-SP13 only)
- None for SP9. Ready to implement.