docs(spec): SP24 design + plan for reissue-claims CLI + IG-correctness regression test

Locks in:
- cyclone.reissue subpackage (parse_inputs / emit_outputs /
  zip_outputs / ig_correctness_check — pure functions, no Click).
- @main.command('reissue-claims') thin wrapper in cli.py.
- PATIENT_LOOP_DEFAULT_INCLUDED public constant in serialize_837.py
  (replaces inspect.signature on the private _build_subscriber_block).
- IG-correctness regression test in test_serialize_837.py pinning the
  constant to False.
- deprecation shim at scripts/reissue_claims.py.
- RUNBOOK entry under 'Operator workflows → Reissue claims'.

Workflow coverage: the dzinesco July-8 batches (4 files, 358 claims)
that triggered the 999 '2000C HL must be absent when 2000B SBR02=18'
rejection on 2026-07-08.
This commit is contained in:
Nora
2026-07-08 22:23:53 -06:00
parent ea68ae81f7
commit daf55d9a38
2 changed files with 739 additions and 0 deletions
@@ -0,0 +1,489 @@
# Reissue Claims CLI + IG-Correctness Regression Test Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> superpowers:subagent-driven-development (recommended) or
> superpowers:executing-plans to implement this plan task-by-task. Steps
> use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move `scripts/reissue_claims.py` into a `cyclone.reissue` subpackage, expose it as `cyclone reissue-claims`, and lock in the IG-correct serializer default via a regression test.
**Architecture:** `cyclone/reissue/core.py` (pure functions, no Click) + a thin `@main.command("reissue-claims")` wrapper in `cli.py`. The IG-correctness default is promoted from `inspect.signature` introspection of `_build_subscriber_block` to a public `PATIENT_LOOP_DEFAULT_INCLUDED` constant in `serialize_837.py`, exposed via `__all__` and read by the guard.
**Tech Stack:** Python 3.11, Click 8, pytest, `cyclone.parsers.parse_837` + `cyclone.parsers.serialize_837` + `cyclone.edi.filenames.build_outbound_filename`.
**Spec:** [`docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`](../specs/2026-07-08-cyclone-reissue-claims-design.md)
---
## File structure
```
backend/src/cyclone/
reissue/ ← new subpackage
__init__.py re-exports the public surface
core.py parse_inputs / emit_outputs /
zip_outputs / ig_correctness_check
cli.py ← registers @main.command("reissue-claims")
(~50 LOC, calls into cyclone.reissue.core)
parsers/serialize_837.py ← adds PATIENT_LOOP_DEFAULT_INCLUDED constant
in __all__; _build_subscriber_block reads
from it instead of hardcoding `False`
backend/tests/
test_reissue_core.py ← new (pure-unit)
test_cli_reissue_claims.py ← new (CliRunner smoke)
test_serialize_837.py ← adds test_serialize_837_patient_loop_default_is_false
scripts/
reissue_claims.py ← 1-line deprecation shim; prints WARNING
at import time, delegates to cyclone.reissue.core
docs/
RUNBOOK.md ← adds "Operator workflows → Reissue claims"
section pointing at `cyclone reissue-claims`
/tmp/refactor-cyclone.md ← progress tracker (already created)
```
## Task 0: branch + worktree
- [ ] Branch `sp24-reissue-claims` from `main` (no worktree — the user is
already in the main checkout; the branch is local).
- [ ] Verify `git log --oneline main -1` shows
`ea68ae8 feat(sp41): add CYCLONE_EDIFABRIC_DISABLED=1 bypass hook`.
- [ ] First commit on the branch: `docs(spec): design for reissue-claims CLI
+ IG-correctness regression test (Step 0)` (commits the spec file).
## Task 1: promote `PATIENT_LOOP_DEFAULT_INCLUDED` to a public constant
- [ ] In `backend/src/cyclone/parsers/serialize_837.py`, add the constant
after the imports (around line 65):
```python
#: Default value for ``_build_subscriber_block(include_patient_loop=...)``.
#: Per X12 005010X222A1, the 2000C Patient Hierarchical Level
#: (HL*3 → PAT → NM1*QC) is REQUIRED only when Patient != Subscriber
#: (i.e. SBR02 != "18"). When SBR02 == "18" (Self), the 2000C loop
#: MUST be absent. The serializer's default of False matches the
#: CO-Medicaid IHSS workflow and is the IG-correct shape.
#:
#: The IG-correctness regression test
#: ``test_serialize_837_patient_loop_default_is_false`` pins this
#: value; flipping it requires an explicit PR-level discussion.
PATIENT_LOOP_DEFAULT_INCLUDED: bool = False
```
- [ ] Replace the existing hardcoded `include_patient_loop: bool = False`
default on `_build_subscriber_block(...)` with
`include_patient_loop: bool = PATIENT_LOOP_DEFAULT_INCLUDED`.
- [ ] Add `PATIENT_LOOP_DEFAULT_INCLUDED` to the module's `__all__`
tuple (find the existing `__all__` near the top of the file;
if missing, create one with the public functions plus the
constant).
- [ ] **Live-test:** `cd backend && .venv/bin/pytest tests/test_serialize_837.py -v`
(56 cases should still pass; the default behavior is unchanged).
- [ ] **Autoreview:** no flake, no lint regressions. Run
`cd backend && .venv/bin/python -c "from cyclone.parsers.serialize_837 import PATIENT_LOOP_DEFAULT_INCLUDED; print(PATIENT_LOOP_DEFAULT_INCLUDED)"`
and confirm it prints `False`.
- [ ] Commit: `feat(sp24): expose PATIENT_LOOP_DEFAULT_INCLUDED constant in serialize_837`.
## Task 2: create `cyclone.reissue` subpackage
- [ ] Create `backend/src/cyclone/reissue/__init__.py` with re-exports:
```python
"""cyclone.reissue — offline 837P re-emission workflow.
See :mod:`cyclone.reissue.core` for the public functions.
"""
from cyclone.reissue.core import (
parse_inputs,
emit_outputs,
zip_outputs,
ig_correctness_check,
)
__all__ = [
"parse_inputs",
"emit_outputs",
"zip_outputs",
"ig_correctness_check",
]
```
- [ ] Create `backend/src/cyclone/reissue/core.py` mirroring the script's
logic but as pure functions. The signatures match the script:
```python
def ig_correctness_check(*, logger: logging.Logger | None = None) -> bool:
"""Return True if the serializer's IG-correct patient-loop default is in place."""
...
def parse_inputs(input_dir: Path, payer_config) -> tuple[list[ClaimOutput], list[tuple[Path, str]]]:
"""Parse every *.x12 / *.txt / *.edi under input_dir. Tolerates per-file failures."""
...
def emit_outputs(
claims: list[ClaimOutput],
*,
payer_config,
output_dir: Path,
sender_id: str,
receiver_id: str,
submitter_name: str,
submitter_contact_name: str,
submitter_contact_email: str,
receiver_name: str,
) -> list[Path]:
"""Emit one X12 file per claim under output_dir using HCPF-spec filenames."""
...
def zip_outputs(files: list[Path], zip_path: Path) -> None:
"""Zip the per-claim X12 files into a single flat archive with integrity check."""
...
```
Implementation details:
- `ig_correctness_check` reads
`cyclone.parsers.serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED`;
returns True iff it is False. Logs a WARNING (via the optional
`logger` argument, or `logging.getLogger(__name__)`) if False
is not what the caller expected.
- `parse_inputs` skips AppleDouble (`._*`) files, tolerates
per-file parse errors (returns them as the second tuple
element), and skips claims with hard validation errors.
- `emit_outputs` uses `datetime.now(tz=ZoneInfo("America/Denver"))`
for the base timestamp + `timedelta(milliseconds=i)` for
per-claim uniqueness. Sorts claims by `claim_id`. Writes
`_serialize_summary.json` next to the files (caller does this).
- `zip_outputs` uses `zipfile.ZIP_DEFLATED` and runs
`ZipFile.testzip()` after writing; raises `RuntimeError` if
any entry is corrupt.
- [ ] **Live-test:**
`cd backend && .venv/bin/python -c "from cyclone.reissue import parse_inputs, emit_outputs, zip_outputs, ig_correctness_check; print('ok')"`
should print `ok`.
- [ ] Commit: `feat(sp24): add cyclone.reissue subpackage with pure core functions`.
## Task 3: write `test_reissue_core.py` (pure-unit)
- [ ] Create `backend/tests/test_reissue_core.py` with:
- `test_ig_correctness_check_returns_true_when_default_is_false`
(default state — guard silent).
- `test_ig_correctness_check_returns_false_when_default_flipped`
(monkeypatches `PATIENT_LOOP_DEFAULT_INCLUDED = True` via
`monkeypatch.setattr`; assert False).
- `test_parse_inputs_skips_apple_double_metadata`
(creates `tmp_path/in/foo.x12` and `tmp_path/in/._foo.x12`;
asserts only foo.x12 is parsed).
- `test_parse_inputs_tolerates_per_file_failures`
(creates one valid + one malformed file; asserts the malformed
one is in the errors list and the valid one is in claims).
- `test_emit_outputs_writes_hcpf_spec_filenames`
(parses a minimal 837P, emits to `tmp_path/out`, asserts the
filenames match `tp*-837P-*-1of1.x12`).
- `test_emit_outputs_sorts_claims_by_claim_id`
(two-claim fixture, asserts the filename suffixes are sorted
by claim_id).
- `test_zip_outputs_round_trip_integrity`
(writes a tiny zip, asserts `ZipFile.testzip()` returns None).
- [ ] **Live-test:** `cd backend && .venv/bin/pytest tests/test_reissue_core.py -v`
(all new cases should pass).
- [ ] **Autoreview:** confirm no test relies on network, no test
reaches into `docs/prodfiles/`, no test uses wall-clock sleep.
- [ ] Commit: `test(sp24): pure-unit tests for cyclone.reissue.core`.
## Task 4: register `@main.command("reissue-claims")` in cli.py
- [ ] Append the CLI subcommand to `backend/src/cyclone/cli.py` after
the existing `rebill-from-835` block. The Click signature follows
§2 Decision 3 of the spec:
```python
@main.command("reissue-claims")
@click.option("--input-dir", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True)
@click.option("--output-root", type=click.Path(file_okay=False, path_type=Path),
default=Path("dev/rebills"), show_default=True)
@click.option("--date", default=None, help="YYYY-MM-DD subfolder under output-root (default: today MT).")
@click.option("--pipeline", default="initial", show_default=True)
@click.option("--payer", default="co_medicaid", show_default=True)
@click.option("--sender-id", default="11525703", show_default=True)
@click.option("--receiver-id", default="COMEDASSISTPROG", show_default=True)
@click.option("--submitter-name", default="Dzinesco", show_default=True)
@click.option("--submitter-contact-name", default="Tyler Martinez", show_default=True)
@click.option("--submitter-contact-email", default="tyler@dzinesco.com", show_default=True)
@click.option("--receiver-name", default="COLORADO MEDICAL ASSISTANCE PROGRAM", show_default=True)
@click.option("--zip-output", type=click.Path(file_okay=False, path_type=Path), default=None)
@click.option("--no-clean", is_flag=True, default=False,
help="Don't wipe prior .x12 outputs in the pipeline subdir before writing.")
@click.option("--log-level", default="INFO", show_default=True,
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def reissue_claims(
input_dir: Path,
output_root: Path,
date: str | None,
pipeline: str,
payer: str,
sender_id: str,
receiver_id: str,
submitter_name: str,
submitter_contact_name: str,
submitter_contact_email: str,
receiver_name: str,
zip_output: Path | None,
no_clean: bool,
log_level: str,
) -> None:
"""Re-parse raw 837P files and emit one IG-correct single-claim X12 per claim (SP24).
Workflow:
1. Parse every *.x12 / *.txt / *.edi under --input-dir.
2. Validate each parsed claim; skip hard-error claims.
3. Serialize each surviving claim via the IG-correct serializer.
4. Write one X12 file per claim under
``--output-root/--date/--pipeline/`` using HCPF-spec filenames.
5. Optionally zip the output to --zip-output.
Exit codes: 0 success; 1 IG-correctness guard tripped or unexpected
error; 2 parse / validation failure on a file (zero claims survived).
"""
setup_logging(level=log_level)
import logging as _logging
from cyclone.reissue import (
parse_inputs, emit_outputs, zip_outputs, ig_correctness_check,
)
from cyclone.parsers.payer import PayerConfig
log = _logging.getLogger("reissue-claims")
if not ig_correctness_check(logger=log):
click.echo("REFUSING to run: PATIENT_LOOP_DEFAULT_INCLUDED is True; "
"the 2000C patient loop is forbidden when SBR02='18'. "
"Restore the IG-correct default (False) in serialize_837.py.",
err=True)
sys.exit(1)
payer_factories = {"co_medicaid": PayerConfig.co_medicaid}
if payer not in payer_factories:
raise click.BadParameter(f"Unknown payer {payer!r}. Choose from: {', '.join(payer_factories)}")
payer_config = payer_factories[payer]()
date_label = date or datetime.now().strftime("%Y-%m-%d")
out_dir = output_root / date_label / pipeline
if out_dir.exists() and not no_clean:
for p in out_dir.glob("tp*.x12"):
p.unlink()
log.info("cleaned prior .x12 outputs under %s", out_dir)
log.info("parse: input_dir=%s payer=%s", input_dir, payer)
claims, parse_errors = parse_inputs(input_dir, payer_config)
log.info("parse: %d claims read, %d file-level errors",
len(claims), len(parse_errors))
for src, err in parse_errors[:10]:
log.warning(" %s: %s", src, err)
if parse_errors and not claims:
click.echo(f"PARSE FAILED: zero claims survived; first error: {parse_errors[0][1]}", err=True)
sys.exit(2)
log.info("serialize: output_dir=%s", out_dir)
written = emit_outputs(
claims,
payer_config=payer_config,
output_dir=out_dir,
sender_id=sender_id,
receiver_id=receiver_id,
submitter_name=submitter_name,
submitter_contact_name=submitter_contact_name,
submitter_contact_email=submitter_contact_email,
receiver_name=receiver_name,
)
import json as _json
summary_path = out_dir / "_serialize_summary.json"
summary = [
{"claim_id": c.claim_id, "output_file": str(p), "byte_size": p.stat().st_size}
for c, p in zip(sorted(claims, key=lambda c: c.claim_id), written)
]
summary_path.write_text(_json.dumps(summary, indent=2), encoding="utf-8")
log.info("serialize: %d files, %d bytes, summary=%s",
len(written), sum(p.stat().st_size for p in written), summary_path)
if zip_output:
zip_outputs(written, zip_output)
log.info("zip: %s (%d bytes)", zip_output, zip_output.stat().st_size)
click.echo(f"DONE files={len(written)} errors={len(parse_errors)} out={out_dir}")
if parse_errors:
sys.exit(2)
```
- [ ] **Live-test:**
`cd backend && .venv/bin/python -m cyclone.cli reissue-claims --help`
should print the help with the long-form flags listed in §2 Decision 3.
- [ ] **Live-test end-to-end:**
`cd backend && .venv/bin/python -m cyclone.cli reissue-claims \
--input-dir /home/tyler/dev/cyclone/july8billing \
--date 2026-07-08 \
--output-root /tmp/sp24-smoke \
--zip-output /tmp/sp24-smoke.zip`
should produce `/tmp/sp24-smoke/2026-07-08/initial/` with 358 files
and `/tmp/sp24-smoke.zip` with 358 entries.
- [ ] **Autoreview:** confirm the help text is readable, exit codes
behave per the spec, and no click.UsageError leaks a
CycloneParseError stack trace.
- [ ] Commit: `feat(sp24): register cyclone reissue-claims CLI subcommand`.
## Task 5: write `test_cli_reissue_claims.py` (CliRunner smoke)
- [ ] Create `backend/tests/test_cli_reissue_claims.py` with:
- `test_reissue_claims_help_renders` — `CliRunner().invoke(main,
["reissue-claims", "--help"])` exits 0 and contains
`--input-dir`, `--output-root`, `--zip-output`,
`--no-clean`.
- `test_reissue_claims_happy_path` — writes a minimal 837P fixture
to `tmp_path/in`, runs the CLI with `--input-dir tmp_path/in
--output-root tmp_path/out --date 2026-07-08`, asserts exit 0
and 1+ .x12 file in `tmp_path/out/2026-07-08/initial/`.
- `test_reissue_claims_empty_input_exits_2` — empty input dir,
asserts exit 2 with "no claims" / "PARSE FAILED" in stderr.
- `test_reissue_claims_ig_correctness_guard_fires` —
`monkeypatch.setattr("cyclone.parsers.serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED",
True, raising=False)`, asserts exit 1 with `REFUSING to run`
in captured output. Use `monkeypatch.undo()` via a
`with`-block or `addfinalizer` so other tests are unaffected.
- `test_reissue_claims_zip_output_round_trip` — happy path + zip,
asserts the zip file exists and `ZipFile(path).testzip() is None`.
- [ ] **Live-test:**
`cd backend && .venv/bin/pytest tests/test_cli_reissue_claims.py -v`
(all new cases should pass).
- [ ] Commit: `test(sp24): CliRunner smoke tests for reissue-claims`.
## Task 6: write the IG-correctness regression test in `test_serialize_837.py`
- [ ] Add a new test to `backend/tests/test_serialize_837.py`:
```python
def test_serialize_837_patient_loop_default_is_false():
"""SP24: the IG-correct default for include_patient_loop is False.
Per X12 005010X222A1, the 2000C Patient Hierarchical Level
(HL*3 → PAT → NM1*QC) is REQUIRED only when Patient != Subscriber.
For SBR02 == "18" (Self-pay, the CO Medicaid IHSS workflow),
the 2000C loop MUST be absent. Flipping this default back to
True reintroduces the Edifabric 999 "2000C HL must be absent
when 2000B SBR02 = '18'" rejection — see the 2026-07-08 audit.
"""
from cyclone.parsers.serialize_837 import (
PATIENT_LOOP_DEFAULT_INCLUDED,
_build_subscriber_block,
)
from cyclone.parsers.models import Subscriber
assert PATIENT_LOOP_DEFAULT_INCLUDED is False, (
f"PATIENT_LOOP_DEFAULT_INCLUDED must be False (got {PATIENT_LOOP_DEFAULT_INCLUDED!r}); "
"the IG-correct serializer shape for SBR02='18' claims is 2000C-absent."
)
# Construct a minimal subscriber + payer fixture and call
# _build_subscriber_block without the kwarg; verify no HL*3
# segment appears in the output.
from datetime import date
sub = Subscriber(
member_id="TEST123",
first_name="Test", last_name="Patient",
dob=date(1990, 1, 1), gender="F",
)
out = _build_subscriber_block(sub, "MC")
assert not any(seg.startswith("HL*3") for seg in out), (
f"HL*3 emitted with the IG-correct default; segments={out}"
)
```
- [ ] **Live-test:** `cd backend && .venv/bin/pytest
tests/test_serialize_837.py::test_serialize_837_patient_loop_default_is_false -v`
passes.
- [ ] **Autoreview:** try flipping the constant to `True` locally,
re-run the test, confirm it fails with the assertion message;
restore the constant.
- [ ] Commit: `test(sp24): regression test pinning PATIENT_LOOP_DEFAULT_INCLUDED to False`.
## Task 7: deprecation shim for `scripts/reissue_claims.py`
- [ ] Replace `scripts/reissue_claims.py` with a 1-line shim:
```python
"""DEPRECATED shim — use `cyclone reissue-claims` instead.
See `docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`
for the canonical entry point.
"""
import warnings
warnings.warn(
"`scripts/reissue_claims.py` is deprecated; use "
"`cyclone reissue-claims` (the Click subcommand in cli.py) "
"for the canonical offline reissue workflow. This shim will be "
"removed in a follow-up increment.",
DeprecationWarning,
stacklevel=2,
)
from cyclone.cli import main
import sys
if __name__ == "__main__":
# Strip the script argv prefix so `python scripts/reissue_claims.py --help`
# behaves like `cyclone reissue-claims --help`. The Click group
# expects the subcommand as argv[1].
sys.exit(main())
```
- [ ] **Live-test:**
`python scripts/reissue_claims.py --help` prints the canonical
Click help for the `cyclone` group (and emits a DeprecationWarning).
- [ [ ] Commit: `refactor(sp24): deprecation shim for scripts/reissue_claims.py`.
## Task 8: `docs/RUNBOOK.md` operator-workflow entry
- [ ] Add a section under "Operator workflows" pointing at the new
CLI subcommand with a worked example:
```markdown
### Reissue claims (SP24)
The canonical workflow to rebuild a batch of 837P files into
single-claim, IG-correct X12 files:
```bash
cd backend && .venv/bin/python -m cyclone.cli reissue-claims \
--input-dir <path-to-raw-837ps> \
--date 2026-07-08 \
--output-root dev/rebills \
--zip-output xxxclaims.zip
```
Exit codes: 0 = success; 1 = IG-correctness guard tripped or
unexpected error; 2 = parse / validation failure on a file.
```
- [ ] Commit: `docs(sp24): RUNBOOK entry for cyclone reissue-claims`.
## Task 9: autoreview (the merge gate)
- [ ] `cd backend && .venv/bin/pytest -q` — full backend suite passes.
- [ ] `cd backend && .venv/bin/pytest tests/test_serialize_837.py tests/test_reissue_core.py tests/test_cli_reissue_claims.py -v`
— all new + adjacent tests pass.
- [ ] `cd backend && .venv/bin/python -m cyclone.cli reissue-claims --help`
— CLI help renders cleanly.
- [ ] End-to-end live-test against `/home/tyler/dev/cyclone/july8billing/`
produces 358 files + a valid zip (per acceptance criterion #2 + #3).
- [ ] No `git status --short` shows any unexpected untracked files.
- [ ] No regressions in the existing `rebill-from-835` or
`submit-batch` CLIs (they don't import from `cyclone.reissue`).
## Task 10: merge to `main`
- [ ] `git checkout main && git merge --no-ff --no-squash --no-rebase
sp24-reissue-claims -m "merge: SP24 reissue-claims CLI + IG-correctness
regression test into main"`
- [ ] `git log --oneline main -1` shows the merge commit with the
expected subject.
- [ ] `git push origin main` only after the operator has reviewed
and approved the merge (per cyclone-spec — the merge commit is
the audit trail; the operator must approve).