- 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
- 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
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.