Commit Graph

194 Commits

Author SHA1 Message Date
Nora d328c0b224 feat(sp39): _normalize_payer_id normalizes SKCO0/CO_BHA/empty -> CO_TXIX 2026-07-07 17:19:18 -06:00
Nora 28835e2f1d feat(sp25): cyclone recover-ingest CLI for stranded source files 2026-07-07 14:33:38 -06:00
Nora 0ccc396e5e feat(sp25): read-side defensive stub for synthetic-batch rows 2026-07-07 14:29:56 -06:00
Nora 9c0aa577fb fix: RateLimitMiddleware._buckets is class-level so reload-safe
20 pre-existing pytest failures were caused by tests/test_api.py
calling importlib.reload(cyclone.api) mid-suite. Reload creates a
NEW FastAPI app with a NEW RateLimitMiddleware whose private
_buckets dict is independent of the OLD instance. Tests that
imported 'from cyclone.api import app' at module load kept
referencing the OLD app, so their requests accumulated in the
orphaned bucket which the conftest reset never cleared. After
~300 requests, the orphaned bucket tripped the limiter and these
tests got spurious 429s:
  - test_existing_endpoints_require_auth (6)
  - test_inbox_endpoints (8)
  - test_inbox_endpoints_sp7 (1)
  - test_list_endpoint_counts (4)

Hoisting _buckets to a class-level dict makes one
RateLimitMiddleware._buckets.clear() (in conftest reset) reach
every instance — current, stale, post-reload — eliminating the
orphaned-bucket leak. Per-instance _lock stays per-instance
since it guards mutation of the shared dict.

Verified stable: 3 consecutive full-suite runs all pass with
1435 passed, 10 skipped, 0 failed in ~82s each.
2026-07-07 13:56:49 -06:00
Nora 07ea7ca1d6 fix(sp38): restore per-kind control_number in find_ack_orphans + review cleanups
Three pr-reviewer followups from the 2026-07-07 review of commit ad14b56:

1. BUG: find_ack_orphans refactor routed 277ca/ta1 through
   _ack_control_number which only knew 999 — restored per-kind source
   (999 reads raw_json.envelope.control_number, 277ca/ta1 read the ORM
   control_number column). Added regression test pinning all three
   kinds.

2. DOCSTRING DRIFT: reconcile_orphan_st02s said 'remaining columns
   take their defaults' but explicitly passes parsed_at and
   transaction_set_control_number — added both to the explicit list
   and clarified the rest take schema defaults.

3. WASTED SORT: _iter_orphan_999_st02s yielded sorted() but the
   caller re-sorts by (-ack_count, st02) — yielding unsorted now.

Plus three cleanups the reviewer flagged:

* Hoisted 'json' / 'uuid' / 'datetime' imports to module top of
  store/__init__.py (replaced in-method imports).
* Added two missing tests: sentinel grep-discoverability via
  LIKE '<synthetic:%>' + 999-walk tolerance for non-dict raw_json
  (None / list shapes — bytes is unreachable through the ORM).
* Aligned spec/plan exit codes to the cyclone-cli convention
  (exit 1 on DB error, not 2 — matches the existing CLI
  sys.exit(1) and the cyclone-cli skill documentation).

36/36 SP38 tests pass.
2026-07-07 13:11:29 -06:00
Nora 9ef749c783 feat(sp38): 'cyclone ack-orphans {status,reconcile}' CLI subcommands
Implements tasks 4-5 of
docs/superpowers/plans/2026-07-07-cyclone-orphan-ack-housekeeping.md.

Adds a new 'ack-orphans' group under the main click CLI with two
subcommands:

  cyclone ack-orphans status
    Prints a table of orphan 999 acks:
      ST02             ACK COUNT   HAS BATCH
      --------------- ----------   ---------
      991102989              419   no
      991102988              226   no
      ...
      TOTAL                  804
    Empty-DB case prints 'no orphans' instead of a blank table.

  cyclone ack-orphans reconcile [--dry-run]
    One-shot synthetic-batch seeder. --dry-run prints the plan
    without writing. Real call inserts one synthetic batches row
    per orphan ST02 lacking one, marked with the
    '<synthetic:orphan-reconcile>' sentinel so they're trivially
    distinguishable in queries.

Exit codes per cyclone-cli convention: 0 = success, 1 = DB error.
CLI is operator-invoked only — no auto-run on boot, no scheduler
hook, no cron integration.

7 new tests (test_ack_orphans_cli.py) use click.testing.CliRunner
(matching the SP37-followup #5 pattern that replaced subprocess.run
with the in-process runner). Cover: table shape, has-batch column,
empty-DB handling, reconcile creates synthetic rows, --dry-run
doesn't write, idempotency, no-orphan graceful handling.

Live-verified against ~/.local/share/cyclone/cyclone.db: status
prints the 4 distinct ST02s (419/226/106/53 = 804 total). Reconcile
inserts 4 synthetic batches. Subsequent status shows all 4 marked
has_batch=yes.
2026-07-07 12:53:51 -06:00
Nora ad14b56732 feat(sp38): CycloneStore.find_ack_orphan_st02_summary + reconcile_orphan_st02s
Implements the store-helper half of the SP38 spec (tasks 1-3 of
docs/superpowers/plans/2026-07-07-cyclone-orphan-ack-housekeeping.md).

Two new CycloneStore methods + one extraction helper in claim_acks.py:

  - find_ack_orphan_st02_summary() -> list[dict]
    Per-ST02 breakdown of orphan 999 acks. Returns one row per
    distinct (st02, ack_count, has_batch, batch_id). Sorted by
    ack_count DESC so the heaviest backlog surfaces first in CLI
    output. 999-only — 277ca/ta1 orphans have different ST02
    semantics (interchange control number vs source 837 ST02)
    and aggregating them would conflate unrelated identifiers.

  - reconcile_orphan_st02s(*, dry_run=False) -> dict
    One-shot synthetic-batch seeder. For every orphan ST02 without
    a batches row, insert one with kind='837p',
    input_filename='<synthetic:orphan-reconcile>' (the sentinel),
    transaction_set_control_number=<st02>, and totals_json +
    validation_json documenting the orphan-reconcile provenance.
    Idempotent: re-running after a successful pass is a no-op.
    dry_run=True returns the plan shape without writing.

  - _iter_orphan_999_st02s() -> Iterator[(st02, count)]
    Extracted walk helper. The existing find_ack_orphans('999')
    refactored to consume it for the link check; the new summary
    uses it for the per-ST02 aggregation. Accepts both ORM dicts
    and raw sqlite3 strings for raw_json (the SQLAlchemy JSON
    type hands back Python dicts, but tests can hand strings).

11 new tests (test_ack_orphan_summary.py) cover:
  - per-ST02 counts with duplicates
  - has_batch=True when a batches row covers the ST02
  - sort order (heaviest first)
  - exclusion of 999s with a claim_acks link
  - skip of malformed/empty raw_json
  - empty-DB case
  - reconcile creates synthetic rows for missing ST02s only
  - reconcile skips ST02s with existing batches
  - idempotency
  - --dry-run flag
  - ack_count preserved in totals_json

Live-verified against ~/.local/share/cyclone/cyclone.db: 4 distinct
orphan ST02s, 804 total orphan rows. After reconcile, all 4 marked
has_batch=yes.
2026-07-07 12:53:40 -06:00
Nora 893a6629a8 fix(sp37-followup): close auth-coverage gaps and drop dead matrix entries
Audit on 2026-07-07 found three production bugs and six stale matrix
entries:

  Three registered routes were DENIED in production (fail-closed):
    - DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id}
      (Inbox unlink-a-wrong-match action; wired from the frontend)
    - GET  /api/dashboard/kpis (dashboard summary cards)
    - GET  /api/inbox/ack-orphans (Inbox 'Ack orphans' lane; already
      wired into src/hooks/useAckOrphans.ts + AckOrphansLane.tsx)

  All three had working frontend callers + passing tests (TestClient
  bypasses matrix_gate), so operators hitting those endpoints got 403s
  in production while CI stayed green. Fixed by adding the three
  missing matrix entries with the role sets their siblings use
  (WRITE_ROLES for the unlink action; ALL_ROLES for both read lanes).

  Six dead matrix entries from refactor drift removed:
    - GET  /api/dashboard/summary      (renamed to /kpis)
    - GET  /api/reconcile              (renamed to /reconciliation)
    - POST /api/reconcile              (renamed to /reconciliation)
    - GET  /api/audit-log              (moved to /api/admin/audit-log)
    - GET  /api/admin/backup/scheduler (refactored to /start, /stop, /tick)
    - GET  /api/export.csv             (renamed to /api/inbox/export.csv)

  Plus a one-line doc fix at api_routers/claim_acks.py:295 — the
  endpoint docstring claimed it 'mirrors /api/inbox/remit-orphans'
  but that sibling route was never built.

Guard test: tests/test_routes_have_auth_coverage.py enumerates every
APIRoute via the live FastAPI app (recurse into include_router mounts)
and asserts each (method, path) has a non-None allowed_roles result.
Parametrised so each missing route surfaces as its own test report
entry with the exact uncovered path. 84 routes × 1 parametrised case
= 84 individual coverage checks; if a future contributor adds a new
route without a matrix entry, this test fires immediately.
2026-07-07 12:31:11 -06:00
Nora f62e1a7881 fix(sp37-followup): add SftpClient.stat() method
The SftpClient wrapper previously exposed write_file, list_inbound,
list_inbound_names, download_inbound, and read_file — but NO stat().
The canonical cyclone.submission.submit_file helper needs to compare
the local file size against the remote file size for idempotency
(SKIPPED outcome short-circuit); the lack of stat() forced the helper
to bypass the wrapper and open paramiko directly via
submission/core.py:_default_sftp_factory.

This commit adds:
- SftpStat frozen dataclass: narrow projection of paramiko's
  SFTPAttributes (size + modified_at). Callers don't need paramiko.
- SftpClient.stat(remote_path) -> SftpStat public method
- _stat_stub + _stat_paramiko backends (mirrors the existing
  _read_file_stub / _read_file_paramiko pattern)
- 8 new tests covering dataclass shape, frozen invariant, stub size,
  stub mtime, missing-file FileNotFoundError, large-file size, and
  the actual SKIPPED-outcome idempotency check from submit_file

Also adds backend/var/ to .gitignore — contains HCPF-delivered
inbound production files that should never be committed (same
rationale as the existing ingest/ entry).
2026-07-07 12:24:35 -06:00
Nora dc5bff617d fix(sp37-followup): load BACKFILL_SQL from migration file (no drift)
Followup #4 from the SP37 final-state tracker. The previous
BACKFILL_SQL constant in test_migration_0020.py was a hand-copied
duplicate of the migration's UPDATE statement. A future contributor
could edit one without the other and the test would silently
replay a different SQL than production — defeating the regression.

Fix: tests now load the migration file at test time and extract
its UPDATE via the same splitter db_migrate.run() uses (strip
'--' comments, split on ';'). The test can never disagree with
what production runs.

Changes:
  * test_migration_0020.py:
    - Remove the hand-copied BACKFILL_SQL constant
    - Add _migration_0020_path(), _extract_update_statements(),
      and _load_migration_0020_backfill_sql() helpers
    - Replace 3 BACKFILL_SQL references with helper calls
  * test_migration_0020_no_drift.py (new, 3 tests):
    - test_migration_0020_backfill_sql_uses_migration_file
      (asserts the extracted SQL targets the right column + path)
    - test_migration_0020_backfill_sql_is_non_empty_single_statement
    - test_migration_0020_has_exactly_one_update (guardrail against
      future contributors adding a second UPDATE — the extraction
      fails loudly so the test author can decide which is the
      backfill)

Tests: 43/43 pass in 1.46s (full SP37 followup chain).
Imports across test files match the existing pattern (test_store.py
imports from test_store_reconcile.py).
2026-07-07 12:21:27 -06:00
Nora abaf23c122 fix(sp37-followup): SubmitOutcome.UNEXPECTED_ERROR for non-typed failures
Followup #3 from the SP37 final-state tracker. The router's
per-file try/except previously coerced every unexpected exception
to SubmitOutcome.SFTP_FAILED — misleading because a RuntimeError
from cycl_store.add has nothing to do with SFTP.

Changes:
  * submission/result.py — add UNEXPECTED_ERROR = 'unexpected_error'
    enum value. Docstring distinguishes it from the typed SFTP_FAILED.
  * api_routers/submission.py — exception handler now returns
    UNEXPECTED_ERROR (was SFTP_FAILED).
  * test_api_submit_batch.py::test_submit_batch_unexpected_exception_recorded_in_results
    — updated assertion (was sftp_failed, now unexpected_error).
  * test_unexpected_error_outcome.py (new, 4 tests):
    - enum value exists
    - distinct from typed failure values
    - API surfaces 'unexpected_error' for uncaught exceptions
    - typed SFTP_FAILED path still surfaces 'sftp_failed' (regression lock)

Tests: 40/40 pass in 1.41s (full SP37 chain + new file).
Live curl: POST /api/submit-batch in stub mode → HTTP 409 (unchanged
behavior, no regression on the config-level guards).
2026-07-07 12:19:42 -06:00
Nora ce0df8ac24 fix(sp37-followup): use CliRunner in test_submit_batch_cli_help
Followup #2 from the SP37 final-state tracker. Replaces the
subprocess.run-based CLI help test with click.testing.CliRunner
(matches the pattern already used in test_cli.py).

Speedup: CliRunner doesn't spawn a subprocess, so the test runs in
~0.5s vs ~0.2s+ for subprocess (and the subprocess version was
susceptible to fork overhead, environment leakage between tests,
and PATH/CWD surprises on different hosts).

Same assertions:
  * result.exit_code == 0  (matches subprocess.returncode == 0)
  * '--ingest-dir' in result.output  (matches stdout check)

Test passes in isolation and as part of the full SP37 chain
(36 tests in 1.29s — same speedup as the single test).
2026-07-07 12:17:50 -06:00
Nora cc02cb99c5 fix(sp37-followup): remove dead /api/resubmit permissions entry
Followup #1 from the SP37 final-state tracker. The pre-existing
('POST', '/api/resubmit'): WRITE_ROLES entry only matched the exact
path /api/resubmit (no children — the prefix-match logic requires
the trailing slash for sub-paths). No route is registered at that
path; the actual resubmit lives at /api/inbox/rejected/resubmit
(which has its own entry).

Removing the dead entry is fail-closed: any future request to
/api/resubmit (or its descendants) returns None from allowed_roles,
which means deny. Without this cleanup, the matrix silently granted
WRITE_ROLES to a non-existent route.

Tests (test_auth_permissions_matrix.py, 5 cases):
  * Exact path /api/resubmit not in PERMISSIONS
  * allowed_roles('POST', '/api/resubmit') is None
  * /api/resubmit/, /api/resubmit/anything all deny

Pin the invariant so a future contributor can't re-add a dead prefix.

TDD: tests written first, watched fail (5 fail with the dead entry),
removed the line, tests pass (5 pass). Full SP37 chain (36 tests)
passes with the change.
2026-07-07 12:17:14 -06:00
Cyclone SP37 f1dc06ec3b feat(sp37): POST /api/submit-batch endpoint
Thin HTTP wrapper around `cyclone.submission.submit_file` — the
HTTP twin of the `cyclone submit-batch` CLI (SP37 Task 5). Same
walker pattern, same `._*` AppleDouble skip, same `limit`
semantics, same per-file outcomes; both surfaces now agree on DB +
SFTP state byte-for-byte.

Body: `{ingest_dir, validate?, actor?, limit?}`. Response:
`{submitted, skipped, failed, results:[{file, outcome, batch_id,
error}]}`. Status codes:
  - 200 on completed runs (per-file failures live in the body)
  - 401 unauthenticated (matrix_gate)
  - 404 no clearhouse seeded
  - 409 clearhouse SFTP block in stub mode
  - 422 ingest_dir missing on disk or Pydantic body error

Does NOT inject `sftp_client_factory` — submit_file uses its
default paramiko factory so SKIPPED is reachable in production.
Tests monkey-patch submit_file itself.

Permissions entry: `POST /api/submit-batch` → WRITE_ROLES (admin +
user), matching `POST /api/resubmit` posture.

Tests (11): happy path, AppleDouble skip, limit, empty ingest_dir
422, mixed-outcome 200, unexpected-exception-as-failed,
Pydantic-missing-422, on-disk-missing-422, no-clearhouse-404,
stub-mode-409, auth-gate-401.
2026-07-07 11:36:47 -06:00
Nora dc990cfde3 feat(sp37): cyclone submit-batch CLI
Walks batch-*-claims/*.x12 under --ingest-dir, calls submit_file per
file, prints submitted/skipped/failed counts. Exits 0 even on
per-file failures (details in stdout); exits 2 on config-level
errors (no clearhouse, stub mode, missing dir).

This is the canonical outbound path; resubmit-rejected-claims
remains for one-off cases where no DB row is wanted.
2026-07-07 11:10:10 -06:00
Nora ec065a9082 fix(sp37): submit_file default factory uses paramiko directly
SftpClient wrapper lacks a stat() method, so the previous default
factory produced a client whose stat() raised AttributeError — making
the SKIPPED outcome unreachable in production and silently
misclassifying all uploads as SFTP_FAILED. The helper now opens a
paramiko SFTP session directly (same pattern as
resubmit-rejected-claims in cli.py:620-650), so stat() and write_file()
both work end-to-end.

Also: tighten typing (SftpBlock instead of Any), add PAYER_MISMATCH
test, drop duplicate _db fixture, rename misleading test, add class
docstrings to SubmitResult/SubmitOutcome.
2026-07-07 11:06:08 -06:00
Nora 5d4e06b863 feat(sp37): cyclone.submission.submit_file helper
Owns the parse → DB write → SFTP upload sequence in one place. CLI
and HTTP thin-call it. DB-first invariant: if add_record raises, no
SFTP call is made. Idempotency: add_record dedupes via s.get(Claim,
claim_id); SFTP layer dedupes via stat().st_size match.
2026-07-07 10:50:48 -06:00
Nora 298200fe71 fix(sp37): sync head-assertion + update batch_envelope_index docstrings
- test_acks.py:test_migration_latest_idempotent_on_fresh_db was still
  asserting user_version == 19; migration 0020 (Task 1) bumped it to 20.
  Without this fix, the full pytest suite fails the moment SP37 ships.
- claim_acks.py module docstring + store/__init__.py facade docstring
  still advertised the index as single-key {envelope.control_number:
  batch.id}. SP37 Task 3 made it dual-key (ISA13 + ST02); the docs now
  match the implementation.
2026-07-07 10:34:46 -06:00
Nora 7ad190c267 feat(sp37): batch_envelope_index now resolves by ST02 or ISA13
Each 837p batch row contributes up to two entries to the join-key
index (envelope.control_number + transaction_set_control_number). One
idx.get(set_control_number) call resolves either, so 999 acks whose
AK201 echoes the source 837's ST02 now hit Pass 1 where they
previously fell through to Pass 2 (and to the orphan log).

Backward compat preserved: rows with transaction_set_control_number
NULL (pre-SP37 batches) still resolve by ISA13.
2026-07-07 10:30:23 -06:00
Nora 3a58f561d4 feat(sp37): populate batches.transaction_set_control_number on 837P ingest
The parser now captures the source 837's ST02 (transaction set control
number) on the Envelope model as 'transaction_set_control_number'.
add_record reads it off the envelope and stores it on the Batch row,
mirroring how 'envelope.control_number' (ISA13) was already handled.

Unlocks the SP37 join-key update so 999 set_control_number (AK201)
values resolve back to the right batch via Pass 1.
2026-07-07 10:22:32 -06:00
sp37 bot 0067ecc5da feat(sp37): add batches.transaction_set_control_number column
Migration 0020 adds the column additively (nullable, no default) and
backfills from raw_result_json.envelope.transaction_set_control_number
for any existing batch rows that already carry it. Required for the
SP37 join-key update so 999 acks can resolve by ST02 (the source 837's
transaction set control number) instead of just ISA13.
2026-07-07 10:13:00 -06:00
Nora 85791e0df7 feat(sp36): absorb 20 admin endpoints into api_routers/admin.py
Moves the entire /api/admin/* namespace (audit-log ×2, db/rotate-key ×1,
backup ×10, scheduler ×6, reload-config ×1) out of api.py and into the
existing api_routers/admin.py, which already owned /api/admin/validate-provider.

- api.py: 4294 → 3547 LOC (Δ -747). Removed the orphaned # --- separator
  left behind by the cut, the orphaned 'return {ok,loaded,errors}'
  tail of reload-config, and the now-unused cyclone.clearhouse.InboundFile
  top-level import.
- api_routers/admin.py: 60 → 851 LOC. Added the imports the moved
  routes need (json, logging, threading, time, AuditEvent, verify_chain,
  InboundFile) and stripped the redundant top-level imports of
  symbols that the route bodies reach via the inline _db_crypto /
  _secrets / _backup_svc_mod / _backup_sched_mod / _scheduler_mod
  / _audit aliases (those aliases stay — tests rely on being able to
  monkeypatch cyclone.api_routers.admin._X.method). Hoisted the inline
  'import time' from inside pull_inbound to module scope. Dropped
  the redundant 'import threading as _threading' alias — top-level
  'import threading' already covers it.
- tests/test_api_rotate_key.py: 4 monkeypatch targets migrated from
  cyclone.api._db_crypto / cyclone.api._secrets to
  cyclone.api_routers.admin._db_crypto / cyclone.api_routers.admin._secrets
  to match the new home of the rotate-key endpoint. Lock acquire/release
  also migrated.

The non-admin /api/config/* and /api/payers/{id}/summary routes that
bracketed the moved admin blocks stay in api.py — they're extracted
in Tasks 5 & 6.

Behaviour-preserving: pytest = 20 failed / 1253 passed / 10 skipped
= baseline match. Admin-only subset (audit-log + backup + scheduler +
rotate-key + reload-config) = 34 passed.
2026-07-06 14:01:37 -06:00
Nora b0e06a2dd0 feat(sp35): regression locks on parse-999/277ca/ta1 envelope guards
The 999, 277CA, and TA1 parsers already enforce envelope correctness at
the parser level (parse_999.py line 290 raises 'No AK9 segment found';
parse_277ca.py line 298 raises 'Expected ST*277 or ST*277CA'; parse_ta1.py
line 111 raises 'Expected TA1, got <other>'). These tests lock the HTTP
surface contract: a wrong-kind file POSTed to those endpoints must come
back as 400, never as 200 or 500.

Tests added:
- test_api_999.py: rejects_837_input, rejects_835_input
- test_api_277ca.py: rejects_835_input, rejects_837_input
- test_api_ta1.py: rejects_837_input, rejects_835_input

If a future PR relaxes any of those parser-level guards, the
corresponding regression lock fires immediately.
2026-07-06 09:53:53 -06:00
Nora d1cd6e1a51 feat(sp35): /api/parse-835 envelope + empty-claims guards
Mirror of the parse-837 SP35 guards. Same defense-in-depth shape:
tokenize first, reject anything whose ST01 doesn't start with '835'
(400 'Mismatched file kind'), and after parse refuse to persist a
batch with zero CLP segments (400 'No claims parsed').

Reuses the _transaction_set_id_from_segments helper added by the
parse-837 commit.

New tests in tests/test_api_835.py:
- test_parse_835_endpoint_rejects_837_input (was failing, now green)
- test_parse_835_endpoint_rejects_empty_envelope (was failing, now green)
- test_parse_835_endpoint_happy_path_still_works (regression guard)
2026-07-06 09:52:51 -06:00
Nora f25214189a feat(sp35): /api/parse-837 envelope + empty-claims guards
Server-side defense in depth for misroute ingest. Before SP35, posting
an 835 file (or any other X12 with a parseable ISA envelope) to
/api/parse-837 silently produced a BatchRecord with claims=[] and a
bogus row on the History tab. The 837 parser only required an ISA
envelope; it didn't check the ST transaction-set id.

Two new guards run before persistence:

1. Envelope check: tokenize first, read ST01, reject anything that
   doesn't start with '837'. 400 with error='Mismatched file kind',
   expected='837p', detected_st=<actual>. Catches an 835/999/270/etc
   routed to the wrong endpoint.
2. Empty-claims check: even with the right envelope, if the parser
   produces zero CLM segments, return 400 'No claims parsed' and do
   NOT persist.

New tests in tests/test_api.py:
- test_parse_837_endpoint_rejects_835_input (was failing, now green)
- test_parse_837_endpoint_rejects_empty_envelope (was failing, now green)
- test_parse_837_endpoint_happy_path_still_works (regression guard)

Helper _transaction_set_id_from_segments reused by the 835 mirror.
2026-07-06 09:51:27 -06:00
Nora f10ab83628 feat(sp33): cli resubmit-rejected-claims + fixture/test payer_id refresh
- Adds `cyclone resubmit-rejected-claims` to push corrected single-claim
  837 files to the Gainwell ToHPE SFTP dir. Idempotent (stat-then-skip by
  byte size). One persistent paramiko session per batch with
  reconnect-every=50 to dodge MOVEit's silent per-session file cap
  (~200 puts/session, no exception).
- Validates each file via `parse_837` before upload and rejects any
  whose payer_id is not `CO_TXIX` (catches a bad byte-fix early).
- Refreshes test fixtures (`minimal_837p.txt`, `co_medicaid_837p.txt`,
  `co_medicaid_837p_with_renderer.txt`) and the corresponding test
  assertions (`test_payer.py`, `test_payer_summary.py`,
  `test_co_medicaid_fixture.py`, `test_parse_837.py`) from the old
  `SKCO0`/`COHCPF` payer IDs to `CO_TXIX`, matching
  PayerConfig.co_medicaid() and the HCPF 837P Companion Guide.
- Adds `ingest/` to .gitignore — local scratch / production-data
  staging only.
2026-07-02 21:50:23 -06:00
Nora 0625c83a45 feat(sp33): apply_999_rejections uses batch_envelope_index
The 999 handler's rejection pass was looking up claims by patient
control number, but Gainwell rejects at the SET level (ST envelope)
when the whole batch fails the NM109=CO_TXIX rule. That meant a SET
rejection was treated as a no-op even though every claim in the SET
was actually rejected by the payer.

Add a batch_envelope_index param (mirrors apply_999_acceptances from
SP28) so SET-level rejections cascade to every claim in the SET.
Falls back to the legacy PCN lookup when the index has no entry.

Also tightens test_payer.py: PayerConfig.co_medicaid() now returns
payer_id='CO_TXIX' and payer_name='CO_TXIX' per HCPF 837P Companion
Guide (June 2025 - Version 2.5).
2026-07-02 21:07:07 -06:00
Nora cf7c343ff0 feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX 2026-07-02 20:23:56 -06:00
Nora 1b2f6c6b21 test(sp32): bump migration version assertions to 19 2026-07-02 17:18:16 -06:00
Nora 0f3b264e41 feat(sp32): add backfill-rendering-npi CLI subcommand 2026-07-02 17:16:59 -06:00
Nora de77f19d9d feat(sp32): _content_keys_match prefers typed NPI columns over raw_json fallback 2026-07-02 17:08:09 -06:00
Nora c4bc118557 feat(sp32): wire rendering_provider_npi and service_provider_npi through ORM builders 2026-07-02 17:06:25 -06:00
Nora 4d3eef22ef feat(sp32): extract 837p NM1*82 (rendering provider NPI) per ClaimOutput 2026-07-02 17:04:07 -06:00
Nora d8707ba874 feat(sp32): extract 835 NM1*1P (service provider NPI) per ClaimPayment 2026-07-02 16:56:01 -06:00
Nora 18fa119ff7 feat(sp31): expose keys_matched/candidate_count in Match dataclass and ActivityEvent payload, fix broken pre-existing test 2026-07-02 16:23:42 -06:00
Nora ab91449e62 feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event 2026-07-02 15:58:01 -06:00
Nora 9bade2429c fix(sp31): read NPI and charge from raw_json when ORM attribute missing 2026-07-02 15:27:32 -06:00
Nora 5a54961930 feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool 2026-07-02 15:17:26 -06:00
Nora b484ca36dc feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule) 2026-07-02 15:04:48 -06:00
Nora 75a2800a9d refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity 2026-07-02 14:59:49 -06:00
Nora 97512ec4a7 feat(sp30): Dashboard Recent batches widget with billing outcome
- backend: GET /api/batches now returns acceptedCount/rejectedCount/
  pendingCount/billedTotal/topRejectionReason/hasProblem per item
  (one GROUP BY query + one ordered scan, no N+1)
- backend: 2 tests pin the 837p full-bucket case + the 835 zero case
- frontend: BatchSummary extended with 6 optional fields
  (backwards compat preserved)
- frontend: new RecentBatchesWidget renders one row per batch with
  status icon + billed total + accepted count + top rejection reason
- frontend: 837p rows show $ total + 'N/M accepted'; 835 rows show
  payment count (Remittance has no batch-level total_charge)
- frontend: full-width row between KPI tiles and Activity on the
  Dashboard; click navigates to /batches?batch=ID
- frontend: 3 component tests cover empty/clean/problem/835 branches
2026-07-02 14:18:55 -06:00
Nora ffacfd8665 feat(sp29): inbox rejected-row 999 ack chips + per-row Resubmit
- backend: _ack_summary_for_claims helper attaches claim_acks payload
  to inbox rejected-lane rows (3 tests)
- frontend: InboxRow renders newest 3 AK2 chips + '+N more' overflow
  under the rejection reason, with '999 not linked' marker when an
  ack couldn't be linked to a claim
- frontend: per-row Resubmit button downloads a single corrected 837
  via api.serializeClaim837 (no bulk modal, no zip — one click, one
  .x12 file)
- frontend: 2 new tests for the chip rendering and the per-row
  download flow
2026-07-02 13:16:38 -06:00
Nora fba594baf4 fix(sp28): align Batch.kind filter with prod ('837p' not '837')
The SP28 helpers (batch_envelope_index + lookup_claims_for_ack_set_response
+ two _batch_lookup closures in handle_ta1 + api.py) all filter
Batch.kind == "837", but prod Batch rows are persisted with
kind="837p" (lowercase p — see api.py:443 / store/records.py:58).
Result: the D10 batch-envelope index was empty on prod and zero 999 /
277CA / TA1 acks auto-linked to their claims via Pass 1 (ST02 via
batch.envelope.control_number). Pass 2 (PCN) was the only path firing,
which on this codebase matches 0 acks (Gainwell's 999 echoes the 837's
ST02, not its CLM01 — see spec §D10).

Fix: 4 production sites swapped to Batch.kind == "837p". 3 test
files updated to use the prod value (test_apply_claim_ack_links.py +
test_api_claim_acks.py + test_e2e_999_to_claim_drawer.py). All 25 SP28
tests + 20 handler tests pass. Expected prod effect: claim
t991102989o1c120d's 143 incoming 999 AK2 acks now auto-link via the
batch with envelope.control_number=991102989 (Pass 1), plus 727/1,398
total acks across all batches.
2026-07-02 12:22:29 -06:00
Nora 21a5485faa feat(sp28): api_routers/claim_acks (7 endpoints) + claim_acks surface on claims/acks
Phase 5 of SP28 (Ack↔Claim Auto-Link). Adds the public HTTP
surface for the claim_acks join table and wires it into the
existing claims + acks list endpoints.

New router (registered with matrix_gate auth, any-logged-in-user):

* GET    /api/claims/{id}/acks           — per-claim links
* GET    /api/claims/{id}/acks/stream    — NDJSON live-tail
* GET    /api/acks/{kind}/{id}/claims    — per-ack links
* GET    /api/acks/{kind}/{id}/claims/stream — NDJSON live-tail
* POST   /api/acks/{kind}/{id}/match-claim   — manual link (D5)
* DELETE /api/acks/{kind}/{id}/match-claim/{claim_id} — unlink
* GET    /api/inbox/ack-orphans          — orphan reconciliation

Manual match is any-logged-in-user (D5/D9), idempotent on the
(claim_id, ack_kind, ack_id) dedup key, rejects with 409 when the
claim is in a terminal state (REVERSED), and publishes
claim_ack_written / claim_ack_dropped on the bus so live-tail
subscribers refresh.

Existing list endpoints extended:
* /api/acks           — each item gains linked_claim_ids
* /api/ta1-acks       — each item gains linked_claim_ids
* /api/277ca-acks     — each item gains linked_claim_ids
* /api/claims/{id}    — body gains ack_links (compact form)

All three list extensions use a single batched SELECT against
claim_acks to avoid N+1.

Tests:
* tests/test_api_claim_acks.py — 8 tests covering all 7
  endpoints + the spec §6 named tests for the extended
  surfaces (claim_detail_includes_ack_links,
  acks_list_includes_linked_claim_ids,
  claim_acks_stream_emits_claim_ack_written). The stream test
  uses the established direct-endpoint-invocation pattern
  from test_api_stream_live.py so it gets true byte-streaming
  + clean async cancellation.
2026-07-02 11:55:15 -06:00
Nora 9e16b8d9bd feat(sp28): wire apply_claim_ack_links into handle_999 / handle_277ca / handle_ta1
Phase 4 of SP28 (Ack↔Claim Auto-Link). Refactors the per-AK2
helpers in cyclone.claim_acks to return ClaimAckLinkRow dataclasses
instead of mutating the session directly — the orchestrator (the
999 / 277CA / TA1 handlers + the matching parse-* API endpoints)
now persists each row via cycl_store.add_claim_ack so the
publish-from-store contract owns the live-tail event emission.

* handle_999 / handle_277ca / handle_ta1 — build batch envelope
  index outside the work session (SQLite + concurrent sessions
  causes 'database is locked'), call apply_X to get
  ClaimAckLinkRow dataclasses, snapshot the rows before committing
  the work session, then call cycl_store.add_claim_ack per row
  in fresh sessions.
* /api/parse-999 / /api/parse-277ca / /api/parse-ta1 — mirror the
  handler chain with event_bus passed through so live-tail
  subscribers on the claim and ack sides see the new rows the
  moment they land. Adds a 'claim_ack_links_count' field to each
  ack response (spec §4).
* lookup_claims_for_ack_set_response — now accepts either a
  callable OR a plain dict as batch_envelope_index (the store
  returns a dict; tests pass callables).
* test_apply_claim_ack_links.py — 15 tests updated to assert on
  the dataclass shape and exercise the full helper→add_claim_ack
  cycle (so idempotency is verified at the store layer).
* test_e2e_999_to_claim_drawer.py — 2 new tests covering the
  D10 two-pass join end-to-end via FastAPI TestClient (Pass 1
  via ST02 + Pass 2 via PCN fallback).
2026-07-02 11:45:19 -06:00
Nora b094231995 feat(sp28): store.claim_acks facade + batch_envelope_index + to_ui_claim_ack serializer
The persistence layer for the SP28 join table lands in
cyclone/store/claim_acks.py:

- add_claim_ack — insert + publish 'claim_ack_written' on the bus
  (mirrors the publish-from-store pattern used by the ACKs paths)
- list_acks_for_claim / list_claims_for_ack — read helpers for the
  two list endpoints
- find_ack_orphans — Inbox ack-orphans lane source: every ack row
  whose ack_id has no ClaimAck row tied to it
- remove_claim_ack — unlink + publish 'claim_ack_dropped'
- batch_envelope_index — D10 in-memory map of
  {envelope.control_number: batch.id}, called once per ingest (cost
  is ~16 lookups today; trivially cheap)

Plus the to_ui_claim_ack serializer in store/ui.py mirroring
to_ui_ack shape — single source of truth for the wire format so the
live-tail event payload matches the list endpoint byte-for-byte.
Includes a single SELECT against claims for the claim_state field
(TA1 batch-level rows return 'n/a').

The CycloneStore facade in store/__init__.py re-exports all six
methods (add_claim_ack, list_acks_for_claim, list_claims_for_ack,
find_ack_orphans, remove_claim_ack, batch_envelope_index) plus
to_ui_claim_ack from the ui module.

Migration-version assertions in test_acks.py and test_db_migrate.py
bumped 17 → 18 to match the new migration head.

Steps 3.1/3.2/3.3/3.4 of the SP28 implementation plan.
2026-07-02 11:31:29 -06:00
Nora a97f1d1350 feat(sp28): apply_claim_ack_links orchestrator + 999/277CA/TA1 helpers
The auto-linker closes the operator gap where every inbound 999 /
277CA / TA1 ack was persisted but never linked back to the claim it
acknowledges. Five pure helpers land in cyclone/claim_acks.py:

- lookup_claims_for_ack_set_response — D10 two-pass join. Primary is
  Batch.envelope.control_number (== source 837 ST02 for Gainwell
  batches); fallback is Claim.patient_control_number. Pass 1 wins,
  the two paths cannot both fire.
- apply_999_acceptances — walks parsed_999.set_responses, emits one
  ClaimAck per AK2 per matched claim (one-ack-to-many supported).
  Both accepted AND rejected AK2s link; per-AK2 granularity.
- apply_277ca_acks — same shape for parsed_277ca.claim_statuses.
  STC category code carried on the link row's
  set_accept_reject_code so the UI can render the lane inline
  without re-parsing raw_json.
- apply_ta1_envelope_link — envelope-level link. The link row has
  claim_id NULL + batch_id populated (the spec's batch-level TA1
  trace). Sender/receiver matching is delegated to a closure the
  caller supplies.
- link_manual — manually link an ack to a claim. Used by the new
  /api/acks/{kind}/{id}/match-claim endpoint. Idempotent.

All five helpers are pure (callers own the session); idempotent via
the partial unique index ux_claim_acks_dedup (helpers pre-check to
avoid IntegrityError log noise on re-ingest); flush-only (callers
commit).

14 of 15 named tests pass (the facade surface check belongs in
Phase 3 once store/claim_acks.py lands).

Steps 2.1/2.2/2.3/2.4/2.5 of the SP28 implementation plan.
2026-07-02 11:29:02 -06:00
Nora 603ec8842a feat(sp25): /api/acks/stream + /api/ta1-acks/stream NDJSON endpoints
Adds two streaming endpoints that match the live-tail wire format
established by /api/claims/stream, /api/remittances/stream, and
/api/activity/stream:

  * /api/acks/stream      — subscribes to ack_received
  * /api/ta1-acks/stream  — subscribes to ta1_ack_received

Both yield a snapshot of existing rows (newest first, capped by the
`limit` query param), then a `snapshot_end` line, then forward
live events from the bus. They are registered before the
/{ack_id} path-param endpoints so the literal `stream` segment
isn't matched as an id.

Tests use the same direct-coroutine pattern as test_api_stream_live.py
because httpx.ASGITransport buffers the response body and never
delivers a disconnect message — iterating body_iterator directly
with body_iterator.aclose() simulates a client disconnect.
2026-07-02 09:00:54 -06:00
Nora 686c11f480 feat(sp25): drop event_bus kwarg from handlers; thread event_bus into parse endpoints 2026-07-02 08:52:20 -06:00
Nora 8d11b391a0 feat(sp25): add_*_ack publish ack_received / ta1_ack_received / two77ca_ack_received 2026-07-02 08:47:03 -06:00