feat(sp20): NPI Luhn checksum + Tax ID (EIN) format validation

Adds pure local validators for the 10-digit NPI Luhn checksum (CMS-
published algorithm with the '80840' NPPES prefix) and 9-digit EIN
format (rejects reserved prefixes 00/07/80-89). No NPPES round-trip,
no IRS e-file lookup — catches the 99% typo case at parse time.

Surface:
- cyclone.npi.is_valid_npi / is_valid_tax_id / normalize_tax_id
- CLI: 'cyclone validate-npi <npi>' and 'cyclone validate-tax-id <ein>'
- API: GET /api/admin/validate-provider?npi=&tax_id=
- Parser validator: new R021_npi_checksum rule (warning, not error,
  to keep test fixtures with placeholder NPIs ingestible)
- minimal_837p.txt fixture NPI updated from '1234567890' to the
  Luhn-valid '1993999998' so strict-mode CLI parses still pass

Tests:
- test_npi.py — 27 cases (Luhn math, valid/invalid NPIs, EIN cases,
  normalize_tax_id edge cases)
- test_api_validate_provider.py — 4 cases (both valid, both invalid,
  omitted NPI, omitted tax_id)
- test_cli_validate.py — 8 cases (valid/invalid for both subcommands,
  exit codes, malformed inputs)
- test_validator.py — 4 new R021 cases (valid Luhn silent, bad Luhn
  warning, skipped when format bad, skipped when NPI missing)

Total: 923 tests pass.
This commit is contained in:
Tyler
2026-06-21 10:46:10 -06:00
parent f26bbe061a
commit 1942a22629
13 changed files with 838 additions and 3 deletions
+70
View File
@@ -370,6 +370,65 @@ fingerprints and the post-rotation table count. The old key is
retained in the `cyclone.db.key.previous` Keychain account for a
grace period so a botched rotation can be rolled back by hand.
## NPI checksum + Tax ID format validation (SP20)
Two pure local validators — no NPPES round-trip, no IRS e-file
lookup. Catches the 99% case (a typo at the end of an NPI, a letter in
an EIN, an extra digit, the reserved `00`/`07`/`8X` EIN prefix).
| Check | Algorithm | Surface |
|-------|-----------|---------|
| NPI | 10 digits where the last is a Luhn checksum over `80840 + body`. CMS-published example: body `123456789` → check `3` → valid NPI `1234567893`. | `cyclone.npi.is_valid_npi`, CLI `cyclone validate-npi <npi>`, API `GET /api/admin/validate-provider?npi=...`, validator rule `R021_npi_checksum` (warning) |
| Tax ID (EIN) | 9 digits, optional `XX-XXXXXXX` formatting. Rejects reserved prefixes `00`, `07`, `80``89` (IRS Pension Plan Branch). | `cyclone.npi.is_valid_tax_id`, CLI `cyclone validate-tax-id <ein>`, API `GET /api/admin/validate-provider?tax_id=...` |
### CLI
```bash
$ cyclone validate-npi 1234567893
OK: 10-digit NPI passes Luhn checksum
$ cyclone validate-npi 1234567890
INVALID: '1234567890' fails NPI Luhn checksum # exit 1
$ cyclone validate-tax-id 72-1587149
OK: 9-digit EIN (normalized=721587149)
$ cyclone validate-tax-id 00-1234567
INVALID: 9-digit EIN has reserved prefix (00); EIN is not assignable by IRS # exit 1
```
### API
```bash
curl 'http://localhost:8000/api/admin/validate-provider?npi=1234567893&tax_id=72-1587149'
# {
# "npi": {"valid": true, "skipped": false},
# "tax_id": {"valid": true, "skipped": false, "normalized": "721587149"}
# }
```
Both query params are optional; omitted fields return
`{"valid": null, "skipped": true}` so the caller can render "no check
performed" rather than treating absent input as a hard fail.
### Parser integration
The `R021_npi_checksum` rule runs alongside the existing `R020_npi_format`
in `cyclone.parsers.validator`. A billing-provider NPI that passes
R020 (right shape) but fails R021 (bad Luhn) is yielded as a
**warning**, not an error — operators sometimes ingest test fixtures
with placeholder NPIs (e.g. all-same-digit) and we don't want to block
that path. In strict mode (`--strict` / `?strict=true`) warnings are
promoted to errors.
### Files
* `cyclone/npi.py` — new module (~155 LOC).
* `cyclone.parsers.validator` — new `R021_npi_checksum` rule.
* `cyclone.api_routers.admin` — new `validate-provider` endpoint.
* `cyclone.cli``validate-npi` + `validate-tax-id` subcommands.
* Tests: `test_npi.py` (27), `test_api_validate_provider.py` (4),
`test_cli_validate.py` (8), `test_validator.py::test_r021_*` (4) —
**43 new tests**.
## Security hardening (SP19)
Three pure-ASGI middlewares sit in front of every FastAPI request.
@@ -674,6 +733,17 @@ Shipped sub-projects (most recent first):
MFT scheduler state, backup scheduler state, live pubsub
subscriber counts, last batch id + timestamp. See
[Security hardening (SP19)](#security-hardening-sp19) below.
- **Sub-project 20 (shipped) — NPI checksum + Tax ID format validation.**
Pure local validators (`cyclone.npi`) — no NPPES round-trip, no IRS
e-file lookup. Catches the 99% typo case at parse time. NPI uses
CMS-published Luhn over `80840 + body` (example: `1234567893` is
valid). EIN rejects reserved prefixes (`00`, `07`, `80``89`).
Surface: `cyclone validate-npi` / `validate-tax-id` CLI subcommands,
`GET /api/admin/validate-provider`, new `R021_npi_checksum`
validator rule (warning, not error — placeholder NPIs in test
fixtures shouldn't block ingest). See
[NPI checksum + Tax ID format validation (SP20)](#npi-checksum--tax-id-format-validation-sp20)
below.
- **Sub-project 18 (shipped) — Structured JSON logging.** All logs
emitted by the API, CLI, scheduler tick loop, and backup service
flow through a `JsonFormatter` (newline-delimited JSON, ISO-8601 ms