Files
cyclone/docs/RUNBOOK.md
T
Nora a8aef7b9b2 docs(sp24): RUNBOOK entry for cyclone reissue-claims
Adds a 'Reissue claims (SP24)' section under the Manual SFTP mode
heading, pointing the operator at the new CLI subcommand with a
worked example against the dzinesco July-8 billing layout.

Covers:
  - the canonical command (full flag set, full output path)
  - the 5-step workflow (parse / validate / serialize / write / zip)
  - the exit-code contract (0/1/2)
  - the IG-correctness guard's purpose + the regression story
  - the 'no SFTP' boundary so the operator doesn't expect this
    to push files to Gainwell (use submit-batch for that)
2026-07-08 22:37:38 -06:00

565 lines
23 KiB
Markdown

# Cyclone Operator Runbook
Procedures for running Cyclone in production. The end-user README
covers dev setup; this doc is for an operator bringing up SFTP polling
on a fresh host.
## SP25 — Enable SFTP Polling for Real Gainwell MFT
The inbound MFT polling scheduler ships in SP16 and is wired to real
paramiko SFTP in SP13. To turn it on for `mft.gainwelltechnologies.com`:
### Prerequisites
- Python 3.11+ with the `cyclone` package installed (`pip install -e .[dev,sftp]`).
- The macOS `keyring` library is optional. On a Linux server or Docker
container, the MFT password is supplied via a plain env var (no
Keychain dependency).
- Outbound TCP/22 to `mft.gainwelltechnologies.com`.
### Env vars
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `CYCLONE_SFTP_PASSWORD` | Yes (real MFT only) | unset | The MFT password. Stripped of whitespace; empty values are treated as unset. |
| `CYCLONE_SFTP_PASSWORD_FILE` | No | unset | Path to a file containing the MFT password. Highest-priority lookup in `secrets.get_secret()`. Standard Docker-secrets pattern. See "Docker secrets variant" below. |
| `CYCLONE_SCHEDULER_AUTOSTART` | No | unset (falsy) | When `1`/`true`/`yes`, the scheduler starts polling on API launch. |
| `CYCLONE_SCHEDULER_POLL_SECONDS` | No | `60` | Seconds between poll cycles. The Gainwell MFT server doesn't push — we pull. |
### First-time setup
1. **Set the password env var.** On the server that runs Cyclone:
```bash
export CYCLONE_SFTP_PASSWORD='the-actual-password'
```
For systemd / Docker, persist this in the service file or
`docker-compose.yml` (use a `_FILE` companion or a `secrets:`
block; see your platform's docs).
2. **Confirm the clearhouse SFTP block is configured.** `GET /api/clearhouse` should return a row. If not, run the lifespan once to seed it (any API launch will do).
3. **Flip stub → false and point at real MFT.** Authenticated as an admin:
```bash
# GET the current row, modify the sftp_block, PATCH it back.
# The endpoint requires a full Clearhouse body, so always
# round-trip through GET to get the right updated_at + filename_block.
curl -s http://127.0.0.1:8000/api/clearhouse -b cookies.txt > /tmp/ch.json
# Edit /tmp/ch.json — set sftp_block.stub to false and adjust host/port/paths/auth.
# The minimum change for real-MFT mode is:
# jq '.sftp_block.stub = false
# | .sftp_block.host = "mft.gainwelltechnologies.com"
# | .sftp_block.auth = {"password_keychain_account": "sftp.gainwell.password"}' \
# /tmp/ch.json > /tmp/ch-patched.json
curl -X PATCH http://127.0.0.1:8000/api/clearhouse \
-H 'Content-Type: application/json' \
-b cookies.txt \
--data @/tmp/ch-patched.json
```
The endpoint hot-reloads the scheduler. No API restart required.
4. **Start polling.** Either:
- Set `CYCLONE_SCHEDULER_AUTOSTART=true` and restart the API.
- Or trigger manually:
```bash
curl -X POST http://127.0.0.1:8000/api/admin/scheduler/start -b cookies.txt
```
### Verification
```bash
# Is the loop running?
curl http://127.0.0.1:8000/api/admin/scheduler/status -b cookies.txt
# Force one poll cycle right now:
curl -X POST http://127.0.0.1:8000/api/admin/scheduler/tick -b cookies.txt
# What files has the scheduler seen?
curl 'http://127.0.0.1:8000/api/admin/scheduler/processed-files?limit=20' -b cookies.txt
# Only the errors?
curl 'http://127.0.0.1:8000/api/admin/scheduler/processed-files?status=error' -b cookies.txt
```
A successful first poll shows rows in `processed-files` with
`status=ok` and a non-zero `claim_count` for 835/277CA files.
### Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `status=error` rows with `AuthenticationException` | Wrong password in `CYCLONE_SFTP_PASSWORD` | Re-export with the correct value, then PATCH /api/clearhouse or restart. |
| `status=error` rows with `IOError: [Errno 111] Connection refused` | Outbound TCP/22 blocked, or wrong host/port | Check the firewall; confirm `host` in `GET /api/clearhouse`. |
| Zero rows in `processed-files` after a tick | Inbound MFT dir is empty (HPE hasn't pushed yet) — not an error | Wait. Trigger `tick` again later. |
| `RuntimeError: SFTP: Keychain entry ... missing or stub` | `get_secret()` fell through to Keychain (Linux + missing env var) | Set `CYCLONE_SFTP_PASSWORD`. |
| `RuntimeError: SftpBlock.auth must contain ...` | PATCH set `stub=false` without an `auth` block | PATCH again with a complete `auth` dict. |
| Scheduler never starts (`running: false`) | `CYCLONE_SCHEDULER_AUTOSTART` not set, no manual `start` call | Either set autostart or `POST /api/admin/scheduler/start`. |
### macOS dev box variant
If you prefer the Keychain over env vars on macOS:
```bash
security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'
security find-generic-password -s cyclone -a sftp.gainwell.password -w # verify
```
The env var is the highest-priority lookup; the Keychain is the
fallback. Setting both means the env var wins. To force the Keychain,
unset the env var for that shell.
### Docker secrets variant (SP26)
For the SP23 Docker stack, mount the MFT password as a file rather
than embedding it in `docker-compose.yml`. The compose file already
declares the `cyclone_sftp_password` secret and wires
`CYCLONE_SFTP_PASSWORD_FILE: "/run/secrets/cyclone_sftp_password"`
on the backend service. Create the file once on the host:
```bash
sudo install -m 0600 -o root -g root /dev/null /etc/cyclone/secrets/sftp_password
echo -n 'the-actual-password' | sudo tee /etc/cyclone/secrets/sftp_password > /dev/null
sudo chmod 0600 /etc/cyclone/secrets/sftp_password
```
Then `docker compose up -d`. The backend's `secrets.get_secret()` will
read the file on the next scheduler tick — no env-var export, no
`docker-compose.yml` edit with the password in it. The file takes
precedence over the plain `CYCLONE_SFTP_PASSWORD` env var; setting
both means the file wins.
If the mounted file is missing or unreadable (typo in path, container
started without the secret mount), the scheduler surfaces a
`RuntimeError` at the next tick that names the env var and the
missing path — this is intentional, so a silent fall-through doesn't
mask a real misconfiguration.
## Manual SFTP mode (this box's current posture)
Use this when the host's IP isn't whitelisted with Gainwell's MFT, when
you don't want a daemon polling every minute, or when you prefer to
drag-drop files with FileZilla / WinSCP. The seeded `dzinesco`
clearhouse ships in this mode (`sftp_block.stub: true`); no daemon
changes required.
**Posture.** `SftpClient` reads/writes to local staging instead of
`mft.gainwelltechnologies.com`:
- Outbound (`write_file`): `./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/ToHPE/`
- Inbound (`list_inbound`): `./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE/`
(Paths are relative to the backend cwd; create the directories if
absent.)
### Daily flow (manual mode)
1. **Pull inbound from Gainwell** with your SFTP client:
`/CO XIX/PROD/coxix_prod_11525703/FromHPE/`
2. **Stage locally** in `./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE/`
(or, equivalently, drop them in `/home/tyler/dev/cyclone/ingest/`
and copy in).
3. **Process** with `pull-inbound` (writes acks/835s to DB, dedupes
via `processed_inbound_files`):
```bash
cd /home/tyler/dev/cyclone/backend
mkdir -p "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE"
cp /home/tyler/dev/cyclone/ingest/*.x12 \
"./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE/"
.venv/bin/python -m cyclone pull-inbound --date 20260701
.venv/bin/python -m cyclone pull-inbound --date 20260702
.venv/bin/python -m cyclone pull-inbound --date 20260703
```
4. **Submit outbound** with `POST /api/clearhouse/submit` (writes
serialized 837P files into the stub staging dir; you drag-drop
them to your SFTP client's `/CO XIX/PROD/coxix_prod_11525703/ToHPE/`).
### Backfill a backlog
`ingest/` often holds days of unprocessed acks. The copy + per-date
`pull-inbound` flow above clears it. Files already in
`processed_inbound_files` are skipped automatically — to re-process,
delete the row first (`DELETE FROM processed_inbound_files WHERE name = ...`).
### Submitting claims (canonical — SP37)
For 837P files generated upstream (dzinesco) that you want cyclone to
**track in the DB** before uploading, use the canonical submit path.
This is the preferred outbound path going forward — it captures the
batch's `transaction_set_control_number` (the ST02 control number that
999 acks reference in AK201) so future 999 ack links resolve instead
of becoming orphans.
```bash
cd /home/tyler/dev/cyclone/backend
# 1. Lay out files in batch-*-claims subdirs under your ingest dir:
# ingest/batch-2026-07-08-claims/claim-001.x12
# ingest/batch-2026-07-08-claims/claim-002.x12
# ...
# 2. CLI — walks ingest/, parses, writes to DB, then SFTP-uploads.
.venv/bin/python -m cyclone submit-batch \
--ingest-dir /home/tyler/dev/cyclone/ingest \
--actor cli-submit-batch
# Or via HTTP (auth-gated by matrix_gate):
curl -s -X POST http://localhost:8000/api/submit-batch \
-H 'Content-Type: application/json' \
-d '{"ingest_dir": "/home/tyler/dev/cyclone/ingest", "actor": "api-submit-batch"}'
```
Both surfaces share `cyclone.submission.submit_file` for the
parse → DB-write → SFTP-upload chain (DB-first, upload-second
invariant). The walker pattern is identical: `batch-*-claims/*.x12`,
sorted, with `._*` AppleDouble files skipped.
**When to use `submit-batch` vs `resubmit-rejected-claims`:**
- `submit-batch` — canonical path for fresh 837s from dzinesco (or any
source) that should be tracked in the DB before upload. Default choice.
- `resubmit-rejected-claims` — one-off path for cases where you do NOT
want a DB row (e.g., dzinesco-generated fixes not ready for canonical
tracking). Legacy, retained for backward compat.
**Status codes / exit codes:**
- HTTP 200 on completed runs (per-file failures live in the JSON body);
401 unauthenticated; 404 no clearhouse; 409 stub mode; 422 validation.
- CLI exit 0 on completed runs (per-file failures counted, not bumped);
2 on config-level failures (no clearhouse / stub mode / missing dir).
### Reissue claims (SP24)
For rebuilding raw 837P files into one IG-correct single-claim X12
file per claim — e.g. after a 999 rejection that surfaced an
in-scope serializer defect — use `cyclone reissue-claims`. This is
the canonical offline-rebuild workflow; the pre-SP24 script
`scripts/reissue_claims.py` is a deprecation shim that prints a
warning at import time.
```bash
cd /home/tyler/dev/cyclone/backend
.venv/bin/python -m cyclone.cli reissue-claims \
--input-dir /home/tyler/dev/cyclone/july8billing \
--date 2026-07-08 \
--output-root /home/tyler/dev/cyclone/dev/rebills \
--pipeline initial \
--zip-output xxxclaims.zip
```
What it does:
1. Parse every `*.x12` / `*.txt` / `*.edi` under `--input-dir`.
2. Validate each parsed claim; skip claims with hard errors.
3. Serialize each surviving claim via the IG-correct serializer
(no `HL*3` / `PAT` / `NM1*QC` when `SBR02 == "18"`).
4. Write one X12 per claim under
`--output-root/<date>/<pipeline>/` with HCPF-spec filenames.
5. Optionally zip the output to `--zip-output`.
**Exit codes:** 0 success (per-file warnings are non-fatal); 1
IG-correctness guard tripped (the serializer's `PATIENT_LOOP_DEFAULT_INCLUDED`
constant is `True`) or unexpected error; 2 parse / validation failure
on a file AND zero claims survived.
**IG-correctness guard.** On every invocation the CLI refuses to run
if the serializer's default has been flipped back to `True`. This is
the SP24 regression guard — flipping the constant would re-introduce
the 2026-07-08 Edifabric 999 rejection ("2000C HL must be absent
when 2000B SBR02 = '18'"). The guard's only job is to fail loud; it
runs once per invocation and reads one module-level constant.
**No SFTP.** `reissue-claims` is a pure local workflow — no DB write,
no SFTP upload, no Edifabric gate. To push the rebuilt files to
Gainwell, use `cyclone submit-batch --ingest-dir <the emitted dir>`
(see the "Submitting claims" section above) or move the zip manually
per the "Manual SFTP mode" posture.
### Note on per-file parse CLIs
`parse-837` and `parse-835` exist as CLIs but only emit JSON files to
`--output-dir`; they do NOT write to the DB. There is no
`parse-999` / `parse-ta1` / `parse-277ca` CLI in this version of
cyclone — the canonical 999/TA1/277CA/835 ingestion path is
`pull-inbound` → `Scheduler.process_inbound_files`. For inspection of
a single file without DB writes, use the Python API:
```python
from cyclone.parsers.parse_999 import parse as parse_999
result = parse_999(open("path.999.x12").read())
```
### After SP39 lands — corrected-claim push + tracking
The corrected files now live under
`ingest/corrected-v2/v2-batch-<batch-id>-<N>-claims/` instead of the
SP33-era `ingest/corrected/batch-<id>-<N>-claims/` tree. The
originals are preserved for postmortem; the new tree is the
resubmission payload. Push workflow:
1. `cyclone resubmit-rejected-claims --ingest-dir /home/tyler/dev/cyclone/ingest/corrected-v2`
(one push per batch dir; the CLI is idempotent on
`(claim_id, interchange_control_number)` so re-running is safe).
2. After Gainwell returns 999 acks, drop them into `ingest/` for
the standard `cyclone pull-inbound` flow.
3. Track the post-submission outcomes:
`cyclone resubmissions status [--batch-id=<id>]` — shows
`pending_999 / 999_accepted / 999_rejected / 277ca_accepted /
paid / denied_again` per claim, joined against `claim_acks`
(via the existing SP28/31 auto-link) and `remittances` (via
CLP→claim).
What SP39 fixed in the serializer (2010BB loop, payer NM1 segment):
- Empty / `SKCO0` / `CO_BHA` payer.id → normalized to `CO_TXIX`
(Gainwell's companion guide requires `CO_TXIX` for the 2010BB
NM109; `SKCO0` and `CO_BHA` were silently accepted by Gainwell's
pre-processor in late 2025 but their SET-level structural
validator rejects them as of 2026-07).
- Empty / `COHCPF` / `CO_BHA` payer.name → normalized to `CO_TXIX`.
- Foreign payer IDs (anything else) pass through verbatim with a
WARNING log per substitution.
### Switching from manual → real (and back)
When this host's IP is whitelisted with Gainwell's MFT admin, the
SP25 procedure above flips the block. The CLI one-liner:
```bash
export CYCLONE_SFTP_PASSWORD="$GAINWELL_SFTP_PASS"
.venv/bin/python -c "
from cyclone import db, store
from cyclone.providers import Clearhouse, SftpBlock
db.init_db()
ch = store.store.get_clearhouse()
sb = ch.sftp_block.model_dump()
sb['stub'] = False
sb['auth'] = {'password_keychain_account': 'sftp.gainwell.password'}
new = Clearhouse(
id=1, name=ch.name, tpid=ch.tpid,
submitter_id_qual=ch.submitter_id_qual,
submitter_name=ch.submitter_name,
submitter_contact_name=ch.submitter_contact_name,
submitter_contact_email=ch.submitter_contact_email,
filename_block=ch.filename_block,
sftp_block=SftpBlock.model_validate(sb, strict=True),
updated_at=ch.updated_at,
)
print('updated:', store.store.update_clearhouse(new).sftp_block.stub)
"
```
Hot-reload via `PATCH /api/clearhouse` (preferred over direct DB
write — it also calls `scheduler.reconfigure_scheduler` so the running
daemon picks up the new block without a restart):
```bash
curl -s -b cookies.txt http://127.0.0.1:8000/api/clearhouse > /tmp/ch.json
jq '.sftp_block.stub = false
| .sftp_block.auth = {"password_keychain_account": "sftp.gainwell.password"}' \
/tmp/ch.json > /tmp/ch-patched.json
curl -X PATCH http://127.0.0.1:8000/api/clearhouse \
-H 'Content-Type: application/json' -b cookies.txt \
--data @/tmp/ch-patched.json
```
To revert, set `stub: true` and `auth: {"method": "keychain", "secret_ref": "sftp.gainwell.password"}`.
### After SP40 lands — Edifabric /v2/x12/validate integration
SP40 wires Edifabric's reference X12 validator into the push flow
as a fail-closed pre-upload gate, plus a CLI / HTTP endpoint for
ad-hoc validation of single files. The integration catches the
same byte-level defects (`PER-04 required`, `SBR-09 required`,
etc.) Edifabric flagged on the SP39 regen `ingest/corrected-v2/`.
**One-time secret setup** (operator-only, never commit):
```bash
# Option A — macOS Keychain (preferred, paid tier key):
cyclone secrets set edifabric.api_key <paid-tier-key>
# → stores in Keychain under service='cyclone', account='edifabric.api_key'
# Option B — env var (CI / headless / per-process):
export CYCLONE_EDIFABRIC_API_KEY="<paid-tier-key>"
# Option C — file companion (matches the SP25 SFTP-password pattern):
export CYCLONE_EDIFABRIC_API_KEY_FILE=/path/to/edi-key.txt
# file should be chmod 600, single-line, no trailing newline needed
```
The dev-only `tests/fixtures/edifabric_api_key.txt` holds the
free `EdiNation Developer API` provisional key from the public docs
(`3ecf6b1c5cf34bd797a5f4c57951a1cf`); CI uses the mocked transport
so the key is never actually transmitted.
**Single-file validation — CLI**
```bash
# Status printout + OperationResult summary; exits 0 / 1 / 2.
.venv/bin/python -m cyclone.cli validate-837 ingest/corrected-v2/v2-batch-<id>-<N>-claims/tp-<id>-1of1.x12
```
Exit codes (matches the rest of the validator family):
- `0` — Edifabric returned `Status="success"` (warnings are non-blocking).
- `2` — `Status="error"`: at least one `Details` item has
`Status="error"`. The CLI prints the first 5 offending segment
IDs + messages, then exits.
- `1` — Edifabric client config error (no API key, or transport
exception). The operator must fix `cyc secrets set` before retrying.
**Single-file validation — HTTP (auth-gated)**
```bash
curl -b admin-cookies.txt -X POST \
-F "file=@ingest/corrected-v2/v2-batch-.../tp-...-1of1.x12" \
http://127.0.0.1:8000/api/admin/validate-837
# → 200 with the raw OperationResult JSON (Status + Details + LastIndex)
# Caller inspects Status to decide whether the file is acceptable.
```
`POST /api/admin/validate-837` is matrix-gated (ADMIN_ONLY); 401
without a session cookie, 403 with a viewer cookie.
**Pre-upload gate on resubmit-rejected-claims**
`cyclone resubmit-rejected-claims` runs Edifabric validation
before every SFTP `put`. Any file that returns `Status="error"`
is skipped (not uploaded, no `Resubmission` row inserted), and the
batch continues with the remaining files. After the run:
```bash
.venv/bin/python -m cyclone.cli resubmit-rejected-claims \
--ingest-dir /home/tyler/dev/cyclone/ingest/corrected-v2
# ...
# EDIFABRIC VALIDATION FAIL tp-cyc-deadbeef-1of1.x12: 2 error(s)
# [error] PER: PER-04 is required
# [error] SBR: SBR-09 is required
# ...
# DONE uploaded=361 skipped=0 failed=0 validated=363 validate_failed=2 ...
#
# 2 file(s) blocked by Edifabric gate:
# tp-cyc-aaaaaaaa-1of1.x12
# [error] PER: PER-04 is required
# tp-cyc-bbbbbbbb-1of1.x12
# [error] SBR: SBR-09 is required
```
To bypass the gate (dev only — for hot-fix scenarios where
Edifabric is the bottleneck):
```bash
.venv/bin/python -m cyclone.cli resubmit-rejected-claims \
--ingest-dir ... \
--skip-edifabric-validate
```
**Expected OperationResult shape** (verbatim from EdiNation docs):
```json
{
"Status": "success" | "warning" | "error",
"Details": [
{"Index": 5, "SegmentId": "PER", "Value": "...",
"Message": "PER-04 is required", "Status": "error"}
],
"LastIndex": 46
}
```
Warnings (`Status="warning"`) never block. Only `Details` items
with `Status="error"` fail the gate.
**Troubleshooting**
- `API key not configured` → run
`cyclone secrets set edifabric.api_key <key>` (or set
`CYCLONE_EDIFABRIC_API_KEY_FILE`).
- `502 Edifabric upstream failure` → check
https://status.edifabric.com/ (or the API status page); the
gate treats any 4xx/5xx from Edifabric as a hard fail for that
file but continues the batch.
- After fixing the byte defect, re-run
`cyclone resubmit-rejected-claims` for the blocked file —
idempotent on `(claim_id, interchange_control_number)`.
## Known historical drift — the 804 orphan 999s
The `acks` table may hold several hundred 999 acks whose source 837
batch is not present in the `batches` table — the Inbox "Ack orphans"
lane surfaces them at `GET /api/inbox/ack-orphans`. These are real
production 999s (e.g. sender_id `COMEDASSISTPROG`) whose source 837s
were submitted to HPE clearinghouse before the current
`~/.local/share/cyclone/cyclone.db` snapshot was created. The source
837s themselves were never re-ingested into the current DB, so the
`claims` table has no rows that could link against them. SP37's
canonical submit-batch flow captures ST02 going forward, so the
orphan count is stable — not a forward-looking bug, just historical
drift.
**You cannot auto-link these orphans.** The source 837s are not in
`ingest/`, `backend/var/sftp/staging/`, or any local path — they
were transmitted to HPE and never came back. Cyclone is downstream
of the clearinghouse and does not retain copies of outbound 837s
after SFTP ACK. Do not attempt to re-ingest from SFTP inbound —
those files are the 999 acks themselves, not the source 837s.
### Triage path
1. **Inspect the Inbox > AckOrphansLane** in the UI. Each row is a
999 ack with no resolvable claim. Sort by `parsed_at DESC` to
see the most recent first; older orphans are less likely to be
actionable.
2. **Decide per row.** If the operator can identify the source 837
outside Cyclone (e.g. a manual record of what was submitted that
day), manually create a `claims` row + a `claim_acks` link via
`POST /api/parse-837` followed by `POST /api/inbox/candidates/{remit_id}/match`
or `POST /api/acks/.../match-claim`. If not, leave the orphan
alone — it stays as valid audit history.
### Optional: seed synthetic batch rows (one-shot)
`cyclone ack-orphans reconcile` inserts a synthetic `batches` row
for every orphan ST02 that doesn't already have one. The synthetic
row is marked with `input_filename = '<synthetic:orphan-reconcile>'`
so it's trivially distinguishable in queries. Future 999 acks for
the same ST02s will resolve against the batch envelope index (so
the operator can see "this 999 is for a known orphan source") but
will not link to claims (because no claim rows exist).
```bash
# Preview the reconcile without writing rows.
cyclone ack-orphans reconcile --dry-run
# Insert the synthetic batch rows.
cyclone ack-orphans reconcile
```
Re-running `cyclone ack-orphans reconcile` after a successful pass
is a no-op (idempotent). To inspect the per-ST02 breakdown at any
time:
```bash
cyclone ack-orphans status
```
The status command prints a table with ST02, ACK COUNT, and HAS
BATCH columns, ranked by ack count so the heaviest backlog
surfaces first.
### What this is NOT
- **Not a backfill.** No `claims` rows are synthesized — the source
data is gone.
- **Not auto-runnable.** The reconcile CLI is operator-invoked only;
it does not run on boot, in the SFTP polling scheduler, or via
cron.
- **Not a deletion.** The orphan 999s are valid audit history and
must remain queryable.