# 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 \ --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).