186 lines
14 KiB
Markdown
186 lines
14 KiB
Markdown
# 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.
|