Files
cyclone/docs/superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md
T
2026-07-02 20:20:01 -06:00

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*NM109 from SKCO0 to CO_TXIX, in both the in-code PayerConfig.co_medicaid() factory and any other write path that derives the segment from claim.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.x12 is already correct and stays the source of truth.
  • Fix cyclone.inbox_state.apply_999_rejections to use batch_envelope_index (mirroring the SP28 fix to apply_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_acks to a 999 ack with set_accept_reject_code='R', set claims.state='REJECTED', fill rejection_reason, rejected_at, payer_rejected_reason, payer_rejected_at, and emit a claim.rejected audit event. Idempotent.
  • Resubmit the 338 corrected single-claim 837 files (already split into ingest/batch-*-claims/*.x12 by the operator) by replacing the SKCO0 literal in their bytes with CO_TXIX, then uploading to /CO XIX/PROD/coxix_prod_11525703/ToHPE/ via the existing paramiko-backed SftpClient.

Out of scope:

  • No new R-coded validator rule (the existing R100_payer_id_matches already warns on mismatch; it auto-converges once co_medicaid() returns CO_TXIX).
  • No CO_BHA (behavioral-health) variant in this increment. The HCPF 999 lists CO_TXIX OR CO_BHA as both accepted; this increment picks CO_TXIX as 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_medicaid is touched).
  • No schema migration. The existing claims.payer_id column already accepts any string; claims.resubmit_count already 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)

  1. NM109 = CO_TXIX, single value. No per-claim rule. dzinesco's three NPIs are all Home Health (taxonomy 251E00000X); none are behavioral-health-only providers. Behavioral-health claims (if any exist) would currently emit CO_TXIX and Gainwell would still accept per the 999's "OR CO_BHA" wording. Confirmed with Tyler 2026-07-02.
  2. 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.
  3. Backfill is a one-shot CLI subcommand. A new python -m cyclone.cli backfill-999-rejections invocation, idempotent (claims already in REJECTED state are skipped). Lives in cli.py and a small helper module. Confirmed with Tyler via Option A.
  4. 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 deterministic SKCO0 → CO_TXIX byte replacement on each file, validates via the existing parser, then uploads via SftpClient.put(). Confirmed with Tyler via Option A (Step 6).
  5. No new spec-on-the-fly for the bytecode fix. The SKCO0 → CO_TXIX literal replacement is a single-pass bytes.replace scoped to the NM1*PR segment 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):

  1. 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
  2. For each claim_id, load Claim via session.get. Skip if already REJECTED (idempotent). Otherwise set state='REJECTED', state_changed_at=now, rejected_at=now, rejection_reason=..., payer_rejected_at=now, payer_rejected_reason=..., payer_rejected_status_code='R', then append AuditEvent(event_type='claim.rejected', actor=<name>).
  3. 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:

  1. Discover all claims rows where state='REJECTED' (after the §4.3 backfill lands). Group by batch_id.
  2. For each claim_id in REJECTED, locate the matching _837P-*.x12 file in ingest/batch-<batch_id>-<N>-claims/. Match by directory naming + per-claim patient_control_number declared in the file's CLM01 segment (re-parse each file once, cache the CLM01 list keyed by file).
  3. For each file: new_bytes = old_bytes.replace(b'PI*SKCO0', b'PI*CO_TXIX'). Validate via parse_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.
  4. 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).
  5. After successful upload, increment claims.resubmit_count (existing column, default 0) and emit one clearhouse.submitted audit event per file. claims.state remains REJECTED — the resubmit_count > 0 flag 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 — replace Trading partner: 'SKCO0' (sender) ↔ 'COHCPF' (receiver) on 'NM1*PR' / 'NM1*40' with Trading 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.pynew. Covers:
    • SET-level AK9=R with a batch_envelope_index containing 3 claim_ids → all 3 transition to REJECTED, idempotent on rerun
    • SET-level AK9=A with empty envelope index → 0 matches (no false positives)
    • No batch_envelope_index passed → falls back to old behavior (passes SCN as PCN, returns no matches for non-PCN-keyed claims)
  • backend/tests/test_serialize_837.py — extend: assert co_medicaid() round-trips through serialize_837 → parse_837 and the parsed payer.id equals CO_TXIX
  • backend/tests/test_payer_config_loading.py — confirm YAML still binds CO_TXIX and no in-code change breaks the bootstrap path
  • backend/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 design
    • docs(plan): SP33 CO TXIX payer fix implementation
    • feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX
    • feat(sp33): apply_999_rejections uses batch_envelope_index
    • test(sp33): add test_apply_999_rejections with batch_envelope_index
    • feat(sp33): cli backfill-999-rejections
    • feat(sp33): cli resubmit-rejected-claims
    • docs(sp33): update docs/reference/{837p,co-medicaid}.md to CO_TXIX
    • merge: SP33 Co TXIX payer fix + 999 cascade repair into main

8. Rollback

If the SP33 merge breaks the live 837 flow:

  1. Revert the single merge commit (git revert <merge-sha> — produces a fresh commit, preserves history).
  2. The two doc edits revert cleanly (markdown only).
  3. The cascade-fix revert restores the SP28-era broken-but-not-dead behavior (claims stay SUBMITTED — same as today).
  4. 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 (sets state='SUBMITTED', clears rejection_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.
  5. The resubmit cannot be undone (the corrected 837 files are on SFTP), but a clearhouse audit event is recorded for every upload so the operator can identify and re-trigger if needed.