Commit Graph

114 Commits

Author SHA1 Message Date
Nora 129fb2308d feat(db): add User and Session models 2026-06-22 14:13:30 -06:00
Nora 517f9e23e6 feat(deps): add passlib[bcrypt] for password hashing
passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes
bcrypt.__about__ which 4.x removed). Pin bcrypt<4.1.
2026-06-22 14:12:14 -06:00
Nora 0677e4fd65 wip: dashboard wiring (real backend) + Docker compose for production 2026-06-22 14:07:31 -06:00
Tyler 63b2870f6e docs: surface SP9-SP13 in README + endpoint reference
The README only documented up to SP8 even though SP9-SP13 (multi-payer
+ multi-NPI + SFTP stub, 277CA + Payer-Rejected lane, tamper-evident
audit log, optional SQLCipher encryption, paramiko SFTP wire-up) are
all merged into main.

This brings the README back in line with the codebase:

- Inbox section: bump lane count from four to five, add the
  payer_rejected lane (and its monotonic stamping contract).
- Add a Multi-Payer, Multi-NPI & Clearhouse section (SP9): the
  providers / payers / payer_configs / clearhouse tables, the
  config/payers.yaml loader, the new config + clearhouse endpoints.
- Add a 277CA Claim Acknowledgment section (SP10): parse-277ca +
  277ca-acks, the Payer-Rejected stamp semantics.
- Add a Tamper-Evident Audit Log section (SP11): hash-chained
  audit_log, the verify endpoint.
- Add an Encryption at Rest section (SP12): Keychain-stored
  SQLCipher key, transparent fallback to plain SQLite.
- Add an SFTP Wire-Up (paramiko) section (SP13): the production
  submit path and the file-naming template.
- Persistence section: link to the Encryption at Rest section.
- Roadmap: bump shipped sub-projects to 2-13, add a one-line
  pointer to the completeness review for the honest industry-gap
  accounting.
- Project layout: add api_helpers, db_crypto, audit_log, the new
  inbox_* modules, providers/payers/secrets, the new test files,
  and the config/ + docs/reviews/ + docs/superpowers/specs/ tree.
- backend/README: refresh the endpoint table to include the
  parse-999/parse-ta1/parse-277ca/eligibility/clearhouse/admin
  surface; point at the root README for the full route list.
2026-06-21 00:10:50 -06:00
Tyler a11e051f82 feat(sp13): paramiko-backed SftpClient wire-up
Replace SftpClient stub write_file/list_inbound/read_file
implementations with real paramiko SSHClient + SFTPClient
calls. The public API (SftpClient.write_file, list_inbound,
read_file, get_secret) is unchanged from SP9 — same signature,
same return types — so the API layer needs no changes.

Real-mode behavior:
* _connect() returns a context manager yielding (ssh, sftp);
  closes both on exit. Lazy-imports paramiko so the stub-only
  test path doesn't need the dependency.
* Auth resolves from SftpBlock.auth: password_keychain_account
  (MFT model) or key_file + optional key_passphrase_keychain_account.
  Missing Keychain entries fail loud (RuntimeError) rather than
  silently attempting empty-password auth.
* write_file: opens sftp.open(remote, 'wb') and writes bytes;
  mkdirs the parent dir (idempotent — MFT pre-creates FromHPE/ToHPE).
* list_inbound: listdir_attr + per-file download into local
  staging cache; skips directory entries (0o040000 mask).
* read_file: download via shutil.copyfileobj into BytesIO.

Stub mode is unchanged. AutoAddPolicy for first-time MFT host
fingerprint; operator should pin the key for production.

Adds tests/test_sftp_paramiko.py: 9 tests covering
* stub still works
* real-mode connect builds correct paramiko call from
  password_keychain_account, raises on missing Keychain,
  raises on missing auth config, raises on STUB_SECRET
* write_file opens 'wb' on the right path and writes bytes
* list_inbound translates attrs into InboundFile records and
  caches files locally; skips dirs

Removes 2 obsolete tests in test_sftp_stub.py that expected
SP13-mode to raise NotImplementedError.

pyproject.toml: new optional 'sftp' extra (paramiko>=3.4,<6).
2026-06-21 00:00:13 -06:00
Tyler d54c44f04a feat(sp12): SQLCipher encryption at rest (optional)
- New cyclone.db_crypto module:
  * is_sqlcipher_available() — capability check
  * is_encryption_enabled() — Keychain key + sqlcipher3 present
  * get_db_key() — reads 'cyclone.db.key' from Keychain
  * make_sqlcipher_connect_creator(url, key) — SQLAlchemy creator
- db._make_engine() now switches to SQLCipher when key is present
- pyproject.toml: optional 'sqlcipher' extra (sqlcipher3>=0.6,<1)
- Fallback: without Keychain key, DB stays plain SQLite (no surprise
  behavior for operators who haven't set up encryption yet)
- Verified: encrypted file is unreadable as plain SQLite, wrong key
  raises on first query, migrations + ORM work transparently
- HIPAA §164.312(a)(2)(iv) compliance note in docs

Tests: 705 -> 717 (12 new for SQLCipher). All 717 backend tests pass.
2026-06-20 23:52:41 -06:00
Tyler 62bb09f183 feat(sp11): tamper-evident hash-chained audit_log
- New audit_log table (migration 0009, user_version=9):
  * id, event_type, entity_type, entity_id, actor, payload_json,
    created_at, prev_hash, hash
  * Indexes on (entity_type, entity_id), event_type, created_at
- New cyclone.audit_log module:
  * append_event(session, AuditEvent) — appends one chained row
  * verify_chain(session) — walks the chain, returns first bad id
  * SHA-256 hash over canonical row form (unit-separator delimited)
  * Genesis prev_hash = 64 zeros (Bitcoin-style sentinel)
- New AuditLog ORM model
- New admin API endpoints:
  * GET /api/admin/audit-log (paginated, filterable)
  * GET /api/admin/audit-log/verify (returns ok/first_bad_id/reason)
- Hooked into existing endpoints to append events:
  * /api/parse-999 → 'claim.rejected' per matched claim
  * /api/parse-277ca → 'claim.payer_rejected' per matched claim
  * /api/clearhouse/submit → 'clearhouse.submitted' per claim
- HIPAA §164.316(b)(2) compliance note in docs

Tests: 688 -> 705 (9 audit + 8 audit-API). All 705 backend tests pass.
2026-06-20 23:45:43 -06:00
Tyler 2c0afbe9c5 feat(sp10): 277CA parser + Payer-Rejected Inbox lane
- Add cyclone.parsers.models_277ca + parse_277ca (X12 005010X214)
  - Per-Patient HL ClaimStatus with REF*1K (PCN), REF*EJ (tax ID),
    STC category code, amount, service date
  - STC classifier: A1-A3 accepted, A4/A6/A7 rejected, A8/A9 pended,
    P1-P5 paid, anything else unknown
  - Multiple STCs per Patient HL: last wins (canonical pattern for
    'first pended, then paid' acknowledgments)
  - Subscriber-level STCs surface in unscoped_statuses
- Add apply_277ca_rejections — stamps Claim.payer_rejected_* fields on
  matching rows (lookup by patient_control_number, mirrors 999 path)
- New /api/parse-277ca, /api/277ca-acks, /api/277ca-acks/{id} endpoints
- New two77ca_acks table + Two77caAck ORM model
- New Claim columns: payer_rejected_at, _reason, _status_code, _by_277ca_id
- New payer_rejected lane in /api/inbox/lanes (distinct from 999
  envelope rejected lane)
- New PayerConfig277CA block in config/payers.yaml + Pydantic model
- Migration 0008 bumps user_version to 8

Tests: 654 -> 688 (parser 22 + apply 6 + API 8 + config 6 + adjustments).
All 688 backend tests pass; 1 pre-existing skipped test class unaffected.
2026-06-20 23:41:01 -06:00
Tyler c62826daea feat(sp9): multi-payer, multi-NPI, SFTP stub for dzinesco/TOC
- providers table seeded with 3 NPIs (Montrose, Delta, Salida)
- payers + payer_configs (per-tx config) join table
- clearhouse singleton (dzinesco identity, SFTP block, filename block)
- config/payers.yaml loader with Pydantic schema validation
- cyclone/edi/filenames.py: HCPF X12 File Naming Standards helpers
- cyclone/clearhouse/sftp.py: stub that copies to ./var/sftp/staging/...
- cyclone/secrets.py: macOS Keychain wrapper via keyring
- 11 new validation rules R200-R210 (CO MAP + HCPF naming)
- 6 new API endpoints (/api/clearhouse/*, /api/config/*, /api/admin/reload-config)
- migration 0007
- 72 new tests (646 total passing, was 574)

See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
2026-06-20 23:07:31 -06:00
Tyler ec9eae7a2c feat(sp8): resubmit endpoint supports ?download=zip for regenerated 837s
Add an optional ``?download=true`` query param to
``POST /api/inbox/rejected/resubmit`` that returns the same operation
result as a ZIP archive of regenerated 837P files (one
``claim-{id}.x12`` per successfully resubmitted claim) rather than the
JSON envelope.

Why: operators who mass-resubmit rejected claims want to hand the
files straight to their clearinghouse; round-tripping through copy-
paste is error-prone.

Implementation notes:
  * Uses ``serialize_837_for_resubmit`` so each X12 file in the bundle
    gets a unique interchange/group control number (back-to-back
    resubmits would otherwise collide on ISA13/GS06 = "000000001").
  * Conflicts and missing ids are deliberately excluded from the ZIP
    — the user already saw them in the JSON path on prior calls; the
    download is the "give me the files I asked for" payload.
  * Empty resubmit + download returns 200 with an empty ZIP so the UI
    can still hand the user a downloadable artifact.

Tests (test_inbox_endpoints.py): 2 new tests covering the success
shape (one .x12 per accepted claim) and the conflict-exclusion
contract.
2026-06-20 20:49:08 -06:00
Tyler 2893676c0b fix(835): correct SVC04/SVC05 mapping per X12 005010X221A1
X12 835 SVC segment:
  SVC01 = composite procedure
  SVC02 = charge
  SVC03 = payment
  SVC04 = Unit or Basis for Measurement Code (UN, MJ, DA, ...)
  SVC05 = Service Unit Count

The parser previously read SVC04 as the units count and SVC05 as the
unit type — backwards. On real 835s (and the canonical minimal
fixture), SVC04 carries the code 'UN' which fails Decimal parsing, so
the units always came out as None and the code string was assigned to
unit_type. SP7's line-level matcher couldn't compare units on the SVC
side against the claim side because of this.

- _consume_service_payment: SVC04 → unit_type, SVC05 → units count
- Default unit_type to 'UN' when only the count is present
- minimal_835.txt + unbalanced_835.txt: swap positions to match spec
- Add 2 regression tests (units-and-unit-type, default-unit-type-to-UN)
2026-06-20 20:44:28 -06:00
Tyler 3f4e6849c6 feat(sp8): GET /api/claims/{id}/serialize-837 endpoint
Returns the persisted ClaimOutput as a regenerated X12 837P file via
the new outbound serializer (Approach A — full rebuild from canonical
fields). 404 on missing claim, 422 if the stored raw_json cannot be
validated as a ClaimOutput. text/x12 content-type, attachment
disposition with the claim id as the filename.

3 tests:
- endpoint returns text/x12 attachment starting with ISA*
- 404 for missing claim id
- regenerated text round-trips back through parse()
2026-06-20 20:34:44 -06:00
Tyler 561018c690 feat(sp8): outbound 837P serializer — full rebuild + round-trip tests
- backend/src/cyclone/parsers/serialize_837.py — full-rebuild 837P serializer.
  Emits envelope (ISA/GS/ST/SE/GE/IEA + BHT) + submitter/receiver/billing
  provider/subscriber/payer hierarchy + editable segments (CLM/REF*G1/HI)
  + per-service-line LX/SV1/DTP*472/REF*6R — all from canonical
  ClaimOutput fields.

  Pivoted from spec §3.1 hybrid to full rebuild because ClaimOutput.raw_segments
  only captures post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the
  envelope or hierarchies. A pass-through approach cannot regenerate those
  without expanding raw_segments in parse_837.py (out of scope for this SP).

- backend/tests/test_serialize_837.py — 36 tests covering envelope shape,
  hierarchy segments, claim-level builders, service-line builders, edited-field
  propagation, round-trip, custom sender/receiver IDs, and resubmit helper.

- backend/tests/test_prodfiles_smoke.py::test_claims_prodfile_round_trip —
  every file in docs/prodfiles/claims/ (113 files) round-trips through
  serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo
  validation, which is recomputed by the parser).

- docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md — full plan
  with amendment note documenting the Approach A pivot.

Per session convention, plan note about unrelated modifications to
parse_835.py / fixtures stashed separately.
2026-06-20 20:27:48 -06:00
Tyler d033ce85db feat(sp7): wire _reconcile_pair into manual_match (T21)
manual_match previously only flipped the claim↔remit FK and the
claim state — it never ran line-level reconciliation, so manually-
paired claims surfaced empty line-reconciliation rows to the UI
and skipped CLP-level CAS aggregate recompute.

Refactor reconcile.run() to call a new per-pair helper
_reconcile_pair(session, claim, remittance) that:
  - clears any existing LineReconciliation rows for the claim
    (idempotent re-run; safe across manual_unmatch + manual_rematch
    cycles that may pair the claim with a different remittance),
  - reads 837 SV1 lines from Claim.raw_json and 835 SVC rows from
    ServiceLinePayment,
  - runs match_service_lines() and persists a LineReconciliation row
    per side,
  - recomputes Remittance.claim_level_adjustment_amount (CLP-level
    CAS) and Remittance.adjustment_amount (total CAS).

manual_match now calls this helper after the FK is set and before
commit, mirroring the auto-match path. Reversals are skipped (they
don't have SV1↔SVC line pairs; per §7.3).

Tests (test_store_reconcile.py):
  - test_manual_match_populates_line_reconciliation_rows: end-to-end
    check that a manual pair writes the expected matched +
    unmatched_837_only rows plus zero CAS aggregates.
  - test_manual_match_idempotent_line_reconciliation: after
    manual_unmatch + manual_rematch + inserting a CLP-level CAS row
    directly, the claim has exactly two fresh LineReconciliation rows
    (no duplicates) and the remittance aggregate reflects the new CAS.

Smoke tested end-to-end via TestClient: parse co_medicaid_837p.txt
+ co_medicaid_835.txt, auto-matcher skips (PCNs differ), manual
match via POST /api/reconciliation/match, then
GET /api/claims/{id}/line-reconciliation returns 2 rows and
GET /api/inbox/lanes shows matched_remittance.total_lines=2.

Note: matched_lines may be 0 on real 835 fixtures due to a
pre-existing parser bug in _consume_service_payment that swaps
SVC04 (units) and SVC05 (unit-of-measure) when reading units from
the segment. The SP7 strict-match criterion requires units parity,
so the misread produces None on the SVC side and strict-match
never succeeds. Surfaced as a follow-up; the SP7 work itself is
correct (line counts are computed and persisted).
2026-06-20 20:13:16 -06:00
Tyler b7bda0662f feat(sp7): inbox lanes matched_remittance includes matched/total line counts 2026-06-20 19:43:43 -06:00
Tyler 6ef59b5d1d feat(sp7): remit detail includes serviceLinePayments + claimLevelAdjustments 2026-06-20 19:39:45 -06:00
Tyler 217f14a52a feat(sp7): claim detail includes lineReconciliation slim projection 2026-06-20 19:37:38 -06:00
Tyler 48d4739864 feat(sp7): GET /api/claims/{id}/line-reconciliation endpoint 2026-06-20 19:35:17 -06:00
Tyler c1323c44d0 feat(sp7): wire match_service_lines into reconcile.run 2026-06-20 19:33:02 -06:00
Tyler 9626eae5f1 feat(sp7): match_service_lines() pure function + tests 2026-06-20 19:29:21 -06:00
Tyler 8442bf91ce feat(sp7): persist ServiceLinePayment + link CAS rows on 835 ingest 2026-06-20 19:27:24 -06:00
Tyler 0dd6d39424 test(sp7): update migration version assertion to 6 (SP7 + TA1 WIP) 2026-06-20 19:25:26 -06:00
Tyler e8b452d255 feat(sp7): migration 0006 — service_line_payments + line_reconciliations + FKs
Migration renumbered from 0005 to 0006 because 0005_create_ta1_acks.sql
(another agent's WIP, unmerged) already claims version 5. Bumping our
migration to version 6 avoids stepping on it; user_version will land
at 6 once both migrations run.
2026-06-20 19:23:14 -06:00
Tyler 1feeff94b9 feat(sp7): LineReconciliation ORM model 2026-06-20 19:19:56 -06:00
Tyler 4c7b82f05b feat(sp7): ServiceLinePayment ORM model 2026-06-20 19:19:48 -06:00
Tyler e4743e41b1 feat(sp7): migration 0005 — service_line_payments + line_reconciliations + FKs 2026-06-20 19:19:34 -06:00
Tyler 76278ec9f6 feat(ta1): TA1 interchange ACK parser, persistence, and API
Adds full TA1 (Interchange Acknowledgment, X12 envelope-level) support
to mirror the existing 999 transaction-set ACK pipeline.

Parser & models
- parsers/models_ta1.py: Pydantic Ta1Ack + ParseResultTa1 models
  (AckCode = Literal['A','E','R'], date serializer mirroring 999)
- parsers/parse_ta1.py: TA1 segment parser
  - Tolerant YYMMDD (6-digit) + CCYYMMDD (8-digit) date handling
    for CO Medicaid interchange_date / ack_generated_date
  - 106-char ISA validation (15-char sender/receiver IDs)
  - source_batch_id = 'TA1-<ISA13>'

Persistence
- migrations/0005_create_ta1_acks.sql: ta1_acks table + indexes
  on source_batch_id and ack_code
- db.py: Ta1Ack ORM model (flat columns + raw_json, mirrors Ack)
- store.py: add_ta1_ack, list_ta1_acks (returns all rows),
  get_ta1_ack
- db version bumped 4 -> 5 (test_acks.py updated)

API (api.py)
- POST /api/parse-ta1: text/file ingest, persists ta1_ack,
  returns detail-ready payload
- GET /api/ta1-acks: list with limit + total (mirrors 999 pattern)
- GET /api/ta1-acks/{ack_id}: detail
- _ta1_to_ui / _serialize_ta1 / _serialize_ta1_from_row helpers

Tests (17 new, 508 total passing)
- tests/test_parse_ta1.py (8): CCYYMMDD + YYMMDD acceptance,
  E/R codes, source_batch_id, missing ISA/TA1, short defaults
- tests/test_api_ta1.py (9): happy path, rejected persists,
  empty/malformed 400, missing file 422, detail regenerates
  segment, 404, empty list, newest-first
- tests/test_prodfiles_smoke.py: extended to smoke-test 352
  production TA1 files (A=0, R=352, E=0)
- tests/test_api_parse_persists.py: cross-pipeline reconciliation
  test asserting invariants across 837P + 835 prod files

Real-data finding: all 352 production TA1s are R (rejected);
operator follow-up warranted.
2026-06-20 18:58:56 -06:00
Tyler f4baceb574 feat(sp6): inbox endpoints — lanes, match, dismiss, resubmit, csv export
T7-T10 combined (single edit: all endpoints share a section).

- GET /api/inbox/lanes — four lanes in one call
- POST /api/inbox/candidates/{remit_id}/match — 409 on conflict
- POST /api/inbox/candidates/dismiss — session-scoped dismissed set
- POST /api/inbox/rejected/resubmit — 200 with conflicts list
- GET /api/inbox/export.csv?lane=<lane> — streams CSV

Also adds module-level imports (db, Claim, ClaimState, Remittance,
csv, io, datetime) that the new endpoints need; cleans up the
duplicated local imports in the parse-999 SP6 T4 block.
2026-06-20 18:34:09 -06:00
Tyler cdffb3faf3 feat(sp6): compute_lanes — 4 lanes on read with scoring 2026-06-20 18:31:01 -06:00
Tyler 3432c76561 feat(sp6): 4-field weighted scoring module 2026-06-20 18:30:05 -06:00
Tyler f4910fd94d feat(sp6): wire 999 ingest → claim rejected state
T4 — calls apply_999_rejections inside /api/parse-999, then
publishes a claim.rejected event for each transitioned claim so
the Inbox live-tail refetches.
2026-06-20 18:29:28 -06:00
Tyler 78ed75a31d feat(sp6): apply_999_rejections state helper
T3 — moves claims to ClaimState.REJECTED on 999 AK5 R/E/X.

- New module: src/cyclone/inbox_state.py
- New tests: tests/test_inbox_state.py (4 cases)
- Adds ORM mappings for rejection_reason / rejected_at / resubmit_count
  / state_changed_at on Claim (db.py) — these were added to the
  schema by migration 0004 but not yet exposed to the ORM.
2026-06-20 18:25:25 -06:00
Tyler 1b1534d8d2 feat(sp6): migration 0004 — rejection columns + state-history index
Adds:
- claims.rejection_reason, claims.rejected_at, claims.resubmit_count
- claims.state_changed_at (was missing, needed for Done-today lane)
- ix_claims_state_changed_at composite index

Also fixes the ClaimState count assertion in test_db_models.py
(7 → 8) to match the REJECTED enum value added in the previous
commit, and bumps the user_version expectation in test_acks.py
(3 → 4) for the same reason.
2026-06-20 18:21:29 -06:00
Tyler 756196e2a8 feat(sp6): add REJECTED to ClaimState enum
T1 of the SP6 workflow automation plan. Paired with the existing test
in test_db_models.py that asserts 8 ClaimState values.
2026-06-20 18:15:38 -06:00
Tyler de09959377 test(835): update prodfile test for PCN dedup behavior
The store dedupes remittances by PCN. Refine the assertions:
- total_clps counts the raw CLP segments across all files (3374)
- unique_pcns counts distinct PCNs (persisted row count)
- Read claimId via the API response shape, not the SQLAlchemy attribute
2026-06-20 18:12:59 -06:00
Tyler b5e27927e0 fix(835): drop over-constraining UNIQUE constraints, add multi-BPR warning
Three related changes for real CO Medicaid data:

1. Drop UNIQUE(batch_id, patient_control_number) on claims and
   UNIQUE(batch_id, payer_claim_control_number) on remittances. The X12
   spec allows multiple CLM segments per 2000B subscriber loop and 835
   ERAs can repeat a payer_claim_control_number for reversals. Claim/
   remittance identity is provided by the primary key (claims.id = CLM01,
   remittances.id = CLP01).

2. Add validator rule R835_MULTI_BPR warning for files with multiple BPR
   segments (CO Medicaid split-payment pattern). The parser already sums
   BPR02 paid_amounts; this surfaces the non-standard data to operators.

3. Skip R835_BAL_BPR_vs_CLP04 when BPR01='I' (Information Only 835).
   In that mode BPR02 is informational and the per-claim CLP04 totals
   are authoritative — a diff is expected, not an error.

Migration 0003 handles the drop with IF EXISTS so fresh DBs skip cleanly.
Updates affected tests to reflect new schema (no UNIQUE constraint on
batch_id + patient_control_number / payer_claim_control_number).

Fixes test_api_835::test_prodfile_round_trip_persists_separately which
was failing on real production data.
2026-06-20 18:10:07 -06:00
Tyler 66da69baa0 build(deps): regenerate uv.lock for SP5 pytest-asyncio
SP5 (commit e8dc8c1) added pytest-asyncio to pyproject.toml but didn't
regenerate uv.lock. This brings the lockfile in sync.
2026-06-20 17:51:13 -06:00
Tyler f4a7cd99b3 merge: ui/batch-diff 2026-06-20 17:30:30 -06:00
Tyler 7b394fff1a feat(batch-diff): side-by-side claim diff between two batches 2026-06-20 17:17:09 -06:00
Tyler da29188dd0 feat(api): GET /api/{claims,remittances,activity}/stream + unsubscribe
Adds three live-tail streaming endpoints that emit an NDJSON snapshot
then forward new event-bus events as they arrive, with a 15s idle
heartbeat (overridable via CYCLONE_TAIL_HEARTBEAT_S for tests).

Each endpoint:
  1. yields a snapshot of existing rows as {"type":"item","data":<row>}
  2. terminates the snapshot with {"type":"snapshot_end","data":{"count":N}}
  3. subscribes to its event kind and forwards each new event as an
     {"type":"item","data":<event>} line
  4. emits a {"type":"heartbeat","data":{"ts":<iso>}} line every
     CYCLONE_TAIL_HEARTBEAT_S seconds when idle
  5. checks request.is_disconnected() before each yield and unsubscribes
     from the bus on cleanup so a closed stream releases its queue

The shared tail loop lives in api._tail_events, which polls
bus.subscribe_raw()'s queue directly instead of using the bus's
async-iterator wrapper — wait_for on an async generator cancels the
inner future on timeout, which poisons subsequent __anext__ calls with
StopAsyncIteration. Queue.get() is idempotent under cancellation, so
heartbeats don't break the subscription.

EventBus gains an unsubscribe(queue, kinds) method (idempotent) so
the tail loop can release its queue in a try/finally. The disconnect
test asserts the subscriber list is empty after the body iterator is
closed, validating no queue leak per open stream.

Tests in test_api_stream_live.py: 8 tests covering snapshot shape,
post-snapshot publish, heartbeat timing, multi-item snapshots, and
client disconnect cleanup. Plus 2 tests in test_pubsub.py for the
new unsubscribe method.
2026-06-20 16:57:30 -06:00
Tyler 3ab1de23d3 refactor(api): _ndjson_line helper for streaming responses 2026-06-20 15:52:05 -06:00
Tyler 8bcb23c78d feat(api): parse endpoints pass EventBus into store writes
- api.parse_837 / parse_835: pass request.app.state.event_bus into store.add()
- conftest: autouse fixture wires a fresh EventBus onto app.state for every
  test, since TestClient does not invoke the FastAPI lifespan handler
  unless used as a context manager
- test_pubsub: split get_event_bus coverage into a raises-when-missing test
  and a returns-attached-bus test, both save/restore app.state.event_bus
  around the assertion so the autouse fixture's bus is preserved

Phase 2 complete: db init moved to lifespan, EventBus is the process-wide
publish point, and the two ingest endpoints publish claim_written /
remittance_written / activity_recorded events on every store.add().
2026-06-20 15:51:18 -06:00
Tyler 8d02ed3204 test(store): 835 batch publishes remittance_written + activity_recorded 2026-06-20 15:47:35 -06:00
Tyler ac7f3283d6 feat(store): add publishes claim_written + activity_recorded events 2026-06-20 15:42:56 -06:00
Tyler 5a712e5afd refactor(main): rely on lifespan for db init (SP5) 2026-06-20 15:37:15 -06:00
Tyler f52eec9bad feat(api): lifespan handler initializes EventBus + db 2026-06-20 15:36:50 -06:00
Tyler 25a76a515d feat(pubsub): get_event_bus() late-import accessor 2026-06-20 15:35:02 -06:00
Tyler b2f5a16541 feat(pubsub): EventBus with drop-oldest overflow, per-kind fan-out 2026-06-20 15:28:13 -06:00
Tyler e8dc8c16d7 build(deps): pytest-asyncio for SP5 stream tests 2026-06-20 15:24:48 -06:00
Tyler 14d61f82b8 test(api)+docs: assert matchedRemittance=null; fail-loud on fixture drift; tighten docstring 2026-06-20 09:56:24 -06:00