14 KiB
Sub-project 33 — Co TXIX Payer Fix: Design Spec
Date: 2026-07-02
Status: Draft, awaiting user sign-off
Branch: sp33-co-txix-payer-fix
Aesthetic direction: No new UI; production hotfix to emission
1. Scope
In scope:
- Change the canonical CO Medicaid payer identifier emitted in
NM1*PR NM108=PI*NM109fromSKCO0toCO_TXIX, in both the in-codePayerConfig.co_medicaid()factory and any other write path that derives the segment fromclaim.payer.id. - Update the in-repo companion-guide references (
docs/reference/837p.md,docs/reference/co-medicaid.md) to reflect the corrected value.docs/goodclaim.x12is already correct and stays the source of truth. - Fix
cyclone.inbox_state.apply_999_rejectionsto usebatch_envelope_index(mirroring the SP28 fix toapply_999_acceptances) so SET-level 999 acks correctly cascade to claim-state transitions. - One-shot backfill against the live DB: for each claim already linked via
claim_acksto a 999 ack withset_accept_reject_code='R', setclaims.state='REJECTED', fillrejection_reason,rejected_at,payer_rejected_reason,payer_rejected_at, and emit aclaim.rejectedaudit event. Idempotent. - Resubmit the 338 corrected single-claim 837 files (already split into
ingest/batch-*-claims/*.x12by the operator) by replacing theSKCO0literal in their bytes withCO_TXIX, then uploading to/CO XIX/PROD/coxix_prod_11525703/ToHPE/via the existing paramiko-backedSftpClient.
Out of scope:
- No new R-coded validator rule (the existing
R100_payer_id_matchesalready warns on mismatch; it auto-converges onceco_medicaid()returnsCO_TXIX). - No
CO_BHA(behavioral-health) variant in this increment. The HCPF 999 listsCO_TXIX OR CO_BHAas both accepted; this increment picksCO_TXIXas the single value for dzinesco's three Home-Health NPIs. A future SP may add a per-claim override if behavioral-health submission volumes warrant it. - No changes to other payer configs (only
co_medicaidis touched). - No schema migration. The existing
claims.payer_idcolumn already accepts any string;claims.resubmit_countalready exists and is incremented; no new column is added. - No frontend UI changes. The dashboard already surfaces claim states; once the backfill flips them, the "0 accepted" widget will reflect reality without code change.
2. Context (why now)
On 2026-07-01 at 16:29 MT, dzinesco submitted four 837P batches (145+95+25+73 = 338 claims) to Gainwell's SFTP at mft.gainwelltechnologies.com. On 2026-07-02, Gainwell returned 999 acknowledgments for each, all AK9=R with the SET-level error:
IK3*NM1*17*2010*8
IK5*R*I5
…where "2010BB NM109 must equal CO_TXIX or CO_BHA".
The in-repo reference (docs/reference/837p.md:48, docs/reference/co-medicaid.md:118) and the in-code PayerConfig.co_medicaid() factory (backend/src/cyclone/parsers/payer.py:69-70) both claim SKCO0 is the correct value. The authoritative sample file (docs/goodclaim.x12, line 4) shows NM1*PR*2*CO_TXIX*****PI*CO_TXIX — confirming CO_TXIX is correct. Tyler confirmed this in chat on 2026-07-02: "the problem is we arent changing SKCO0 to CO_TXIX".
A second, separate bug surfaced during the same investigation: apply_999_rejections (backend/src/cyclone/inbox_state.py:56) passes the SET control number to a claim_lookup that queries by patient_control_number. As a result, even when a 999 ack correctly rejects a SET, no claim state ever flips. This bug pre-dates SP33 but is fixed here because (a) the SP28 fix already exists for apply_999_acceptances and the same fix is a small, surgical change, and (b) the dashboard "0/145 accepted" widget can only reflect reality once the cascade works.
3. Decisions (locked during brainstorming)
- NM109 =
CO_TXIX, single value. No per-claim rule. dzinesco's three NPIs are all Home Health (taxonomy251E00000X); none are behavioral-health-only providers. Behavioral-health claims (if any exist) would currently emitCO_TXIXand Gainwell would still accept per the 999's "OR CO_BHA" wording. Confirmed with Tyler 2026-07-02. - Fix the cascade bug in the same increment. The 999 cascade is the same severity class as the 999 emission bug; splitting into a separate SP would mean a second hotfix in days. Confirmed with Tyler via Option A selection.
- Backfill is a one-shot CLI subcommand. A new
python -m cyclone.cli backfill-999-rejectionsinvocation, idempotent (claims already inREJECTEDstate are skipped). Lives incli.pyand a small helper module. Confirmed with Tyler via Option A. - Resubmit uses Tyler's pre-split files. Tyler already produced 338 single-claim 837 files in
ingest/batch-*-claims/*.x12(timestamps 16:41:43 on 2026-07-01). The resubmit path performs a deterministicSKCO0 → CO_TXIXbyte replacement on each file, validates via the existing parser, then uploads viaSftpClient.put(). Confirmed with Tyler via Option A (Step 6). - No new spec-on-the-fly for the bytecode fix. The
SKCO0 → CO_TXIXliteral replacement is a single-passbytes.replacescoped to theNM1*PRsegment of each file. Anything more sophisticated would be over-engineered for a hotfix.
4. Architecture
4.1 Source fix (feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX)
One file, two lines. backend/src/cyclone/parsers/payer.py:69-70:
payer_id="SKCO0", → payer_id="CO_TXIX",
payer_name="COHCPF", → payer_name="CO_TXIX",
The serializer (backend/src/cyclone/parsers/serialize_837.py:488) reads these fields directly and emits NM1*PR accordingly — no serializer change required. The 999 cascade (independent fix, see §4.2) is already gated on apply_999_rejections, which queries against claim.payer.id once the in-DB claims carry the new value.
4.2 Cascade fix (feat(sp33): apply_999_rejections uses batch_envelope_index)
backend/src/cyclone/inbox_state.py:55-65:
+ from cyclone.handlers._ack_id import batch_envelope_index # already exists
def apply_999_rejections(session, parsed_999, *, claim_lookup, batch_envelope_index=None):
...
- claim = claim_lookup(sr.set_control_number)
+ # SCN → list of claim_ids via batch_envelope_index; fall back to direct claim_lookup (rare)
+ candidates = (batch_envelope_index or {}).get(sr.set_control_number, [])
+ for cid in candidates:
+ claim = session.get(Claim, cid)
The new signature mirrors the SP28 pattern in apply_999_acceptances. Backwards-compatible: when batch_envelope_index=None, falls back to the old (broken) behavior, but every caller in handle_999.handle() will pass the index.
The handler at backend/src/cyclone/handlers/handle_999.py:88-99 updates in lockstep:
- rejection_result = apply_999_rejections(session, result, claim_lookup=_lookup)
+ rejection_result = apply_999_rejections(
+ session, result, claim_lookup=_lookup, batch_envelope_index=batch_index,
+ )
4.3 Backfill subcommand (feat(sp33): cli backfill-999-rejections)
New cli.py subcommand:
python -m cyclone.cli backfill-999-rejections [--actor <name>] [--dry-run]
Algorithm (single transaction, single SessionLocal):
SELECT ca.claim_id, ca.set_control_number, ca.set_accept_reject_code, a.ack_code, a.raw_json FROM claim_acks ca JOIN acks a ON a.id=ca.ack_id WHERE ca.set_accept_reject_code='R' AND ca.linked_at IS NOT NULL- For each
claim_id, loadClaimviasession.get. Skip if alreadyREJECTED(idempotent). Otherwise setstate='REJECTED',state_changed_at=now,rejected_at=now,rejection_reason=...,payer_rejected_at=now,payer_rejected_reason=...,payer_rejected_status_code='R', then appendAuditEvent(event_type='claim.rejected', actor=<name>). - Commit. Print
(matched=N already_rejected=M errors=K)summary.
For the 338 current claims, expected output: matched=338 already_rejected=0 errors=0.
4.4 Resubmit (feat(sp33): cli resubmit-rejected-claims)
New CLI subcommand:
python -m cyclone.cli resubmit-rejected-claims [--payer co_medicaid] [--ingest-dir <path>]
Defaults: --ingest-dir /home/tyler/dev/cyclone/ingest. Tyler pre-split the 338 single-claim 837 files into per-batch directories named batch-<batch_id>-<N>-claims/, where <batch_id> matches claims.batch_id and <N> is the claim count. The exact filenames match the SFTP outbound naming convention (TPID-837P-yyyymmddhhmmssSSS-1of1.x12) and are already unique-by-timestamp.
Algorithm:
- Discover all
claimsrows wherestate='REJECTED'(after the §4.3 backfill lands). Group bybatch_id. - For each
claim_idinREJECTED, locate the matching_837P-*.x12file iningest/batch-<batch_id>-<N>-claims/. Match by directory naming + per-claimpatient_control_numberdeclared in the file's CLM01 segment (re-parse each file once, cache the CLM01 list keyed by file). - For each file:
new_bytes = old_bytes.replace(b'PI*SKCO0', b'PI*CO_TXIX'). Validate viaparse_837_text(new_bytes). If validation produces any NEW error (compared to the same parse of the pre-replacement bytes), halt with the file path + error diff so the operator can inspect. - Upload via
cyclone.clearhouse.SftpClient.put(local_path, remote_path)to/CO XIX/PROD/coxix_prod_11525703/ToHPE/using the EXISTING filename. SFTP client retains the paramiko session across files (already supported per SP16). - After successful upload, increment
claims.resubmit_count(existing column, default 0) and emit oneclearhouse.submittedaudit event per file.claims.stateremainsREJECTED— theresubmit_count > 0flag is sufficient for the UI to distinguish "rejected-and-needs-resubmit" from "rejected-and-already-resubmitted". No new claim state is introduced.
For the 338 current claims: this runs once. Output filenames already reflect dzinesco's outbound SFTP naming scheme and are unique.
4.5 Doc updates (docs(spec): SP33 update reference docs to CO_TXIX)
Two files, two lines:
-
docs/reference/837p.md:48— replaceTrading partner: 'SKCO0' (sender) ↔ 'COHCPF' (receiver) on 'NM1*PR' / 'NM1*40'withTrading partner: 'COMEDASSISTPROG' (receiver, NM1*40) and payer 'CO_TXIX' (NM1*PR, NM108=PI, NM109=CO_TXIX) — see docs/goodclaim.x12 for the canonical example. -
docs/reference/co-medicaid.md:118— replace'NM1*PR N104 = "SKCO0"' (COHCPF)with'NM1*PR NM109 = "CO_TXIX"' (CO_TXIX)and add a sentence:For behavioral-health claims only, use "CO_BHA"; dzinesco's current submission volumes don't include behavioral-only providers.
docs/goodclaim.x12 stays unchanged (already correct).
5. Failure modes
| Failure | Detection | Mitigation |
|---|---|---|
| Serializer change breaks 999+1 backwards compat | pytest backend/tests/test_serialize_837.py |
Layered test: pre-fix goodclaim.x12 parse still passes; post-fix parse still passes (the payer.id change is in NM109 only) |
| Cascade fix changes behavior for already-accepted 999s | Unit test: feed 999 with AK5=A; assert no claim state changes |
apply_999_acceptances is unchanged; only apply_999_rejections is touched |
| Backfill races with live scheduler writing new claim_acks | Single-transaction SELECT-then-UPDATE; row-level retry on database is locked |
Wrap the per-claim update in a short retry loop (3 attempts, 100ms backoff) |
| Resubmit overwrites a not-yet-acknowledged file on SFTP | SFTP-side ETags / names | Use Tyler's pre-split filenames verbatim (already unique-by-timestamp) |
| SKCO0 still in some legacy 837 in the SFTP outbound dir | Out of scope — dzinesco is no longer submitting new 837s against the buggy factory | None; historical files on SFTP aren't retransmitted |
6. Test impact
backend/tests/test_apply_999_rejections.py— new. Covers:- SET-level
AK9=Rwith abatch_envelope_indexcontaining 3 claim_ids → all 3 transition to REJECTED, idempotent on rerun - SET-level
AK9=Awith empty envelope index → 0 matches (no false positives) - No
batch_envelope_indexpassed → falls back to old behavior (passes SCN as PCN, returns no matches for non-PCN-keyed claims)
- SET-level
backend/tests/test_serialize_837.py— extend: assertco_medicaid()round-trips throughserialize_837 → parse_837and the parsedpayer.idequalsCO_TXIXbackend/tests/test_payer_config_loading.py— confirm YAML still bindsCO_TXIXand no in-code change breaks the bootstrap pathbackend/tests/test_inbox_state_apply_999.py(if it exists) — adjust for the new optional arg
No frontend changes → no Vitest/RTL impact.
7. Branch / merge plan
- Branch:
sp33-co-txix-payer-fix - Atomic merge commit. PR title:
SP33 Co TXIX payer fix + 999 cascade repair - Commit prefix schedule:
docs(spec): SP33 CO TXIX payer fix designdocs(plan): SP33 CO TXIX payer fix implementationfeat(sp33): PayerConfig.co_medicaid() emits CO_TXIXfeat(sp33): apply_999_rejections uses batch_envelope_indextest(sp33): add test_apply_999_rejections with batch_envelope_indexfeat(sp33): cli backfill-999-rejectionsfeat(sp33): cli resubmit-rejected-claimsdocs(sp33): update docs/reference/{837p,co-medicaid}.md to CO_TXIXmerge: SP33 Co TXIX payer fix + 999 cascade repair into main
8. Rollback
If the SP33 merge breaks the live 837 flow:
- Revert the single merge commit (
git revert <merge-sha>— produces a fresh commit, preserves history). - The two doc edits revert cleanly (markdown only).
- The cascade-fix revert restores the SP28-era broken-but-not-dead behavior (claims stay SUBMITTED — same as today).
- The backfill is fully reversible: a sister CLI subcommand
python -m cyclone.cli reset-rejection-backfill [--batch-id <id>]can un-REJECT the 338 claims (setsstate='SUBMITTED', clearsrejection_reason,rejected_at,payer_rejected_*). Out of scope to ship in SP33, but the column data is fully captured in audit log so rollback is always possible. - The resubmit cannot be undone (the corrected 837 files are on SFTP), but a
clearhouseaudit event is recorded for every upload so the operator can identify and re-trigger if needed.