Files
cyclone/docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md
Nora daf55d9a38 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.
2026-07-08 22:23:53 -06:00

13 KiB

Sub-project 24 — Reissue Claims CLI + IG-Correctness Regression Test: Design Spec

Date: 2026-07-08 Status: Draft, awaiting user sign-off Branch: sp24-reissue-claims Aesthetic direction: No new UI (operator-only CLI; the workflow is the on-call page already documented in docs/RUNBOOK.md).

1. Scope

On 2026-07-08 the operator rebuilt the four dzinesco July-8 837P batches into 358 single-claim, IG-correct X12 files (zipped at /home/tyler/dev/cyclone/xxxclaims.zip) using a one-shot script at scripts/reissue_claims.py. The script solved an immediate crisis: an Edifabric 999 had rejected the original four multi-claim files because the serializer was unconditionally emitting the Loop 2000C patient hierarchy (HL*3 → PAT*01 → NM1*QC) when SBR02 == "18" (Self-pay), which is forbidden by X12 005010X222A1.

The serializer default was fixed in serialize_837.py (see commit ee1a397 on sp41-inwindow-rebill-pipeline), but the workflow that proved the fix — parse + per-claim re-serialize + HCPF-spec filename + zip — still lives in a script the rest of the cyclone pipeline doesn't know about. SP24 brings that workflow into the canonical CLI surface as cyclone reissue-claims, behind a regression test that locks in the IG-correct serializer default forever so a future code change cannot silently reintroduce the same 999 rejection.

In scope:

  • A new subpackage backend/src/cyclone/reissue/ containing the pure functions parse_inputs, emit_outputs, zip_outputs, and ig_correctness_check. No Click imports inside the core module — it accepts plain Python types so the CLI is a thin wrapper and the functions are unit-testable without click.testing.CliRunner.
  • A new CLI subcommand cyclone reissue-claims registered against the existing @main Click group in backend/src/cyclone/cli.py, following the conventions in cyclone-cli (long-form flags, exit codes 0/1/2, click.echo(..., err=True) for errors).
  • A new module-level public constant in serialize_837.py: PATIENT_LOOP_DEFAULT_INCLUDED = False, exported in the module's __all__. The default is read by the IG-correctness guard instead of inspect.signature-ing a private helper, so a future rename of _build_subscriber_block cannot silently break the guard.
  • A regression test test_serialize_837_patient_loop_default_is_false in backend/tests/test_serialize_837.py that asserts the constant is False and that calling _build_subscriber_block with no include_patient_loop kwarg emits HL*2 child_count=0 (no HL*3).
  • Smoke tests under backend/tests/test_cli_reissue_claims.py covering the help screen, an empty-input path, a happy-path re-issue, and the IG-correctness guard firing when the constant is monkeypatched.
  • Pure-unit tests under backend/tests/test_reissue_core.py covering parse_inputs (AppleDouble filter, per-file failure tolerance), emit_outputs (HCPF-spec filenames, unique-per-millisecond, sorted by claim_id), zip_outputs (round-trip integrity check), and ig_correctness_check (fires on mismatch, silent on match).
  • A deprecation shim at scripts/reissue_claims.py that re-exports the new CLI subcommand as a thin wrapper with a WARNING printed at import time telling the operator cyclone reissue-claims is the canonical entry. The shim is removed in a follow-up increment.
  • A docs/RUNBOOK.md entry under "Operator workflows" pointing at the new CLI subcommand with a worked example against the dzinesco July-8 billing layout.

Out of scope:

  • New UI exposure for the reissue workflow. The CLI is the operator's on-call surface today; a dashboard widget is a future increment.
  • A bulk-ingest pipeline for the reissue path. The operator's flow is point-in-time ("rebuild yesterday's 358 files from this batch"), not a streaming one. cyclone bulk-ingest already handles the streaming case for inbound files; the outbound reissue path stays explicit.
  • The SFTP-upload step. reissue-claims writes files to a local directory (and optionally zips them). The operator moves the resulting zip to Gainwell via their SFTP client per the "Manual SFTP mode" posture documented in docs/RUNBOOK.md.
  • An automatic regeneration of historical files. The four dzinesco July-8 batches are the immediate operator concern; running reissue-claims against older batches is a deliberate operator action with the explicit --input-dir path.
  • Edifabric validation. The original script did not gate through Edifabric (the July-8 files were validated by the operator's manual 999 audit). A follow-up SP wires the SP41 SP40 Edifabric gate into the reissue path; this increment keeps the surface minimal.

2. Decisions (locked during brainstorming)

  1. Subpackage layout, not a single cli.py block. The other cross-cutting workflows (cyclone.rebill, cyclone.submission, cyclone.bulk_ingest) follow the cyclone/<verb>/ layout. Putting the reissue logic inside cli.py directly — like rebill-from-835 does — would make cli.py grow past 2,200 LOC (already at 2,054) and force the unit tests to live in the CLI-flavored test_cli_*.py file. The subpackage layout keeps the pure functions unit-testable without Click, matches the rest of the codebase, and lets cli.py stay a thin registration surface.

  2. Public PATIENT_LOOP_DEFAULT_INCLUDED constant, not inspect.signature introspection. The original script introspected the private _build_subscriber_block helper via inspect.signature, which is fragile against rename / refactor. Promoting the default to a named public constant in serialize_837.py and exporting it in __all__ gives the guard a stable handle that survives any future refactor of the helper.

  3. cyclone reissue-claims long-form CLI flags. --input-dir, --output-root, --date, --pipeline, --payer, --sender-id, --receiver-id, --submitter-name, --submitter-contact-name, --submitter-contact-email, --receiver-name, --zip-output, --no-clean, --log-level. Long-form, not positional, so the destructive surface is self-documenting. Defaults match the dzinesco canonical sender (11525703) / receiver (COMEDASSISTPROG) so the typical invocation is just cyclone reissue-claims --input-dir <batch>.

  4. IG-correctness guard fires on every CLI invocation. Not gated behind a --strict flag — the constant is part of the canonical contract the spec requires. The guard's only job is to fail loud if the constant flips to True in a future refactor; running it on every call is the cheapest possible regression test.

  5. Exit codes 0 / 1 / 2 mirror the existing CLI conventions. 0 = success (all files written, zip optional); 1 = IG-correctness guard tripped OR a configuration / unexpected error; 2 = parse / validation failure on a file (per-file failures reported in stdout, but other files continue; only exits 2 if zero claims survived).

  6. Deprecation shim, not deletion. The original scripts/reissue_claims.py stays as a 1-line shim that prints a WARNING at import time and re-exports the new module's main() function. The shim is removed in a follow-up increment once the operator's muscle memory has switched over.

  7. The CLI subcommand does NOT call cyclone.submission.submit_file. Reissue is an offline-rebuild workflow; it produces local X12 files for the operator to inspect / upload manually. Wiring the SFTP path would conflate "regenerate clean files" with "submit them to Gainwell", and the operator wants the inspection step in between.

  8. HCPF-spec filename unchanged. The canonical cyclone.edi.filenames.build_outbound_filename(sender_id, "837P") produces the file names already in use (tp11525703-837P-...-1of1.x12); no change to the filename format. Per-claim 1 ms timestamp offsets (via datetime.now(tz=ZoneInfo("America/Denver")) + per-claim timedelta(milliseconds=i)) guarantee uniqueness within a batch.

3. Threat model

This increment does not change the auth boundary or the network posture. The threat model is unchanged from SP24 (this spec): the auth boundary is HTTP (login required, bcrypt + HttpOnly session cookie); the file-system threat is local-only and handled by SQLCipher at rest and the macOS Keychain. The CLI subcommand runs against the operator's local environment only — there is no HTTP surface, no auth gate, no network call. The IG-correctness guard is a static check against the serializer's documented default; it does not change the security posture.

The regression test in test_serialize_837.py is structural: it pins a constant to a specific value. If a future refactor flips the constant back to True (the broken pattern), the test fails, the CLI guard fires, and the operator sees a clear REFUSING to run log line before any X12 byte is emitted.

4. Test impact

Following the cyclone-tests conventions:

  • Unit tests (pure-unit):
    • backend/tests/test_reissue_core.py — covers parse_inputs (AppleDouble metadata filter, per-file failure tolerance, claim validation error path), emit_outputs (HCPF-spec filename format, sorted by claim_id, per-claim unique timestamps, directory creation), zip_outputs (round-trip integrity via testzip()), and ig_correctness_check (fires on mismatch, silent on match). Each test uses tmp_path + a fixture *.x12 file containing a minimal valid 837P — no prodfiles dropped into fixtures/.
    • backend/tests/test_serialize_837.py — adds test_serialize_837_patient_loop_default_is_false asserting that serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED is False and that calling _build_subscriber_block with no kwargs emits HL*2 with child_count = 0 (no HL*3). This test is the long-term regression guard; flipping it should require an explicit PR-level discussion.
  • Integration tests (CLI smoke):
    • backend/tests/test_cli_reissue_claims.pyCliRunner-based smoke tests for cyclone reissue-claims. Covers:
      • --help exits 0 with the expected long-form flags listed.
      • happy path: a single-file input dir produces the expected number of output files under tmp_path/out.
      • empty input dir: exits 2 with a clear "no claims found" message.
      • IG-correctness guard: monkeypatches serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED to True, asserts the CLI exits 1 with the REFUSING to run log line in the captured stderr.
  • Fixture additions: none. The CLI smoke test synthesizes a minimal 837P file inline via parse_837 round-tripping a stub ClaimOutput. No prodfiles are dropped into fixtures/.
  • Frontend tests: none (no UI in this increment).
  • Live-test smoke: a canonical end-to-end run against /home/tyler/dev/cyclone/july8billing/ to confirm the CLI produces the same 358 files + zip as the original script. The smoke command is documented at the top of /tmp/refactor-cyclone.md.

5. Spec & plan anchors

  • Spec anchor (this file): docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md
  • Plan anchor: docs/superpowers/plans/2026-07-08-cyclone-reissue-claims.md
  • Related serializer fix: commit ee1a397 on sp41-inwindow-rebill-pipeline (already merged to main), which flipped the _build_subscriber_block.include_patient_loop default from True to False and updated the docstring.
  • Original operator workflow: scripts/reissue_claims.py (the one-shot script this increment supersedes).
  • Edifabric 999 that surfaced the bug: /tmp/ig_check.log (the operator's 999 audit reference for the IG rule).

6. Acceptance criteria

  1. cyclone reissue-claims --help exits 0 and lists the long-form flags listed in §2 Decision 3.
  2. cyclone reissue-claims --input-dir /home/tyler/dev/cyclone/july8billing --date 2026-07-08 --output-root /tmp/sp24-acceptance-out produces exactly 358 .x12 files under /tmp/sp24-acceptance-out/2026-07-08/initial/, plus a _serialize_summary.json sidecar with one row per file.
  3. cyclone reissue-claims --input-dir ... --zip-output /tmp/sp24-acceptance.zip produces a zip whose testzip() returns None (integrity OK) and which contains exactly 358 entries.
  4. The IG-correctness guard exits 1 with the REFUSING to run log line when the constant is monkeypatched to True. Verified by the test_cli_reissue_claims.py smoke test.
  5. pytest backend/tests/test_reissue_core.py -v exits 0 with all cases passing.
  6. pytest backend/tests/test_cli_reissue_claims.py -v exits 0 with all cases passing.
  7. pytest backend/tests/test_serialize_837.py -v exits 0 with the new test_serialize_837_patient_loop_default_is_false case passing alongside the existing 56 cases.
  8. pytest (full backend suite) exits 0; no regressions.
  9. The CLI runs without requiring a Keychain entry or DB connection — reissue-claims is a pure parser + serializer + filesystem workflow. This matches the original script's posture.
  10. After the merge to main, scripts/reissue_claims.py is a 1-line shim that prints a WARNING at import time telling the operator cyclone reissue-claims is the canonical entry. The shim does NOT duplicate the argparse surface.