10 Commits

Author SHA1 Message Date
Nora 27bca33b09 feat(sp33): fix resubmit CLI session reuse + mismatch skip
- payer-mismatch files are now skipped instead of uploaded
- SFTP session persists across files (was reopened and leaked per file)
- --reconnect-every now counts successful uploads, after increment
- drop unused SftpClient instantiation; read each file once
- apply_999_rejections docstring: R/E/X, not R/E

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:32:30 -06:00
Nora f10ab83628 feat(sp33): cli resubmit-rejected-claims + fixture/test payer_id refresh
- Adds `cyclone resubmit-rejected-claims` to push corrected single-claim
  837 files to the Gainwell ToHPE SFTP dir. Idempotent (stat-then-skip by
  byte size). One persistent paramiko session per batch with
  reconnect-every=50 to dodge MOVEit's silent per-session file cap
  (~200 puts/session, no exception).
- Validates each file via `parse_837` before upload and rejects any
  whose payer_id is not `CO_TXIX` (catches a bad byte-fix early).
- Refreshes test fixtures (`minimal_837p.txt`, `co_medicaid_837p.txt`,
  `co_medicaid_837p_with_renderer.txt`) and the corresponding test
  assertions (`test_payer.py`, `test_payer_summary.py`,
  `test_co_medicaid_fixture.py`, `test_parse_837.py`) from the old
  `SKCO0`/`COHCPF` payer IDs to `CO_TXIX`, matching
  PayerConfig.co_medicaid() and the HCPF 837P Companion Guide.
- Adds `ingest/` to .gitignore — local scratch / production-data
  staging only.
2026-07-02 21:50:23 -06:00
Nora 244015a361 feat(sp33): cli backfill-999-rejections
Add the 'cyclone backfill-999-rejections' subcommand to replay the
cascade fix in apply_999_rejections for any 999 acks already in the
DB. Used on 2026-07-02 after Gainwell rejected the four dzinesco
batches at the SET level — the 999s were ingested but the pre-SP33
cascade bug didn't flip claim states, so the dashboard's '0/145
accepted' widget was lying.

The command walks claim_acks joined with claims where the link row's
set_accept_reject_code='R', groups by claim_id so each unique claim
fires exactly one audit event (the 36777 R-coded rows collapse to
339 unique claims), and flips each still-SUBMITTED claim to REJECTED
with rejection_reason + payer_rejected_* fields populated. Claims
already in REJECTED are skipped.

Also moves 'if __name__ == "__main__": main()' to the bottom of the
file. The old mid-file placement meant commands defined after it
(backup, pull-inbound, backfill-999-rejections) weren't accessible
via 'python -m cyclone.cli <sub>' — only the 'cyclone' console script
worked. Latent bug since SP17; surfaced when SP33 added another
post-block command.
2026-07-02 21:16:11 -06:00
Nora 3bf5622010 docs(sp33): update reference docs to CO_TXIX (per HCPF 837P Companion Guide)
docs/reference/837p.md line 48: the CO Medicaid trading-partner line
was still saying 'SKCO0 (sender) <-> COHCPF (receiver)'. The HCPF
837P Companion Guide (June 2025 - Version 2.5) requires
NM1*PR NM109 = CO_TXIX; SKCO0 causes Gainwell to reject the SET
('2010BB NM109 must equal CO_TXIX or CO_BHA').

docs/reference/co-medicaid.md line 118 + the Trading Partner table at
the top: same fix, with a pointer to the SP33 spec for the
root-cause story.

No code changes. Pure doc fix to match what the source now emits
(PayerConfig.co_medicaid().payer_id = 'CO_TXIX').
2026-07-02 21:07:12 -06:00
Nora 0625c83a45 feat(sp33): apply_999_rejections uses batch_envelope_index
The 999 handler's rejection pass was looking up claims by patient
control number, but Gainwell rejects at the SET level (ST envelope)
when the whole batch fails the NM109=CO_TXIX rule. That meant a SET
rejection was treated as a no-op even though every claim in the SET
was actually rejected by the payer.

Add a batch_envelope_index param (mirrors apply_999_acceptances from
SP28) so SET-level rejections cascade to every claim in the SET.
Falls back to the legacy PCN lookup when the index has no entry.

Also tightens test_payer.py: PayerConfig.co_medicaid() now returns
payer_id='CO_TXIX' and payer_name='CO_TXIX' per HCPF 837P Companion
Guide (June 2025 - Version 2.5).
2026-07-02 21:07:07 -06:00
Nora cf7c343ff0 feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX 2026-07-02 20:23:56 -06:00
Nora dae7749464 docs(plan): SP33 Co TXIX payer fix implementation 2026-07-02 20:22:17 -06:00
Nora 2e7ad471e0 docs(spec): SP33 CO TXIX payer fix design 2026-07-02 20:20:01 -06:00
Nora dfd654202e merge: SP32 rendering & service-provider NPI extraction into main 2026-07-02 17:21:49 -06:00
Nora 15c85300f6 docs(spec): SP32 align D7 with --file/--input-dir backfill design 2026-07-02 17:18:51 -06:00
19 changed files with 1566 additions and 55 deletions
+4
View File
@@ -38,3 +38,7 @@ claims_output/
# Brainstorm session artifacts (visual companion mockups, events, server state).
# Skills under .superpowers/skills/ are committed project-scoped guidance.
.superpowers/brainstorm/
# SP33+ scratch / production-data ingest. Generated artifacts live
# here only — the source EDI sits under docs/prodfiles/.
ingest/
+370 -4
View File
@@ -388,10 +388,6 @@ def backfill_rendering_npi(
)
if __name__ == "__main__":
main()
# ---------------------------------------------------------------------------
# SP17: `cyclone backup` subcommands
#
@@ -403,6 +399,372 @@ if __name__ == "__main__":
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# SP33: `cyclone backfill-999-rejections`
#
# One-shot replay of the cascade fix in `apply_999_rejections` for any
# 999 acks already in the DB. Used on the night of 2026-07-02 after
# Gainwell rejected the four dzinesco batches at the SET level
# ("2010BB NM109 must equal CO_TXIX") — the 999s were ingested but the
# pre-SP33 cascade bug didn't flip claim states, so the dashboard's
# "0/145 accepted" widget was lying.
#
# Idempotent: claims already in REJECTED are skipped (counted in
# `already_rejected`). Safe to re-run.
# ---------------------------------------------------------------------------
@main.command("backfill-999-rejections")
@click.option("--dry-run", is_flag=True, default=False,
help="Print the would-be state transitions without writing.")
@click.option("--actor", default="sp33-backfill",
show_default=True,
help="Audit-log actor tag for the claim.rejected events.")
def backfill_999_rejections(dry_run: bool, actor: str) -> None:
"""Replay existing 999 rejections onto already-linked claims (SP33).
Walks ``claim_acks`` joined with ``claims`` where the link row's
``set_accept_reject_code='R'`` (a SET-level rejection from the 999
envelope) and flips the matching claim to ``REJECTED`` — mirroring
what the (now-fixed) ``apply_999_rejections`` would have done at
ingest time.
Each flipped claim gets:
- ``state`` set to ``REJECTED``
- ``state_changed_at`` and ``rejected_at`` set to now
- ``rejection_reason`` filled with the 999 AK5 code + SCN
- ``payer_rejected_at`` / ``payer_rejected_reason`` /
``payer_rejected_status_code`` filled (the 999 SET-level reject
is also a payer-side reject for Inbox-lanes purposes)
- one ``claim.rejected`` audit-log event
Claims already in REJECTED are skipped (counted in the summary).
"""
from datetime import datetime, timezone
from sqlalchemy import select, func
from cyclone import db as db_mod
from cyclone.audit_log import AuditEvent, append_event
db_mod.init_db()
now = datetime.now(timezone.utc)
with db_mod.SessionLocal()() as session:
# Pull one representative R-coded link per claim_id (a single
# 999 may stamp many AK2 rows against the same claim, so we
# collapse via MIN(set_control_number) / MIN(ak2_index) to avoid
# firing 1 audit event per duplicate ack row).
#
# 36777 R-coded rows resolve to 339 unique claims (338 still in
# SUBMITTED + 1 already in REJECTED). Grouping in SQL keeps the
# in-Python loop small AND emits exactly 1 audit event per claim.
rows = session.execute(
select(
db_mod.Claim.id,
db_mod.Claim.state,
func.min(db_mod.ClaimAck.set_control_number).label("scn"),
func.min(db_mod.ClaimAck.ak2_index).label("ak2"),
func.min(db_mod.Ack.ack_code).label("ack_code"),
)
.join(db_mod.ClaimAck, db_mod.ClaimAck.claim_id == db_mod.Claim.id)
.join(db_mod.Ack, db_mod.Ack.id == db_mod.ClaimAck.ack_id)
.where(db_mod.ClaimAck.set_accept_reject_code == "R")
.where(db_mod.ClaimAck.claim_id.is_not(None))
.group_by(db_mod.Claim.id, db_mod.Claim.state)
).all()
matched = 0
already = 0
errors = 0
for (claim_id, current_state, scn, ak2_idx, ack_code) in rows:
if current_state == db_mod.ClaimState.REJECTED:
already += 1
continue
if dry_run:
matched += 1
continue
claim = session.get(db_mod.Claim, claim_id)
if claim is None:
errors += 1
continue
claim.state = db_mod.ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = (
f"999 AK5={ack_code or 'R'} SCN={scn} ak2={ak2_idx}"
)
# Mirror the 999 SET-level reject into the payer-rejected
# lane so the Inbox sees it as a Payer-Rejected claim too.
claim.payer_rejected_at = now
claim.payer_rejected_reason = f"999 SET-level reject at SCN={scn}"
claim.payer_rejected_status_code = "R"
append_event(session, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=claim_id,
payload={"source": "backfill-999-rejections",
"scn": scn, "ak2_index": ak2_idx,
"ack_code": ack_code},
actor=actor,
))
matched += 1
if not dry_run:
session.commit()
click.echo(
f"matched={matched} already_rejected={already} errors={errors} "
f"dry_run={dry_run}"
)
# ---------------------------------------------------------------------------
# SP33: `cyclone resubmit-rejected-claims`
#
# Push the corrected single-claim 837 files to Gainwell's SFTP ToHPE
# dir so dzinesco can resubmit the batch. The byte-level
# SKCO0 -> CO_TXIX fix is assumed to have already been applied (see
# the SP33 plan §4.4 / `docs/ingest/corrected/`). This CLI just walks
# the corrected directory, validates each file via the parser, and
# uploads via the real SftpClient.
#
# Idempotent: a file already present on the remote with the same byte
# size is skipped (counted in `skipped`). Re-runnable after a partial
# failure without re-uploading files that landed.
# ---------------------------------------------------------------------------
@main.command("resubmit-rejected-claims")
@click.option("--ingest-dir", default="ingest/corrected",
show_default=True,
help="Root dir holding batch-*-claims/ subfolders of fixed .x12 files.")
@click.option("--actor", default="sp33-resubmit",
show_default=True,
help="Audit-log actor tag for the clearhouse.submitted events.")
@click.option("--validate/--no-validate", default=True,
help="Parse each file via parse_837 before upload (catches a bad fix).")
@click.option("--limit", type=int, default=None,
help="Stop after checking this many files (smoke-tests). Counts "
"all attempts, not just successful uploads.")
@click.option("--reconnect-every", type=int, default=50, show_default=True,
help="Reconnect SFTP every N uploads to avoid MOVEit's per-session cap.")
def resubmit_rejected_claims(
ingest_dir: str,
actor: str,
validate: bool,
limit: int | None,
reconnect_every: int,
) -> None:
"""Upload corrected 837 files to the Gainwell SFTP ToHPE dir (SP33).
Walks every ``batch-*-claims/*.x12`` under ``--ingest-dir`` (default
``./ingest/corrected``), validates each one through ``parse_837``,
and uploads via the seeded real-SFTP ``SftpClient``.
Idempotent: a file already present on the remote with the same
byte size is skipped (counted in ``skipped``). Reconnects every
``--reconnect-every`` uploads to avoid MOVEit's silent per-session
file cap (observed: ~200 puts/session before silent drops with no
exception — see SP33 root-cause notes).
Doesn't mutate claim state — claims stay in REJECTED until a 999
ACK confirms Gainwell accepted the resubmit. Emits one
``clearhouse.submitted`` audit event per successful upload.
"""
import time
from cyclone import db as db_mod
from cyclone.audit_log import AuditEvent, append_event
from cyclone.parsers.parse_837 import parse as parse_837_text
from cyclone.parsers.payer import PayerConfig
from cyclone.store import store as cycl_store
db_mod.init_db()
cycl_store.ensure_clearhouse_seeded()
clearhouse = cycl_store.get_clearhouse()
if clearhouse is None:
click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True)
sys.exit(2)
sftp_block = clearhouse.sftp_block
if sftp_block.stub:
click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True)
sys.exit(2)
remote_root = sftp_block.paths["outbound"]
root = Path(ingest_dir).resolve()
if not root.exists():
click.echo(f"--ingest-dir does not exist: {root}", err=True)
sys.exit(2)
files: list[Path] = []
for batch_dir in sorted(root.glob("batch-*-claims")):
files.extend(sorted(p for p in batch_dir.glob("*.x12")
if not p.name.startswith("._")))
if not files:
click.echo(f"No .x12 files under {root}", err=True)
sys.exit(2)
click.echo(f"found {len(files)} files under {root}")
uploaded = 0
skipped = 0
failed = 0
validated = 0
payer_cfg = PayerConfig.co_medicaid()
start = time.monotonic()
last_progress = start
# One persistent paramiko session per batch, with periodic
# reconnect to dodge MOVEit's silent per-session file cap
# (~200 puts/session, no exception — see SP33 root-cause notes).
import paramiko
from cyclone.secrets import get_secret
def _open_session() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
pw = get_secret(sftp_block.auth.get("password_keychain_account", ""))
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(
sftp_block.host, port=sftp_block.port,
username=sftp_block.username, password=pw,
timeout=15, banner_timeout=15, auth_timeout=15,
)
return ssh, ssh.open_sftp()
def _close_session(ssh: paramiko.SSHClient | None) -> None:
if ssh is None:
return
try: ssh.close()
except Exception: pass
ssh: paramiko.SSHClient | None = None
sftp: paramiko.SFTPClient | None = None
for i, src in enumerate(files, 1):
if limit is not None and i > limit:
break
content = src.read_bytes()
# Validate: parse must succeed AND payer_id must match the
# companion-guide CO_TXIX (catches a bad byte-fix early).
if validate:
try:
parsed = parse_837_text(content.decode(), payer_cfg)
except Exception as exc: # noqa: BLE001
failed += 1
click.echo(f"PARSE FAIL {src.name}: {exc.__class__.__name__}: {exc}", err=True)
continue
mismatch = next(
(c for c in parsed.claims if c.payer.id != "CO_TXIX"), None
)
if mismatch is not None:
failed += 1
click.echo(
f"PAYER MISMATCH {src.name}: payer.id={mismatch.payer.id!r} "
f"(expected 'CO_TXIX')", err=True,
)
continue
validated += 1
local_size = len(content)
remote_path = f"{remote_root}/{src.name}"
attempts = 0
ok = False
while attempts < 3:
attempts += 1
if ssh is None:
try:
ssh, sftp = _open_session()
except Exception as exc: # noqa: BLE001
click.echo(
f"SFTP CONNECT FAIL attempt {attempts}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
continue
try:
# Idempotency check.
try:
rs = sftp.stat(remote_path) # type: ignore[union-attr]
if rs.st_size == local_size:
skipped += 1
ok = True
break
except IOError:
pass # not on remote yet
sftp.put(str(src), remote_path) # type: ignore[union-attr]
ok = True
break
except Exception as exc: # noqa: BLE001
# Connection died — drop the session and let the next
# attempt reopen.
_close_session(ssh)
ssh, sftp = None, None
if attempts >= 3:
click.echo(
f"UPLOAD FAIL {src.name}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
continue
if not ok:
failed += 1
continue
# Audit (best-effort; if the DB is unavailable we still keep
# the file on the wire).
try:
with db_mod.SessionLocal()() as session:
append_event(session, AuditEvent(
event_type="clearhouse.submitted",
entity_type="claim_file",
entity_id=src.name,
payload={"remote_path": remote_path,
"source": "resubmit-rejected-claims",
"size": local_size},
actor=actor,
))
session.commit()
except Exception as exc: # noqa: BLE001
click.echo(
f"audit-log write failed for {src.name}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
uploaded += 1
# Reconnect periodically to dodge MOVEit's per-session cap.
if uploaded % reconnect_every == 0:
click.echo(f"reconnecting (after {uploaded} uploads)", err=True)
_close_session(ssh)
ssh, sftp = None, None
# Progress every 10s of wall-clock (or at end).
now = time.monotonic()
if now - last_progress >= 10 or i == len(files):
elapsed = now - start
rate = uploaded / elapsed if elapsed else 0
click.echo(
f"progress {i}/{len(files)} uploaded={uploaded} "
f"skipped={skipped} failed={failed} rate={rate:.2f}/s "
f"elapsed={elapsed:.1f}s", err=True,
)
last_progress = now
# Tear down the long-lived session if one is still open.
if ssh is not None:
_close_session(ssh)
elapsed = time.monotonic() - start
click.echo(
f"DONE uploaded={uploaded} skipped={skipped} failed={failed} "
f"validated={validated} files_total={len(files)} elapsed={elapsed:.1f}s"
)
@main.group()
def backup() -> None:
"""Encrypted DB backup management (SP17)."""
@@ -824,3 +1186,7 @@ def pull_inbound(
click.echo("download errors:", err=True)
for e in summary["download_errors"]:
click.echo(f" {e}", err=True)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -90,7 +90,9 @@ def handle(
.first()
)
rejection_result = apply_999_rejections(
session, result, claim_lookup=_lookup,
session, result,
claim_lookup=_lookup,
batch_envelope_index=batch_index,
)
if rejection_result.matched:
for cid in rejection_result.matched:
+25 -4
View File
@@ -33,35 +33,56 @@ def apply_999_rejections(
parsed_999,
*,
claim_lookup: Callable[[str], Claim | None],
batch_envelope_index: dict[str, list[str]] | None = None,
) -> Apply999Result:
"""For each set response with code R or E, look up the matching claim and
"""For each set response with code R, E, or X, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims.
Args:
session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses).
claim_lookup: callable from patient_control_number → Claim or None.
Legacy fallback; rarely hits when batch_envelope_index is present.
batch_envelope_index: SP33 — mapping from SET control_number (the 837
envelope's ST02) to list of Claim.id for the claims in that SET.
Mirrors the SP28 fix in apply_999_acceptances so SET-level
rejections correctly cascade across every claim under the SET.
Returns:
Apply999Result with lists of matched claim ids and orphan PCNs.
"""
result = Apply999Result()
now = datetime.now(timezone.utc)
index = batch_envelope_index or {}
for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code
if code not in ("R", "E", "X"):
continue
claim = claim_lookup(sr.set_control_number)
if claim is None:
# SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level
# rejection correctly flips every claim in the SET. Fall back to
# the legacy claim_lookup when the index is empty for this SCN.
candidate_ids = index.get(sr.set_control_number, []) or []
claims_to_reject: list[Claim] = []
if candidate_ids:
claims_to_reject = (
session.query(Claim)
.filter(Claim.id.in_(candidate_ids))
.all()
)
else:
legacy = claim_lookup(sr.set_control_number)
if legacy is not None:
claims_to_reject = [legacy]
else:
result.orphans.append(sr.set_control_number)
continue
for claim in claims_to_reject:
if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
+2 -2
View File
@@ -66,8 +66,8 @@ class PayerConfig(BaseModel):
# Lenient in v1 — see spec §9 R031.
require_ref_g1_for_adjustments=False,
allowed_bht06={"CH"},
payer_id="SKCO0",
payer_name="COHCPF",
payer_id="CO_TXIX",
payer_name="CO_TXIX",
no_patient_loop=True,
encounter_claim_in_same_batch=False,
allowed_facility_qualifiers={"B"},
+2 -2
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~
DMG*D8*19590223*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
@@ -35,7 +35,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~
N4*Montrose*CO*814019524~
DMG*D8*19570727*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
+2 -2
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~
DMG*D8*19590223*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
@@ -36,7 +36,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~
N4*Montrose*CO*814019524~
DMG*D8*19570727*F~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
+1 -1
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Doe*John****MI*ABC123~
N3*456 Member St~
N4*Denver*CO*80203~
DMG*D8*19800101*M~
NM1*PR*2*COHCPF*****PI*SKCO0~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~
REF*G1*PA123~
HI*ABK:Z00~
+120
View File
@@ -0,0 +1,120 @@
"""SP33: apply_999_rejections must use batch_envelope_index (mirrors
SP28's apply_999_acceptances fix) so SET-level 999 rejections cascade
to claim state transitions.
The pre-SP33 bug: ``claim_lookup(sr.set_control_number)`` passed the
SET control_number to a PCN-based lookup, so no claim was ever matched
and rejections silently died. SP33 threads the same
``batch_envelope_index`` that ``apply_999_acceptances`` already has.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from cyclone.db import ClaimState
from cyclone.inbox_state import apply_999_rejections
def _mk_set_response(code: str = "R", scn: str = "991102994"):
"""Build a stand-in for ``ParseResult999.set_responses[0]``."""
return SimpleNamespace(
set_accept_reject=SimpleNamespace(code=code),
set_control_number=scn,
segment_errors=[],
)
def _mk_claim(cid: str = "claim-x", state=ClaimState.SUBMITTED):
c = MagicMock()
c.id = cid
c.state = state
return c
def test_set_level_rejection_cascades_via_envelope_index():
"""When the SET-level 999 has set_control_number=991102994 and the
batch_envelope_index maps it to a list of 3 claim_ids, all 3 should
transition to REJECTED.
"""
session = MagicMock()
claims = [_mk_claim(f"c-{i}") for i in range(3)]
session.query.return_value.filter.return_value.all.return_value = claims
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
index = {"991102994": [c.id for c in claims]}
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index=index,
)
assert sorted(result.matched) == sorted(c.id for c in claims)
assert result.orphans == []
session.commit.assert_called_once()
for c in claims:
# ClaimState.REJECTED is the lowercase 'rejected' string (the enum
# value stored in the DB column via SQLAlchemy Enum type).
assert c.state == ClaimState.REJECTED
def test_set_level_rejection_without_index_still_uses_legacy_lookup():
"""Backwards compat: when batch_envelope_index is None, the function
must fall back to claim_lookup (existing behavior preserved).
"""
session = MagicMock()
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
legacy_claim = _mk_claim("legacy-claim")
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None,
)
assert result.matched == ["legacy-claim"]
def test_no_envelope_index_no_match_becomes_orphan():
"""Empty index + empty legacy lookup => the SET becomes an orphan."""
session = MagicMock()
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102999")])
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={}, # empty index
)
assert result.matched == []
assert "991102999" in result.orphans
def test_accepted_999_does_not_reject():
"""An 'A' code must not transition any claim to REJECTED."""
session = MagicMock()
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="A", scn="991102994")])
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={"991102994": ["claim-x"]},
)
assert result.matched == []
session.query.assert_not_called()
session.commit.assert_not_called()
def test_already_rejected_is_idempotent():
"""Pre-REJECTED claims in the index are left alone (no second commit)."""
session = MagicMock()
already = _mk_claim("already-rejected", state=ClaimState.REJECTED)
session.query.return_value.filter.return_value.all.return_value = [already]
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={"991102994": ["already-rejected"]},
)
assert result.matched == []
assert already.state == ClaimState.REJECTED # untouched
+2 -2
View File
@@ -36,5 +36,5 @@ def test_subscriber_payer_reused_across_claims():
result = parse(FIXTURE.read_text(), PayerConfig.co_medicaid())
assert result.claims[0].billing_provider.npi == "1881068062"
assert result.claims[1].billing_provider.npi == "1881068062"
assert result.claims[0].payer.id == "SKCO0"
assert result.claims[1].payer.id == "SKCO0"
assert result.claims[0].payer.id == "CO_TXIX"
assert result.claims[1].payer.id == "CO_TXIX"
@@ -0,0 +1,33 @@
"""SP33 regression test: ``PayerConfig.co_medicaid()`` emits CO_TXIX.
The HCPF 837P Companion Guide (June 2025 - Version 2.5) requires
``NM1*PR NM108=PI, NM109=CO_TXIX`` for the Payer Name loop (2010BB).
The in-code factory historically emitted ``SKCO0`` which Gainwell
rejects with ``IK3*NM1*17*2010*8``. This test pins the corrected value.
See ``docs/superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md``.
"""
from __future__ import annotations
def test_co_medicaid_factory_emits_CO_TXIX():
"""Per HCPF 837P Companion Guide, NM1*PR NM109 must equal CO_TXIX."""
from cyclone.parsers.payer import PayerConfig
cfg = PayerConfig.co_medicaid()
assert cfg.payer_id == "CO_TXIX", (
f"co_medicaid().payer_id must be 'CO_TXIX' (HCPF guide), got {cfg.payer_id!r}"
)
assert cfg.payer_name == "CO_TXIX", (
f"co_medicaid().payer_name must be 'CO_TXIX' (matches docs/goodclaim.x12), got {cfg.payer_name!r}"
)
def test_skco0_not_present_in_factory():
"""Hard guard: SKCO0 must never be emitted by the factory again."""
from cyclone.parsers.payer import PayerConfig
cfg = PayerConfig.co_medicaid()
assert "SKCO0" not in cfg.payer_id, (
f"SKCO0 must not appear in payer_id (HCPF guide says CO_TXIX), got {cfg.payer_id!r}"
)
assert "SKCO0" not in cfg.payer_name, (
f"SKCO0 must not appear in payer_name, got {cfg.payer_name!r}"
)
+1 -1
View File
@@ -18,7 +18,7 @@ def test_parse_minimal_fixture_returns_one_claim():
assert claim.subscriber.last_name == "Doe"
assert claim.subscriber.first_name == "John"
assert claim.subscriber.member_id == "ABC123"
assert claim.payer.id == "SKCO0"
assert claim.payer.id == "CO_TXIX"
assert claim.claim.frequency_code == "1"
assert claim.claim.place_of_service == "12"
assert claim.claim.prior_auth == "PA123"
+2 -2
View File
@@ -8,8 +8,8 @@ def test_co_medicaid_defaults():
assert cfg.allowed_claim_frequencies == {1, 7, 8}
assert cfg.require_ref_g1_for_adjustments is False # lenient in v1
assert cfg.allowed_bht06 == {"CH"}
assert cfg.payer_id == "SKCO0"
assert cfg.payer_name == "COHCPF"
assert cfg.payer_id == "CO_TXIX"
assert cfg.payer_name == "CO_TXIX"
assert cfg.no_patient_loop is True
assert cfg.encounter_claim_in_same_batch is False
+9 -9
View File
@@ -5,13 +5,13 @@ The endpoint is the payer-level aggregate that the drill-down UI's
denial rate, and the top 5 NPIs by claim volume for one payer_id,
cached in-process for 60s.
The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``,
The minimal 837P fixture ships one CLM with ``payer_id="CO_TXIX"``,
charge_amount=100.00; the minimal 835 carries one CLP for the same
claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns
claim with total_paid=85.00. So ``/api/payers/CO_TXIX/summary`` returns
``claim_count >= 1`` after both files are ingested.
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
e.g. ``SKCO0``). It is NOT the configured payer name from
e.g. ``CO_TXIX``). It is NOT the configured payer name from
``config/payers.yaml``. The filter key in the store layer is
``Claim.payer_id`` — not the ``payer=`` substring filter used by
``/api/claims?payer=...``.
@@ -54,7 +54,7 @@ def client() -> TestClient:
def seeded_db(client: TestClient):
"""Ingest one minimal 837P + one minimal 835.
Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint
Both fixtures carry ``payer_id="CO_TXIX"`` so the summary endpoint
has something to aggregate. ``client`` is yielded back so the
test can hit the API on the same TestClient that ingested the
fixtures (parses share the per-test SQLite from conftest).
@@ -77,11 +77,11 @@ def seeded_db(client: TestClient):
def test_payer_summary_happy_path(seeded_db: TestClient):
"""Seeded db has at least one claim for SKCO0 → 200 with the spec shape."""
resp = seeded_db.get("/api/payers/SKCO0/summary")
"""Seeded db has at least one claim for CO_TXIX → 200 with the spec shape."""
resp = seeded_db.get("/api/payers/CO_TXIX/summary")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["payer_id"] == "SKCO0"
assert data["payer_id"] == "CO_TXIX"
assert "claim_count" in data
assert "billed_total" in data
assert "received_total" in data
@@ -99,8 +99,8 @@ def test_payer_summary_unknown_payer_returns_404(client: TestClient):
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
"""Two back-to-back calls return identical payloads (in-process cache)."""
resp1 = seeded_db.get("/api/payers/SKCO0/summary")
resp2 = seeded_db.get("/api/payers/SKCO0/summary")
resp1 = seeded_db.get("/api/payers/CO_TXIX/summary")
resp2 = seeded_db.get("/api/payers/CO_TXIX/summary")
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json() == resp2.json()
+1 -1
View File
@@ -45,7 +45,7 @@ full walker.
## CO Medicaid specifics
- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40`
- Trading partners: `COMEDASSISTPROG` (NM1*40 NM109, 1000B receiver) and `CO_TXIX` (NM1*PR NM109, 2010BB payer). See `docs/goodclaim.x12` for a canonical example and the HCPF 837P Companion Guide for the full segment table.
- `CLM05` is a composite of three components: place of service, facility code
qualifier, and frequency code (in that order)
- `CLM05-1` = place of service (any valid CMS POS code)
+7 -4
View File
@@ -9,11 +9,14 @@ Financing (HCPF) requires.
| Role | 837P | 835 |
|---|---|---|
| Sender | `SKCO0` | (varies; usually the clearinghouse) |
| Receiver | `COHCPF` | (varies) |
| Sender (submitter, `NM1*41`) | `Dzinesco` (TPID `11525703`) | (varies; usually the clearinghouse) |
| Receiver (`NM1*40`) | `COMEDASSISTPROG` | (varies) |
| Payer (`NM1*PR`) | `CO_TXIX` (per HCPF 837P Companion Guide) | (varies) |
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
the 837P file.
the 837P file. The legacy codes `SKCO0` / `COHCPF` are no longer
accepted by Gainwell (HCPF error: "2010BB NM109 must equal CO_TXIX or
CO_BHA"); see [SP33](../superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md).
## dzinesco's TPID (clearinghouse identity)
@@ -115,7 +118,7 @@ ls -la "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/ToHPE/"
### 837P (claims)
- `NM1*PR N104 = "SKCO0"` (COHCPF)
- `NM1*PR NM108 = "PI"`, `NM1*PR NM109 = "CO_TXIX"` (per HCPF 837P Companion Guide, June 2025 — Version 2.5). dzinesco submits against this code under dzinesco TPID `11525703`. The legacy trading-partner ID `SKCO0` is no longer accepted as the payer identifier.
### 835 (remittance)
@@ -0,0 +1,768 @@
# SP33 — Co TXIX Payer Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` (Tyler picked inline execution for SP33, single-operator night-shift). Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make dzinesco's CO Medicaid 837P submissions emit `NM1*PR*...*PI*CO_TXIX` (per HCPF companion guide), cascade existing 999-rejections into `claims.state=REJECTED`, backfill the 338 rejected claims, and resubmit corrected 837s to Gainwell SFTP — all in one tonight.
**Architecture:** A 2-line payer-config edit + a 3-line `apply_999_rejections` signature fix unlock the cascade. Two new `cyclone.cli` subcommands do the backfill and the resubmit. The doc updates align the in-repo reference with the authoritative HCPF companion guide Tyler pasted. Atomic single merge into `main`.
**Tech Stack:** Python 3.11, SQLAlchemy + SQLite/SQLCipher, paramiko (SFTP), pytest. Frontend untouched.
**Spec:** [`docs/superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md`](../specs/2026-07-02-cyclone-co-txix-payer-fix-design.md)
**Authoritative source:** CO HCPF "Health Care Claim Professional (837) Transaction Standard Companion Guide" (June 2025 Version 2.5). Loop 2010BB NM1 Payer Name says `NM108=PI`, `NM109=CO_TXIX` — confirmed by Tyler via chat 2026-07-02. In-repo reference for shape: [`docs/goodclaim.x12`](../../goodclaim.x12).
---
## File structure
| Path | Action | Responsibility |
|---|---|---|
| `backend/src/cyclone/parsers/payer.py` | modify (lines 69-70) | `co_medicaid()` factory emits CO_TXIX |
| `backend/src/cyclone/inbox_state.py` | modify (`apply_999_rejections` signature) | Cascade fix w/ batch_envelope_index |
| `backend/src/cyclone/handlers/handle_999.py` | modify (caller) | Pass batch_envelope_index through |
| `backend/src/cyclone/cli.py` | extend (2 new subcommands) | backfill-999-rejections, resubmit-rejected-claims |
| `backend/tests/test_apply_999_rejections.py` | create | Cascade-fix coverage |
| `backend/tests/test_serialize_837.py` | extend | Round-trip with CO_TXIX |
| `docs/reference/837p.md` | modify (line 48) | Doc update |
| `docs/reference/co-medicaid.md` | modify (line 118) | Doc update |
## Task 0: Pre-flight + branch
**Files:** none (git only)
- [ ] **Step 1: Verify clean tree**
Run: `git status --short`
Expected: only the spec commit `2e7ad47 docs(spec): SP33 CO TXIX payer fix design`.
- [ ] **Step 2: Create branch off main**
Run: `git checkout -b sp33-co-txix-payer-fix main`
Expected: `Switched to a new branch 'sp33-co-txix-payer-fix'` (already on `main` at `2e7ad47` or `dfd6542+`).
- [ ] **Step 3: Confirm Docker backend is up**
Run: `docker ps --filter name=cyclone-backend-1 --format '{{.Names}} {{.Status}}'`
Expected: `cyclone-backend-1 Up X minutes (healthy)`.
## Task 1: Fix `co_medicaid()` factory
**Files:**
- Modify: `backend/src/cyclone/parsers/payer.py:69-70`
- [ ] **Step 1: Read the current block (sanity)**
Run: `sed -n '67,72p' backend/src/cyclone/parsers/payer.py`
Expected output:
```
allowed_bht06={"CH"},
payer_id="SKCO0",
payer_name="COHCPF",
no_patient_loop=True,
```
- [ ] **Step 2: Apply the fix (2 lines)**
`search_replace` exact:
OLD:
```
payer_id="SKCO0",
payer_name="COHCPF",
```
NEW:
```
payer_id="CO_TXIX",
payer_name="CO_TXIX",
```
(Drop the bad stale-comment line above it too — the `co_medicaid()` docstring still says "Source: `docs/companionguides/837p.md`" which is wrong; leave the docstring alone in this increment, ship only the data-fix tonight, docstring cleanup is a separate clean-up.)
- [ ] **Step 3: Verify the diff**
Run: `git diff backend/src/cyclone/parsers/payer.py`
Expected: a 2-line change, exactly the two `payer_id`/`payer_name` lines.
- [ ] **Step 4: Add test**
Create: `backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py`:
```python
"""SP33 regression test: ``PayerConfig.co_medicaid()`` emits CO_TXIX."""
from __future__ import annotations
def test_co_medicaid_factory_emits_CO_TXIX():
"""Per HCPF 837P Companion Guide, NM1*PR NM109 must equal CO_TXIX."""
from cyclone.parsers.payer import PayerConfig
cfg = PayerConfig.co_medicaid()
assert cfg.payer_id == "CO_TXIX", (
f"co_medicaid().payer_id must be 'CO_TXIX' (HCPF guide), got {cfg.payer_id!r}"
)
assert cfg.payer_name == "CO_TXIX", (
f"co_medicaid().payer_name must be 'CO_TXIX' (matches docs/goodclaim.x12), got {cfg.payer_name!r}"
)
```
- [ ] **Step 5: Run the test in the container (since the venv on host isn't trustworthy)**
Run:
```bash
docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/pytest tests/test_co_medicaid_payer_id_emits_CO_TXIX.py -v"
```
Expected: 1 passed.
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/parsers/payer.py backend/tests/test_co_medicaid_payer_id_emits_CO_TXIX.py
git commit -m "feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX"
```
## Task 2: Fix `apply_999_rejections` cascade
**Files:**
- Modify: `backend/src/cyclone/inbox_state.py` (`apply_999_rejections` signature)
- Modify: `backend/src/cyclone/handlers/handle_999.py:88-99` (caller)
- [ ] **Step 1: Read current function**
Run: `sed -n '44,90p' backend/src/cyclone/inbox_state.py`
Expected: see `claim = claim_lookup(sr.set_control_number)` at ~line 56.
- [ ] **Step 2: Apply signature + body change**
OLD (the full function body, lines 44-69):
```python
def apply_999_rejections(
session: Session,
parsed_999,
*,
claim_lookup: Callable[[str], Claim | None],
) -> Apply999Result:
"""For each set response with code R or E, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims.
Args:
session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses).
claim_lookup: callable from patient_control_number → Claim or None.
Returns:
Apply999Result with lists of matched claim ids and orphan PCNs.
"""
result = Apply999Result()
now = datetime.now(timezone.utc)
for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code
if code not in ("R", "E", "X"):
continue
claim = claim_lookup(sr.set_control_number)
if claim is None:
result.orphans.append(sr.set_control_number)
continue
if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = _build_reason(
code, len(sr.segment_errors or [])
)
result.matched.append(claim.id)
if result.matched or result.orphans:
session.commit()
return result
```
NEW:
```python
def apply_999_rejections(
session: Session,
parsed_999,
*,
claim_lookup: Callable[[str], Claim | None],
batch_envelope_index: dict[str, list[str]] | None = None,
) -> Apply999Result:
"""For each set response with code R or E, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims.
Args:
session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses).
claim_lookup: callable from patient_control_number → Claim or None
(legacy fallback; rarely hits with the batch_envelope_index path).
batch_envelope_index: SP33 — mapping from SET control_number
(the 837 envelope's ST02) to claim.id list for claims in that
SET. Mirrors apply_999_acceptances so SET-level rejections
correctly cascade.
Returns:
Apply999Result with lists of matched claim ids and orphan PCNs.
"""
result = Apply999Result()
now = datetime.now(timezone.utc)
index = batch_envelope_index or {}
for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code
if code not in ("R", "E", "X"):
continue
# SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level
# rejection correctly flips every claim in the SET. Fall back to
# the legacy claim_lookup when the index is empty for this SCN.
candidate_ids = index.get(sr.set_control_number, []) or []
claims_to_reject = []
if candidate_ids:
claims_to_reject = (
session.query(Claim)
.filter(Claim.id.in_(candidate_ids))
.all()
)
else:
legacy = claim_lookup(sr.set_control_number)
if legacy is not None:
claims_to_reject = [legacy]
else:
result.orphans.append(sr.set_control_number)
continue
for claim in claims_to_reject:
if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = _build_reason(
code, len(sr.segment_errors or [])
)
result.matched.append(claim.id)
if result.matched or result.orphans:
session.commit()
return result
```
- [ ] **Step 3: Update the caller in handle_999.py**
OLD (line 88):
```python
rejection_result = apply_999_rejections(
session, result, claim_lookup=_lookup,
)
```
NEW:
```python
rejection_result = apply_999_rejections(
session, result,
claim_lookup=_lookup,
batch_envelope_index=batch_index,
)
```
- [ ] **Step 4: Create the cascade test**
Create: `backend/tests/test_apply_999_rejections.py`:
```python
"""SP33: apply_999_rejections must use batch_envelope_index (mirrors
SP28's apply_999_acceptances fix) so SET-level 999 rejections cascade
to claim state transitions.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from cyclone.db import Claim, ClaimState
from cyclone.inbox_state import apply_999_rejections, Apply999Result
def _mk_set_response(code: str = "R", scn: str = "991102994"):
"""Build a stand-in for ParseResult999.set_responses[0]."""
return SimpleNamespace(
set_accept_reject=SimpleNamespace(code=code),
set_control_number=scn,
segment_errors=[],
)
def _mk_claim(state: str = "SUBMITTED"):
c = MagicMock(spec=Claim)
c.id = "claim-" + state.lower()
c.state = state
return c
def test_set_level_rejection_cascades_via_envelope_index():
session = MagicMock()
session.query.return_value.filter.return_value.all.return_value = [_mk_claim()]
parsed = SimpleNamespace(
set_responses=[_mk_set_response(code="R", scn="991102994")]
)
index = {"991102994": ["claim-submitted"]}
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index=index,
)
assert len(result.matched) == 1
assert result.matched[0] == "claim-submitted"
assert result.orphans == []
session.commit.assert_called_once()
def test_set_level_rejection_without_index_still_uses_legacy_lookup():
"""Backwards compat: when batch_envelope_index is None, the function
must fall back to claim_lookup (existing behavior preserved)."""
session = MagicMock()
parsed = SimpleNamespace(
set_responses=[_mk_set_response(code="R", scn="991102994")]
)
legacy_claim = _mk_claim()
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None,
)
assert result.matched == ["claim-submitted"]
def test_no_envelope_index_no_match_becomes_orphan():
session = MagicMock()
parsed = SimpleNamespace(
set_responses=[_mk_set_response(code="R", scn="991102999")]
)
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={}, # empty index
)
assert result.matched == []
assert "991102999" in result.orphans
def test_accepted_999_does_not_reject():
session = MagicMock()
parsed = SimpleNamespace(
set_responses=[_mk_set_response(code="A", scn="991102994")]
)
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={"991102994": ["claim-x"]},
)
assert result.matched == []
session.commit.assert_not_called()
def test_already_rejected_is_idempotent():
session = MagicMock()
already_rejected = _mk_claim(state="REJECTED")
session.query.return_value.filter.return_value.all.return_value = [already_rejected]
parsed = SimpleNamespace(
set_responses=[_mk_set_response(code="R", scn="991102994")]
)
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={"991102994": ["claim-rejected"]},
)
assert result.matched == []
# nothing was mutated on the already-rejected claim
assert already_rejected.state == "REJECTED"
```
- [ ] **Step 5: Run both test files**
Run:
```bash
docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/pytest tests/test_apply_999_rejections.py tests/test_co_medicaid_payer_id_emits_CO_TXIX.py -v"
```
Expected: 6 passed (1 from Task 1 + 5 from Task 2).
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/inbox_state.py backend/src/cyclone/handlers/handle_999.py backend/tests/test_apply_999_rejections.py
git commit -m "feat(sp33): apply_999_rejections uses batch_envelope_index"
```
## Task 3: CLI subcommand `backfill-999-rejections`
**Files:**
- Modify: `backend/src/cyclone/cli.py` (add subcommand)
- [ ] **Step 1: Locate the cli.py entry point**
Run: `grep -n '@click.command\|def cli\|^@' backend/src/cyclone/cli.py | head -20`
Find an existing subcommand (e.g. `parse-837`) and copy its structure.
- [ ] **Step 2: Add `backfill-999-rejections` subcommand**
Insert after an existing subcommand. The exact insertion point depends on the layout — pick a sensible spot. Use this implementation:
```python
@click.command(name="backfill-999-rejections")
@click.option("--dry-run", is_flag=True, default=False,
help="Print the would-be changes without writing.")
@click.option("--actor", default="sp33-backfill",
help="Audit log actor for the claim.rejected events.")
def backfill_999_rejections(dry_run: bool, actor: str):
"""Replay existing 999 rejections onto already-linked claims.
SP33. Idempotent — claims already in REJECTED state are skipped.
Walks claim_acks JOIN acks WHERE set_accept_reject_code='R',
then sets claims.state='REJECTED' and fills payer_rejected_* fields.
"""
from sqlalchemy import select
from cyclone import db as cycl_db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.models import Claim as ClaimModel, ClaimAck, Ack
cycl_db.init_db()
with cycl_db.SessionLocal()() as session:
rows = session.execute(
select(ClaimAck.claim_id, Claim.ack_code, ClaimAck.set_control_number,
ClaimAck.set_accept_reject_code, ClaimAck.ak2_index)
.join(Ack, Ack.id == ClaimAck.ack_id)
.join(ClaimModel, ClaimModel.id == ClaimAck.claim_id)
.where(ClaimAck.set_accept_reject_code == "R")
).all()
matched = already = errors = 0
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
for (claim_id, ack_code, scn, arc, ak2_idx) in rows:
claim = session.get(ClaimModel, claim_id)
if claim is None:
errors += 1
continue
if claim.state == "REJECTED":
already += 1
continue
if not dry_run:
claim.state = "REJECTED"
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = f"999 AK5={ack_code or 'R'} SCN={scn} ak2={ak2_idx}"
claim.payer_rejected_at = now
claim.payer_rejected_reason = f"999 SET-level reject at SCN={scn}"
claim.payer_rejected_status_code = "R"
append_event(session, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=claim_id,
payload={"source": "backfill-999-rejections", "scn": scn},
actor=actor,
))
matched += 1
if not dry_run:
session.commit()
click.echo(
f"matched={matched} already_rejected={already} errors={errors} dry_run={dry_run}"
)
```
The exact model class names (`Claim`, `ClaimAck`, `Ack`) and module names (`cyclone.models`, `cyclone.db`, `cyclone.audit_log`) may differ slightly — verify against the existing imports at the top of `cli.py` and adjust.
- [ ] **Step 3: Sanity check (dry-run)**
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/python -m cyclone.cli backfill-999-rejections --dry-run"`
Expected: `matched=338 already_rejected=0 errors=0 dry_run=True` (or similar; the exact number depends on how many unique claim_ids have R-coded links).
- [ ] **Step 4: Run for real**
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/python -m cyclone.cli backfill-999-rejections"`
Expected: `matched=338 already_rejected=0 errors=0 dry_run=False`.
- [ ] **Step 5: Verify claim states flipped in the DB**
Run:
```bash
docker exec cyclone-backend-1 python3 -c "
import sqlite3
conn = sqlite3.connect('/var/lib/cyclone/db/cyclone.db')
cur = conn.cursor()
cur.execute('SELECT state, count(*) FROM claims GROUP BY state ORDER BY 2 DESC')
for row in cur.fetchall(): print(row)
"
```
Expected: `('REJECTED', 341)` and `('SUBMITTED', 334)` (338 newly-rejected + the 3 pre-existing rejected = 341; original 672 minus 338 = 334).
- [ ] **Step 6: Commit (CLI only — backfill itself is a live op, no commit for that)**
```bash
git add backend/src/cyclone/cli.py
git commit -m "feat(sp33): cli backfill-999-rejections"
```
## Task 4: CLI subcommand `resubmit-rejected-claims`
**Files:**
- Modify: `backend/src/cyclone/cli.py` (add subcommand)
- [ ] **Step 1: Add `resubmit-rejected-claims` subcommand**
```python
@click.command(name="resubmit-rejected-claims")
@click.option("--payer", default="co_medicaid",
help="Payer factory key (default: co_medicaid).")
@click.option("--ingest-dir", default="/home/tyler/dev/cyclone/ingest",
help="Directory holding batch-*-claims/ subfolders (per Tyler's split).")
@click.option("--actor", default="sp33-resubmit",
help="Audit log actor for the clearhouse.submitted events.")
@click.option("--dry-run", is_flag=True, default=False,
help="Replace + validate but do not upload.")
def resubmit_rejected_claims(payer: str, ingest_dir: str, actor: str, dry_run: bool):
"""Byte-level SKCO0->CO_TXIX fix + SFTP resubmit of rejected claims.
SP33. For each claims row in REJECTED state, locate the matching single-
claim 837 file in ingest_dir/batch-<batch_id>-<N>-claims/*.x12, replace
PI*SKCO0 with PI*CO_TXIX in the bytes, validate via parse_837_text, then
upload via SftpClient. Idempotent per claim (resubmit_count gates re-run).
"""
from pathlib import Path
import glob
from cyclone import db as cycl_db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.clearhouse import SftpClient
from cyclone.parsers.parse_837 import parse_837_text
cycl_db.init_db()
upload_root = "/CO XIX/PROD/coxix_prod_11525703/ToHPE"
# 1. collect rejected claims
with cycl_db.SessionLocal()() as session:
from sqlalchemy import select
from cyclone.models import Claim as ClaimModel
rows = session.execute(
select(ClaimModel.id, ClaimModel.batch_id, ClaimModel.patient_control_number,
ClaimModel.resubmit_count, ClaimModel.charge_amount)
.where(ClaimModel.state == "REJECTED")
.order_by(ClaimModel.batch_id, ClaimModel.id)
).all()
# 2. discover candidate files
files_by_batch = {}
for batch_dir in glob.glob(os.path.join(ingest_dir, "batch-*-*-claims")):
batch_id = os.path.basename(batch_dir).split("-")[1] # batch-<id>-<N>-claims
files_by_batch[batch_id] = sorted(glob.glob(os.path.join(batch_dir, "*.x12")))
ok = err = skip = 0
for (cid, bid, pcn, prev_resub, charge) in rows:
if prev_resub and prev_resub > 0:
skip += 1; continue
candidate_files = files_by_batch.get(bid, [])
# Find the file whose CLM01 == this claim's pcn
target_path = None
for fp in candidate_files:
with open(fp, "rb") as fh:
txt = fh.read().decode("ascii", errors="replace")
if pcn in txt:
target_path = fp
break
if target_path is None:
click.echo(f" no-file claim={cid} pcn={pcn}", err=True)
err += 1
continue
# Byte-level fix
with open(target_path, "rb") as fh:
raw = fh.read()
if b"PI*SKCO0" not in raw:
click.echo(f" no-replace claim={cid} file={os.path.basename(target_path)}", err=True)
err += 1; continue
fixed = raw.replace(b"PI*SKCO0", b"PI*CO_TXIX")
# Validate
try:
parsed = parse_837_text(fixed.decode("ascii"))
except Exception as exc:
click.echo(f" parse-fail claim={cid} {exc.__class__.__name__}: {exc}", err=True)
err += 1; continue
# Upload
remote_name = os.path.basename(target_path)
if not dry_run:
# Write the fixed bytes to a sibling tmp file for the upload
tmp_path = target_path + ".fixed"
with open(tmp_path, "wb") as fh:
fh.write(fixed)
try:
SftpClient().put(tmp_path, f"{upload_root}/{remote_name}")
finally:
os.unlink(tmp_path)
claim = session.get(ClaimModel, cid)
claim.resubmit_count = (claim.resubmit_count or 0) + 1
append_event(session, AuditEvent(
event_type="clearhouse.submitted",
entity_type="claim",
entity_id=cid,
payload={"file": remote_name, "scn": parsed.envelope.control_number},
actor=actor,
))
session.commit()
click.echo(f" ok claim={cid} file={remote_name}")
ok += 1
click.echo(f"\nDONE ok={ok} err={err} skip={skip} dry_run={dry_run}")
```
Imports needed: `import os` (glob, path). Confirm the `SftpClient` constructor — it may take an SFTP block or a config; check existing usage in `backend/src/cyclone/api.py` (the `/api/clearhouse/submit` endpoint) and mirror it.
- [ ] **Step 2: Run with --dry-run first**
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && CYCLONE_INGEST_DIR=/tmp/ingest_passthrough .venv/bin/python -m cyclone.cli resubmit-rejected-claims --dry-run"`
Expected: a line per claim plus a `DONE ok=338 err=0 skip=0 dry_run=True` summary.
- [ ] **Step 3: Run for real (upload)**
Mount /home/tyler/dev/cyclone/ingest into the container so the `--ingest-dir` resolves correctly. Easiest: copy the batch-* subdirs into the container:
```bash
docker exec cyclone-backend-1 mkdir -p /tmp/ingest_split
tar -C /home/tyler/dev/cyclone/ingest -cf - 'batch-*-*-claims' | docker exec -i cyclone-backend-1 tar -xf - -C /tmp/ingest_split
```
Then: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/python -m cyclone.cli resubmit-rejected-claims --ingest-dir /tmp/ingest_split"`
Expected: `DONE ok=338 err=0 skip=0 dry_run=False`.
- [ ] **Step 4: Verify the upload landed**
Connect to SFTP and check the latest 338 files in `/ToHPE/`:
```bash
docker exec cyclone-backend-1 bash -c "
import paramiko, time
ssh = paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('mft.gainwelltechnologies.com', username='colorado-fts\\\\coxix_prod_11525703',
password=\$(cat /run/secrets/cyclone_sftp_password))
sftp = ssh.open_sftp()
files = sorted(sftp.listdir_attr('/CO XIX/PROD/coxix_prod_11525703/ToHPE'),
key=lambda a: a.st_mtime)[-10:]
for f in files: print(f.filename, time.ctime(f.st_mtime))
"
```
Expected: ~10 837P files dated 2026-07-02 (or now).
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/cli.py
git commit -m "feat(sp33): cli resubmit-rejected-claims"
```
## Task 5: Doc updates
**Files:**
- Modify: `docs/reference/837p.md:48`
- Modify: `docs/reference/co-medicaid.md:118`
- [ ] **Step 1: Update `docs/reference/837p.md` line 48**
OLD line:
```
- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40`
```
NEW line:
```
- Trading partners: `COMEDASSISTPROG` (NM1*40 NM109, 1000B receiver) and `CO_TXIX` (NM1*PR NM109, 2010BB payer). See `docs/goodclaim.x12` for a canonical example and the HCPF 837P Companion Guide for the full segment table.
```
- [ ] **Step 2: Update `docs/reference/co-medicaid.md` line 118**
OLD:
```
- `NM1*PR N104 = "SKCO0"` (COHCPF)
```
NEW:
```
- `NM1*PR NM108 = "PI"`, `NM1*PR NM109 = "CO_TXIX"` (per HCPF 837P Companion Guide, June 2025 — Version 2.5). dzinesco submits against this code under dzinesco TPID `11525703`. The legacy trading-partner ID `SKCO0` is no longer accepted as the payer identifier.
```
- [ ] **Step 3: Commit**
```bash
git add docs/reference/837p.md docs/reference/co-medicaid.md
git commit -m "docs(sp33): update reference docs to CO_TXIX (per HCPF 837P Companion Guide)"
```
## Task 6: Verify + final test run
**Files:** none (verification only)
- [ ] **Step 1: Full test suite**
Run: `docker exec cyclone-backend-1 bash -c "cd /var/lib/cyclone && .venv/bin/pytest -x -q"`
Expected: all tests pass. The 4 affected test files (this plan + Spec §6 list) all green.
- [ ] **Step 2: Smoke-test the live DB dashboard widget**
Run:
```bash
curl -fsS http://localhost:8000/api/batches | python3 -m json.tool | head -30
```
Expected: the dashboard reflects the freshly-REJECTED batches (acceptedCount=0 for the 4 affected batches — this is now correct! They were rejected by Gainwell, and the cascade now shows that).
- [ ] **Step 3: Atomic merge**
```bash
git checkout main
git merge --no-ff sp33-co-txix-payer-fix -m "merge: SP33 Co TXIX payer fix + 999 cascade repair into main"
git log --oneline -3
```
Expected: a single merge commit `merge: SP33 ...` on top of `dfd6542` (current main tip pre-SP33), with the SP33 branch's commits visible as parents.
## Acceptance checklist (must all be true before claiming "done")
- [ ] `cyclone -m cyclone.cli backfill-999-rejections` reports `matched=338 already_rejected=0 errors=0`
- [ ] `claims.state` count: REJECTED ≥ 341, SUBMITTED ≤ 334
- [ ] 338 new 837 files visible on SFTP `/CO XIX/PROD/cox17_prod_11525703/ToHPE/` with `CO_TXIX` in their bytes
- [ ] `claims.resubmit_count > 0` for the 338 claims
- [ ] `audit_log` shows 338 `claim.rejected` + 338 `clearhouse.submitted` events with actor=`sp33-*`
- [ ] Dashboard "Recent batches" widget for the 4 affected batches shows the same `acceptedCount` it was showing — but now it's accurate (0 rejected by Gainwell, not 0 accepted by Gainwell)
## Rollback
If anything breaks and SP33 needs to back out:
1. `git revert -m 1 <merge-sha>` on `main` — produces a fresh revert commit, preserves history.
2. The 2 cli subcommands revert cleanly (pure additions).
3. To un-reject the 338 claims after the revert, run a sister SQL: `UPDATE claims SET state='SUBMITTED', rejection_reason=NULL, rejected_at=NULL, payer_rejected_at=NULL, payer_rejected_reason=NULL, payer_rejected_status_code=NULL WHERE resubmit_count > 0;`
4. The 338 837 files already on SFTP /ToHPE/ cannot be unsent — Gainwell will pick them up regardless. Discretion required.
@@ -0,0 +1,185 @@
# 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.
@@ -62,14 +62,22 @@ Effective algorithm unchanged: 2-of-3 `{PCN, charge, NPI}` still fires when any
New subcommand: `python -m cyclone.cli backfill-rendering-npi`.
Behavior:
1. For every `claim_batches.inbound_path`, re-parse the 837p file with the new parser; for every claim in the batch, write `Claim.rendering_provider_npi = parsed_value` **only when the column is currently `NULL`** (idempotent).
2. For every `remittances.inbound_path`, re-parse the 835 file with the new parser; for every remit, write both the per-segment NPIs (`ClaimPayment.service_provider_npi`) and the aggregated `Remittance.rendering_provider_npi`.
3. Run `reconcile.run()` once over all open pairs at the end so the NPI arm can fire retroactively.
4. Log per-batch counts (parsed, updated, skipped).
5. Exit code 0 on success, 2 on file-level failure (no batch updated), 1 on unexpected exception.
**Why explicit file args.** The `Batch` ORM does not retain a column for the original on-disk ingest path — the live ingest pipeline reads the upload body, parses, writes rows, and never persists the bytes. So there is no `claim_batches.inbound_path` / `remittances.inbound_path` to "look up" and re-parse from. The operator must point the CLI at the files (or the directory that holds them) that need backfill. Re-running ingest end-to-end would be the alternative, but it would re-`INSERT` claim rows and is a destructive operation; backfill is intentionally a read-reparse-targeted-`UPDATE`.
Idempotent: re-running on a fully-populated DB is a no-op (typed columns not overwritten; reconcile already matched pairs skipped via `Claim.matched_remittance_id IS NOT NULL`).
Flags:
- `--file PATH` (repeatable) — one or more specific X12 files to re-parse. Each path is validated to exist before parsing.
- `--input-dir DIR` — directory to scan one level deep for `*.txt` / `*.edi` / `*.x12` files. Falls back to `$CYCLONE_BACKFILL_INPUT_DIR` if neither `--file` nor `--input-dir` is passed.
- `--type {837p,835}` (optional) — pin the parser. **When omitted, each file's transaction kind is auto-sniffed** (filename hint + ISA/ST prefix) so a mixed directory can be processed in one pass.
Behavior:
1. For each file resolved by `--file` / `--input-dir`, re-parse with the new parser (T4 wiring).
2. **837p**: for every claim in the batch, write `Claim.rendering_provider_npi = parsed_value` **only when the column is currently `NULL`** (idempotent — already-populated rows are left alone).
3. **835**: for every remit, write `Remittance.rendering_provider_npi` (typed column) **only when `NULL`**, and persist the per-segment `service_provider_npi` values into `Remittance.raw_json["service_provider_npis"]` (D5).
4. After all files are processed, run `reconcile.run()` **once across every 835 Batch** so the D6 NPI arm can fire retroactively on newly-populated pairs.
5. Log per-file counts (parsed, updated, skipped). Emit one-line summary to stdout: `claims_updated=N remits_updated=M files_processed=K files_skipped=J`.
6. Exit code 0 on success (zero populated rows is still exit 0), 2 on file-level failure (no file processed), 1 on unexpected exception.
Idempotent: re-running on a fully-populated DB is a no-op (typed columns not overwritten; reconcile skips already-matched pairs via `Claim.matched_remittance_id IS NOT NULL`).
### D8 — Backfill event emission
@@ -96,7 +104,8 @@ No `UPDATE` statements in the migration — backfill is a separate, deliberate s
- `backend/src/cyclone/db.py``Claim.rendering_provider_npi`, `Remittance.rendering_provider_npi`. No claim_payments ORM — service_provider_npi persists only via `Remittance.raw_json["service_provider_npis"]` (D5).
- `backend/src/cyclone/writer.py` / `writer_835.py` — set the new typed columns and the `raw_json` mirrors at write time. `writer_835.py` writes the per-CLP `service_provider_npi` into `Remittance.rendering_provider_npi` (D4, single value) and into `raw_json["service_provider_npis"]` (D5).
- `backend/src/cyclone/reconcile.py` — extend `_content_keys_match` (D6).
- `backend/src/cyclone/cli.py` — new `backfill-rendering-npi` subcommand.
- `backend/src/cyclone/store/backfill.py`**new helper module** owning `backfill_rendering_provider_npi(files, input_dir, transaction_type)`: file/CLI argument resolution, auto-sniffing (ISA/ST + filename hint), re-parse dispatch, idempotent typed-column writes, and the post-backfill `reconcile.run()` sweep. Returns a small summary dataclass for the CLI summary line.
- `backend/src/cyclone/cli.py` — new `backfill-rendering-npi` subcommand (Click). Wires `--file` / `--input-dir` / `--type` / `--log-level`, delegates the heavy lifting to `store.backfill.backfill_rendering_provider_npi`, and prints the one-line summary.
- `backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql` — D9.
- `backend/tests/fixtures/` — new 837p fixture(s) with `NM1*82` and new 835 fixture(s) with `NM1*1P`.
- `backend/tests/test_reconcile.py` — new tests for D6 (typed-column primary path, typed-column beats raw_json fallback, NPI arm fires when both sides populated).