# 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.py` — **new**. 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.
