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:
@@ -0,0 +1,250 @@
|
||||
# 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.py` — `CliRunner`-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.
|
||||
Reference in New Issue
Block a user