Commit Graph

615 Commits

Author SHA1 Message Date
Nora bde3060e9e docs(spec,plan): SP30 Dashboard Recent batches widget 2026-07-02 14:08:30 -06:00
Nora 1d2572e624 merge: SP29 Inbox 999-rejected drill into main 2026-07-02 13:16:40 -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 9cc13e7940 docs(spec,plan): SP29 Inbox 999-rejected drill 2026-07-02 13:06:21 -06:00
Nora 37caf6af77 docs(plan): SP28 follow-up notes — Batch.kind fix + one-time backfill 2026-07-02 12:31:37 -06:00
Nora 02b879a204 merge: SP28 follow-up — fix Batch.kind '837' vs '837p' mismatch
Hotfix for the SP28 merge. The SP28 helpers (batch_envelope_index +
the two _batch_lookup closures in handle_ta1 / api.py) all filtered
Batch.kind == '837', but prod Batch rows are persisted with kind='837p'
(see api.py:443). Result on prod: claim_acks row count = 0, D10 Pass 1
(ST02 via batch.envelope.control_number) never fired, and the user's
specific claim t991102989o1c120d would not surface its 143 999 acks.

Fix: 4 production sites swapped to '837p'; 3 test files updated to
match the prod value. 25 SP28 tests + 20 handler tests pass post-fix.
Expected prod effect after re-deploy: 727 of 1,398 acks auto-link via
batch.envelope.control_number, plus the user's claim surfaces in
ClaimDrawer for /api/claims/t991102989o1c120d/acks.

Follow-up plan: backfill the existing 1,398 acks via a one-time script
that runs the SP28 helpers against the current DB (D10 join now
resolves the 991102989 ST02 from existing batches).
2026-07-02 12:22:36 -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 190f4b8c8d merge: SP28 ack-claim auto-link into main
Closes the operator gap where 999 / 277CA / TA1 acks were persisted but
never linked back to the claims they acknowledged. The original PCN-based
join matched 0 / 1,398 acks on prod (Gainwell's 999 echoes the source
837's ST02, not its CLM01); the D10 two-pass join via Batch.envelope.
control_number recovers 727 / 1,398 automatically.

Increment summary:
- New claim_acks join table (migration 0018) with partial unique dedup index
- Pure helpers (apply_claim_ack_links orchestrator + 999/277CA/TA1 paths)
  with the D10 two-pass lookup (Batch.envelope.control_number primary,
  Claim.patient_control_number fallback)
- CycloneStore facade: add_claim_ack, list_acks_for_claim,
  list_claims_for_ack, find_ack_orphans, remove_claim_ack, batch_envelope_index
- Handler integration: handle_999 / handle_277ca / handle_ta1
- 7 new API endpoints (read + stream + manual-match + unlink + orphan lane)
- Frontend: useClaimAcks / useAckClaims hooks, claimAcks live-tail slice,
  AcknowledgmentsPanel in ClaimDrawer, MatchedClaimPanel + manual-match
  dropdown in AckDrawer, Claims badge column on Acks page, Ack-orphans
  lane on Inbox page

Coverage: 727 / 1,398 acks auto-link; 671 remain real orphans (ST02=0001)
surfaced in the Inbox ack-orphans lane.

Auth posture: any logged-in user can run the manual-match endpoint
(D5/D9 deviation from admin-only remit-orphans — ack matches are
metadata-only, no Claim.state mutation).

Test baseline (no new failures):
- Backend: 25 new SP28 tests pass; full suite 1210 passed / 10 skipped /
  1 failed + 6 errors (pre-existing test_payer_summary +
  test_provider_extended_response pollution; all 7 pass in isolation)
- Frontend: 36 new SP28 tests pass; full suite 580 passed / 5 failed
  (pre-existing api.test.ts / tail-stream.test.ts / Inbox.test.tsx /
  InboxHeader.test.tsx baseline)
- Typecheck: 17 pre-existing errors remain; 0 in SP28-introduced files
  (refactor e10d388 fixed 3 pre-existing joinUrl import errors)

Files: 47 changed, +6,509/-23. Refactor e10d388 (joinUrl export) folded in
to keep SP28 merge coherent. 14 SP28 commits on branch preserved as audit
trail.
2026-07-02 12:12:31 -06:00
Nora 9d11ffcd8a docs(plan): SP28 deviations log — 4 backend + 5 frontend post-impl notes
Backend: helpers return dataclasses instead of inserting directly (preserves
publish-from-store contract); handlers commit work session before calling
cycl_store.add_claim_ack (SQLite single-writer); batch_envelope_index
accepts dict OR callable; link_manual returns ClaimAckLinkRow.

Frontend: TA1 row shape in Inbox lane; cross-page nav via useNavigate;
TA1 column header 'Batches' (not 'Claims'); ack-orphans lane position
between payer_rejected and candidates; fmt.usd instead of fmt.money
in MatchedClaimPanel.

Confirmed test baselines: backend 1210/10/6-err/1-fail = pre-SP28;
frontend 580 pass / 5 fail = pre-SP28. No new failures introduced.
46 files changed, +6,467/-22.
2026-07-02 12:05:45 -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 3fd2c445e7 fix(sp28): use fmt.usd instead of fmt.money in MatchedClaimPanel 2026-07-02 11:46:45 -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 1c367ddbe7 feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown 2026-07-02 11:44:53 -06:00
Nora d8f3fb15f9 feat(sp28): Acks page Claims badge column with orphan/single/many variants 2026-07-02 11:40:50 -06:00
Nora 602dc352c5 feat(sp28): AckDrawer matched-claim panel + manual-match dropdown 2026-07-02 11:39:02 -06:00
Nora 490a1b9478 feat(sp28): ClaimDrawer acknowledgments panel + tests 2026-07-02 11:33:47 -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 c54b2c1867 feat(sp28): claimAcks slice + useTailStream/useMergedTail dispatch 2026-07-02 11:31:17 -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 d2512945ca feat(sp28): useClaimAcks + useAckClaims hooks with api types 2026-07-02 11:26:25 -06:00
Nora 9e775c0150 feat(sp28): add 0018_claim_acks migration + ClaimAck ORM model
The claim_acks join table is the durable record of which inbound
999 / 277CA / TA1 ack acknowledged which claim (or, for TA1, which
originating 837 Batch). One row per AK2 set-response / ClaimStatus
/ envelope. The match granularity is per-AK2 so the operator can
answer 'which claims does this ack acknowledge?' with a single
SELECT and so the ClaimDrawer panel can show per-segment accept /
reject status without re-parsing raw_json.

Schema mirrors spec §3.1:
- claim_id NULLable + batch_id NULLable (TA1 envelope links land
  on batch_id; CHECK enforces at least one populated)
- unique partial index ux_claim_acks_dedup enforces idempotent
  re-ingest of the same 999 file
- set_control_number stores the value the upstream ack ACTUALLY
  CARRIED (== source 837 ST02 for Gainwell batches) for orphan
  traceability — the link survives even when the join had to fall
  back from ST02 to PCN matching

Mirrored on the ORM via Index(..., sqlite_where=text(...)) so
Base.metadata.create_all (the test-time safety net) emits the same
partial-unique constraint as the migration.

Step 1.1/1.2/1.3 of the SP28 implementation plan.
2026-07-02 11:24:49 -06:00
Nora e10d3886c2 refactor: export joinUrl from auth/api and reuse in lib/api
Single source of truth for the VITE_API_BASE_URL prefix logic. Both
auth/api and lib/api imported the same BASE_URL const; promote joinUrl
to a shared exported helper so future endpoints don't drift on the
trailing-slash normalization.
2026-07-02 11:18:22 -06:00
Nora ec87f98f02 docs(spec): SP28 D10 — two-pass join via Batch.envelope.control_number
The original SP28 spec assumed `Claim.patient_control_number == 999.set_control_number`.
Empirically that's wrong: Gainwell's 999 echoes the source 837's ST02, not its
CLM01, and TOC's billing software fills them differently. Measured against
prod on 2026-07-02: the PCN join matches 0 / 1,398 acks.

Fix: two-pass join. Primary is `Batch.envelope.control_number (== 837 ST02)
→ claims via batch_id`; fallback is `Claim.patient_control_number` (for
senders that fill CLM01 == ST02). Coverage after fix: 727 / 1,398 (52%);
the remaining 671 are real orphans (ST02=0001 placeholder, no matching 837
batch in our DB).

Spec adds D10 + a critical-correction paragraph at the top of §1. Plan
gains the `lookup_claims_for_ack_set_response` pure helper, the
`batch_envelope_index()` store method, and three new tests covering the
two-pass join + the false-positive guard + the one-ack-to-many case.
2026-07-02 11:16:53 -06:00
Nora f88a7c7c4f feat(acks): surface 999 rejections via sidebar badge + table sort
The 1,156 accepted acks were burying the 5 rejections — the operator
had to scroll the whole table to find them. Two changes:

1. Sidebar '999 ACKs' nav item gets a warning-toned badge showing
   the total rejected segment count from aggregates.rejected_count
   (mirrors the Reconciliation unmatched badge pattern). One number,
   visible from any page.

2. Acks page table now sorts rejected rows to the top, then
   newest-id-first. All 5 rejections fit on page 1 since they're a
   tiny fraction of the total — no pagination needed.

New files:
- src/hooks/useAckStats.ts (lightweight aggregates-only fetch)
- src/hooks/useAckStats.test.tsx (2 tests)
- src/components/Sidebar.test.tsx (3 tests)

Pre-existing baseline: 10 frontend failures (api.test.ts exportBatch837,
tail-stream.test.ts acks/ta1_acks targeting, Inbox/InboxHeader copy).
Unchanged.
2026-07-02 10:59:46 -06:00
Nora aca9667ff8 fix: enable CYCLONE_SCHEDULER_AUTOSTART in docker-compose
The SFTP poller was off by default in the production container —
operators had to POST /api/admin/scheduler/start manually after every
restart, which they were not doing. As a result the inbound 999 / 277CA
queue fell 7+ days behind in early July 2026 (scheduler.poll_count=0
in the live container since deploy).

Mirrors the existing CYCLONE_BACKUP_AUTOSTART=1 entry.

Note: there is a separate bug where scheduler._tick_impl's alphabetical
list_inbound hangs after ~30s on mft.gainwelltechnologies.com (TCP +
auth + first SFTP channel works, but the full alphabetic scan times
out). The pull-inbound CLI's date-filtered path works fine — SP to
follow.
2026-07-02 10:49:04 -06:00
Nora 999a762c86 docs(plan): SP28 ack-claim auto-link implementation plan 2026-07-02 10:32:17 -06:00
Nora 53a8c77cb8 docs(spec): SP28 ack-claim auto-link design 2026-07-02 10:30:52 -06:00
Nora de8bc2e153 merge: SP25 ack live-tail into main
Wires the live-tail triplet (useTailStream + useMergedTail +
<TailStatusPill>) into the Acks page so 999 and TA1 acknowledgments
appear the moment the SFTP poller (or a manual upload) lands them in
the database — no more 'no tracking is being updated'.

Backend changes
---------------
* store/acks.py: add_ack / add_ta1_ack / add_277ca_ack now publish
  ack_received / ta1_ack_received / two77ca_ack_received from inside
  the store after commit. Failures don't roll back the row.
* store/ui.py: new home for to_ui_ack / to_ui_ta1_ack / to_ui_two77ca_ack
  so the live-tail payload matches the list endpoint shape byte-for-byte.
* api_routers/acks.py + api_routers/ta1_acks.py: new /api/acks/stream
  and /api/ta1-acks/stream NDJSON endpoints following the live-tail
  wire format used by /api/claims/stream, /api/remittances/stream,
  and /api/activity/stream (snapshot + snapshot_end + live events +
  heartbeat + clean disconnect).
* api.py: parse-999 / parse-ta1 / parse-277ca endpoints thread
  request.app.state.event_bus into the store on write.
* handlers/handle_*.py: dropped the event_bus kwarg; the publish
  path is now store-only, not handler-driven.

Frontend changes
----------------
* lib/tail-stream.ts: TailResource widened to include acks + ta1_acks.
* store/tail-store.ts: acks + ta1Acks slices, addAck + addTa1Ack
  setters, matching reset cases. evictOldest is generic over
  (K extends string|number, V) so both keyed-by-id flavors share it.
* hooks/useTailStream.ts: dispatch routes acks / ta1_acks items into
  the matching store slices.
* hooks/useMergedTail.ts: generic constraint widened to
  T extends { id: string | number }, dedup normalizes via String(id)
  so numeric ids compare correctly.
* pages/Acks.tsx: opens both streams + renders two <TailStatusPill>
  components (one in the 999 hero, one in the TA1 section).

Verification
------------
* Backend: 1191 pass, 1 pre-existing test pollution failure (also
  fails on main, passes in isolation, unrelated to SP25).
* Frontend: 535 pass, 10 pre-existing failures in Inbox / Upload /
  Receipt / InboxHeader tests (all verified to fail the same way on
  main).
* TypeScript: zero new SP25 errors. Pre-existing Upload / Inbox
  errors match main.

Per the SP-N increment flow: no squash, no rebase. The merge commit
is the record of the increment landing.
2026-07-02 09:30:49 -06:00
Nora 1363d36046 fix(sp25): typecheck fixups
Three small follow-ups after the live-tail triplet wired up:

  * evictOldest is now generic over (K extends string|number, V) so
    the addAck / addTa1Ack call sites don't need a type assertion.
  * Acks.test.tsx no longer spreads importOriginal() — TypeScript
    was choking on the inferred 'unknown' from the mock factory.
    Use the same ApiError / mock shape that Claims.test.tsx uses.
  * tail-stream.test.ts guards the optional gen.return() with
    so strict mode TS2722 stops complaining about possibly-undefined.
2026-07-02 09:23:44 -06:00
Nora 146cb6d17d feat(sp25): wire live-tail triplet into Acks page
The 999 register opens /api/acks/stream via useTailStream("acks"),
merges the snapshot + tail through useMergedTail("acks", ...) so
new rows appear without a manual refresh, and surfaces the
connection state via <TailStatusPill> in the hero. The TA1 section
gets the same triplet against /api/ta1-acks/stream.

Both pills sit in the page header so the operator can see at a
glance whether the live-tail connection is healthy, without having
to open the drawer or refresh the page. A stalled/error stream
shows a Reconnect button inline.

The Acks test mock adds useTailStream at the module level so the
page renders without opening a real fetch. New test asserts both
TailStatusPills mount in the page tree.
2026-07-02 09:12:19 -06:00
Nora 191bc935c3 feat(sp25): extend useMergedTail to handle acks + ta1_acks slices
The Acks page needs two merge hooks (one per ack flavor) and both
shapes use numeric database ids rather than the string ids that
Claim/Remittance/Activity use. The generic constraint widens to
`T extends { id: string | number }` and the dedup Set normalizes
to String(id) so a base item with id=2 and a tail item with id=2
collide correctly.

Adds two switch cases that mirror the claims/remittances keyed-by-id
pattern: iterate the order array, look up the matching value in the
dict, drop undefined holes defensively.

Two tests assert the new slices order by their order arrays and dedup
against base items.
2026-07-02 09:09:53 -06:00
Nora 029623f3a5 feat(sp25): extend useTailStream dispatch for acks + ta1_acks
The Acks page mounts two streams (useTailStream("acks") and
useTailStream("ta1_acks")) — the dispatcher now routes their item
events into the matching store slices (addAck / addTa1Ack) the same
way the claims / remittances / activity cases already do.

Two new tests assert that an acks item lands in the acks slice (not
in claims) and a ta1_acks item lands in the ta1Acks slice (not in
acks) — i.e. the routing is exclusive, so a payload keyed by id 7
won't bleed across the two ack tables.
2026-07-02 09:08:46 -06:00
Nora 1648c81425 feat(sp25): extend useTailStore with acks + ta1_acks slices
The Acks page needs the same live-tail triplet as Claims/Remittances/
Activity. Both Ack and Ta1Ack have stable numeric ids from the
database row, so they get the keyed-by-id treatment (first write
wins, FIFO-capped at TAIL_CAP) — same shape as the claims and
remittances slices, not the append-only activity array.

Adds addAck, addTa1Ack, ackOrder, ta1AckOrder, and the matching
reset cases for the new 'acks' / 'ta1_acks' TailResource kinds.

evictOldest is now generic over T so the same helper covers the
two new keyed-by-id slices without duplication.
2026-07-02 09:07:26 -06:00
Nora f1bee546f6 feat(sp25): extend TailResource to include acks + ta1_acks
The Acks page needs the same live-tail triplet (useTailStream +
useMergedTail + TailStatusPill) that Claims/Remittances/Activity
already use. The first step is widening the TailResource union so
streamTail("acks") and streamTail("ta1_acks") are valid and the
URL falls out as /api/acks/stream and /api/ta1-acks/stream.

Two new tests assert the URL targets the new backend endpoints.
2026-07-02 09:01:53 -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
Nora 4b22193c4a feat(sp25): move ack UI serializers to store/ui.py 2026-07-02 08:45:19 -06:00
cyclone d8c03fde3f docs(plan): plan for SP25 ack live-tail 2026-07-02 08:34:54 -06:00
cyclone 014e02ad42 docs(spec): design for SP25 ack live-tail 2026-07-02 08:30:39 -06:00
Nora 4360ef7209 merge: SP21 store split into main
Splits the 2,995-LOC backend/src/cyclone/store.py into a
cyclone/store/ subpackage (13 sibling modules + __init__.py
facade) with no public API change. Adds dashboard_kpis,
check_matched_pair_drift, _claim_state_str (SP27) into the
right homes, and surfaces 3 private helpers (_claim_status_from_validation,
_persist_835_remit, _remittance_835_row) from the facade for
existing test imports.

Spec: docs/superpowers/specs/2026-06-21-cyclone-store-split-design.md
Plan: docs/superpowers/plans/2026-06-29-cyclone-store-split-resume.md

Tests at baseline: 1 failed (pre-existing isolation flake),
1176 passed, 10 skipped.
2026-06-29 16:20:15 -06:00
Nora fe84ce7cf3 fix(sp21): drop dead code surfaced by code-quality review
- write.py: drop orphan run_reconcile() — no callers, and the
  pre-split store.py never had it. (The CycloneStore class already
  has _publish_events_sync + _sync_publish delegations; the spec's
  mention of _run_reconcile was a doc drift from the 2026-06-21
  plan, not a real requirement.)
- ui.py: drop duplicate _provider_orm_to_dict + _payer_orm_to_dict
  (canonical copies live in providers.py and are the only ones used;
  the ui.py copies were accidental duplicates from the split).
- orm_builders.py: drop orphan _cas_adjustment_row() — never called;
  _persist_835_remit() builds CAS rows inline.
- __init__.py: prune 30+ unused top-level imports and 9 unused
  re-exports. The facade only ever needs the type-hint-bearing
  BatchRecord family, the 4 read-path ORM-row serializers actually
  used inside the package, the 3 documented private helpers, and
  the 7 function-level re-exports the spec promises. Everything
  else was carryover from the original store.py header.

Tests at exact baseline: 1 failed, 1176 passed, 10 skipped
(the 1 failure is the pre-existing test_provider_detail isolation
flake documented in the spec).
2026-06-29 16:20:01 -06:00
Nora cfc95307e3 fix(sp21): remove orphan _ITER_UNBOUNDED from ui.py (now in claim_detail.py) 2026-06-29 16:04:42 -06:00
Tyler Martinez c8f8f5d3c6 refactor(sp21): split store.py into cyclone/store/ subpackage (14 modules)
Behaviour-preserving structural split of the 2,995-LOC store.py into
a 14-module subpackage with a thin facade. CycloneStore class keeps
its full method surface as 1-line delegations to module functions.

14 modules:
  exceptions, records, orm_builders, ui, write,
  batches, claim_detail, kpis, acks,
  backups, inbox, providers
  (+ __init__.py facade)

Facade re-exports 12 public symbols (CycloneStore, store, BatchRecord*,
BatchKind, AlreadyMatchedError/NotMatchedError/InvalidStateError, utcnow,
dashboard_kpis, check_matched_pair_drift) + 3 private helpers
(_claim_status_from_validation, _persist_835_remit, _remittance_835_row)
to preserve the 4 test files that import them.

Zero public API changes, zero test changes, zero importer changes.
CycloneStore._lock and CycloneStore._batches.clear() remain intact for
the 7 test files still using the cleanup idiom.

Verified: 1,176 / 1 (pre-existing isolation flake) / 10 tests pass —
identical to baseline.
2026-06-29 15:57:31 -06:00
Nora 9644db8c51 docs(plan): resume SP21 store split — 14 modules, function-name refs (no line numbers)
Fresh plan that supersedes the 2026-06-21 line-number-anchored plan.
Reflects the resumed state: store.py at 2,995 LOC, 14 target modules,
1,176/1/10 test baseline, 3 new symbols to extract (dashboard_kpis +
_claim_state_str → kpis.py; check_matched_pair_drift → claim_detail.py),
3 private-helper re-exports (added _persist_835_remit + _remittance_835_row
discovered during the audit).

Per-task code copies dropped in favor of function-name references +
read-store.py guidance — the line numbers in the original plan are
inherently stale against a 2,995-LOC file under active development.

The original 2026-06-21 plan is preserved on disk as historical record
of the design conversation.
2026-06-29 14:47:54 -06:00
Nora fbe7b2358d docs(spec): resume SP21 store split — 14 modules, 3 SP27 symbols, 3 private-helper re-exports
Update the 2026-06-21 approved spec with the post-SP27 reality:
store.py grew 2,412 → 2,995 LOC. Three new top-level symbols
(dashboard_kpis, _claim_state_str, check_matched_pair_drift) get
slotted into the new kpis.py and claim_detail.py. Three private
helpers (the original _claim_status_from_validation + the newly-
discovered _persist_835_remit and _remittance_835_row) are re-
exported from the facade to preserve the 4 test files that
import them. Branch bumped to sp21-store-split per SP-N convention.

All previously-locked Decisions are preserved verbatim.
2026-06-29 14:44:21 -06:00
Nora b96da8a5a4 merge: SP27 remittances architecture refactor into main
Atomic SP-N merge per cyclone-spec. Carries the remittances
architecture refactor (handlers/ extraction, unified 835 ingest +
reconcile, server-aggregated KPIs) plus two same-increment fixes
for bugs surfaced against the running stack:

  - fix(sp27): /api/claims + /api/remittances total counts the
    full population (previously page-local reduce)
  - feat(sp27): server-aggregate Dashboard KPIs so the 100-row
    sample doesn't lie
  - fix(sp27): Claim.patient_control_number populated from CLM01
    (claim_id), not 2010BA NM109 (member_id) — restores 837↔835
    auto-match via the existing reconcile.by_pcn join
  - feat(sp27): server-aggregate Remittances KPIs (count, paid,
    adjustments)

Includes migration 0017 (UPDATE claims SET patient_control_number
= id) which was applied to the live DB at ingest time.
2026-06-29 14:33:52 -06:00
Nora 14fcbca5f1 feat(sp27): server-aggregate Remittances KPIs (count, paid, adjustments)
The Remittances page's three KPI tiles — REMITS / TOTAL PAID /
ADJUSTMENTS — were computed page-locally via items.reduce(...) over
the merged tail of the current page + live delta. With a 100-row
default limit, a 1,739-row population showed count=100, paid=$16,934,
adjustments=$147 — silently understating reality because the page
hadn't loaded the remaining rows yet.

This change mirrors the silent-incompleteness fix that
/api/dashboard/kpis (commit 59c3275) and /api/remittances (commit
d81b6ed) made for their tiles:

  * CycloneStore.summarize_remittances() iterates the full filtered
    remittance population (no limit) and returns
    {count, total_paid, total_adjustments}. Mirrors iter_remittances
    with limit=_ITER_UNBOUNDED.
  * GET /api/remittances/summary — server endpoint with the same
    filter parameters as /api/remittances. Registered BEFORE the
    /api/remittances/stream handler so FastAPI doesn't treat
    'summary' as a stream sub-path.
  * api.listRemittanceSummary + useRemittanceSummary hook.
  * Remittances.tsx swaps off items.reduce, consumes the server
    summary. Tiles render the server totals so the values reflect
    the entire DB population, not the page-local sample.

Verified live: /api/remittances/summary returns
{count: 1739, total_paid: 227181.58, total_adjustments: 13792.65},
which matches DB ground truth exactly.

Tests: 8 new backend tests in test_api_remittances_summary.py; 1 new
frontend test in Remittances.test.tsx (kpi_tiles_use_server_summary_not
_page_local_reduce) plus the page-level test for the zero-valued mock
default.
2026-06-29 14:32:10 -06:00
Nora d8841834dc fix(sp27): Claim.patient_control_number populated from CLM01 (claim_id), not 2010BA NM109 (member_id)
The 837 ingest path (_claim_837_row in store.py:194) populated
Claim.patient_control_number from claim.subscriber.member_id — the
subscriber's 2010BA NM109 Medicaid ID — instead of from
claim.claim_id (CLM01, the claim submitter's identifier the 837
actually sent).

That silently broke every downstream join that uses this column as a
cross-reference key:

  * reconcile.match() (reconcile.py:74) — joins
    Claim.patient_control_number against
    Remittance.payer_claim_control_number (which is parsed from CLP01,
    the 835's echo of CLM01 per X12 spec). Member_id never matches
    CLP01, so auto-match always fails.
  * apply_999_rejections — same lookup, same broken key.
  * apply_277ca_rejections — same lookup, same broken key.
  * scoring.score_pair — same broken key.

Live DB probe (1,739 remits, 337 claims):
  * Claim.id == Remit.payer_claim_control_number    → 0 matches
  * Claim.patient_control_number == Remit.payer_claim_control_number
    → 9 matches (substring coincidences; synthetic PCN strings share
    alphanumeric characters with the human-readable member_ids)
  * Claim.matched_remittance_id NOT NULL             → 0 claims

This commit changes _claim_837_row to write
Claim.patient_control_number = claim.claim_id (= CLM01). The
reconcile matcher's existing join now hits the row the 835 echoes
back. Companion migration 0017 backfills the 337 pre-fix rows so
they're on equal footing with new ingests (UPDATE claims SET
patient_control_number = id WHERE patient_control_number IS DISTINCT
FROM id — idempotent).

Tests:
  * 2 new RED→GREEN tests in test_store_reconcile.py:
    - test_837_ingest_populates_patient_control_number_from_claim_id
      pins the field semantics directly
    - test_837_then_835_with_echoed_pcn_auto_pairs proves the full
      end-to-end auto-match now fires
  * 3 existing manual_match tests that relied on the bug (had setup
    using member_id != claim_id to deliberately prevent auto-match)
    updated to use distinct PCNs explicitly so the tests still
    exercise the manual-match path with a real orphan pair.
  * 3 store_claim_detail / api_gets tests adjusted for the same
    reason.

Verified: 1,176 / 1,177 backend tests pass; the one failure is the
pre-existing flake in test_provider_extended_response.py noted before
this work started.

Caveat for the existing dev DB: the 1,739 remits already in the DB
were ingested from 835 fixtures whose CLP01 is a different synthetic
identifier than the 837's CLM01 (the test fixtures never echoed
CLM01 — a fixture-data limitation, not a code bug). After this fix,
new 837+835 ingest pairs whose payer echoes CLM01 in CLP01 will
auto-match as expected. The pre-existing 1,739 remits will continue
to land in the unmatched bucket; that can only be fixed by
regenerating the test fixtures (out of scope for this SP).
2026-06-29 14:32:10 -06:00
Nora d81b6ed4fc fix(sp27): /api/claims + /api/remittances total counts the full population
Before this fix, the list endpoints computed `total` via
`len(list(store.iter_*(**common)))`. Both `iter_claims` and
`iter_remittances` default to `limit=100`, so the reported total
silently capped at 100 even when the DB held 60k claims or 835
remits. The frontend rendered a `data.total` of 100 in the KPI
tile and a 100-row table, the page looked complete, and the bug
stayed hidden — exactly the Dashboard silent-failure pattern that
59c3275 (server-aggregated KPIs) was meant to retire.

The Remittances page symptom reported on Jun 29 ('REMITS 100',
empty CLAIM column on 100 rows) was this bug. The empty CLAIM is
a separate matching concern (those remits have `claim_id = NULL`
in the DB); the count fix addresses the population-size lie.

Fix:
* `CycloneStore.count_claims` + `count_remittances` reuse the
  iter's filter pipeline (DB filters + in-memory payer/date
  filters) with an effectively-unbounded limit so the count
  reflects the true DB population.
* `list_claims` + `list_remittances` call the new count
  helpers instead of slicing `iter_*` twice.
* 13 new tests in `test_list_endpoint_counts.py` cover the
  empty/filter/per-dimension/cardinality cases plus the HTTP
  regression (seed 150/120, assert total matches).

Bonus fix:
* `conftest._reset_rate_limit_buckets` walks the middleware
  stack and clears `RateLimitMiddleware._buckets` between
  tests. Without this, the full suite tripped the 300 req/60s
  rate limiter mid-run and 9 later tests (4 of mine, 5
  pre-existing in test_inbox_endpoints / test_payer_summary)
  got 429s. All 1167 tests now pass.

Follow-ups noted but out of scope:
* `/api/admin/audit-log`, `/api/admin/backup/list`,
  `/api/admin/scheduler/processed-files` use the same
  `len(rows)`-after-`.limit(limit)` pattern. Admin-only,
  no `has_more` consumption, so impact is bounded.
* `count_*` could short-circuit to `func.count()` to skip
  the UI-dict build, but iter already calls `q.all()` so
  the win is modest.
2026-06-29 13:22:42 -06:00