Extracts the 3 /api/remittances endpoints from api.py into
cyclone.api_routers.remittances:
GET /api/remittances (paginated list with filters)
GET /api/remittances/stream (NDJSON live-tail)
GET /api/remittances/{remit_id} (detail with CAS adjustments)
Mirror of the claims router for the 835 / Remittance resource.
No private helpers — every endpoint is a thin wrapper over
store.iter_remittances() / store.get_remittance().
remittances_stream is re-exported at the cyclone.api module level
so test_api_stream_live.py's direct import keeps working.
Route ordering preserved: /stream is declared before /{remit_id} in
the router source so FastAPI's first-match routing doesn't swallow
the literal 'stream' segment as a remittance id.
api.py: 1841 -> 1753 (-88). Net diff: +170 / -96.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
11/11 remittance-targeted tests pass.
Live smoke: all 3 routes return expected status codes (200 for
list/stream, 404 for missing-remit detail).
Extracts the 5 /api/claims endpoints from api.py into
cyclone.api_routers.claims:
GET /api/claims (paginated list)
GET /api/claims/stream (NDJSON live-tail)
GET /api/claims/{claim_id} (drawer detail)
GET /api/claims/{claim_id}/serialize-837 (X12 regen)
GET /api/claims/{claim_id}/line-reconciliation (per-line view)
Plus the two private projection helpers _claim_line_dict and
_svc_to_dict which are only used by line-reconciliation.
claims_stream is re-exported at the cyclone.api module level so
test_api_stream_live.py's direct import keeps working (same pattern
as the activity_stream re-export).
Route ordering preserved: /stream is declared before /{claim_id} in
the router source so FastAPI's first-match routing doesn't swallow
the literal 'stream' segment as a claim id.
api.py: 2201 -> 1841 (-360). Net diff: +432 / -389.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
57/57 claims-targeted tests pass.
Live smoke: all 5 routes return expected status codes (200 for
list/stream, 404 for missing-claim detail/serialize/line-recon).
Extracts GET /api/batches and GET /api/batches/{batch_id} from api.py
into cyclone.api_routers.batches. The _batch_summary_claim_count
helper moves along (only used by list_batches).
api.py: 2257 -> 2201 (-56). Net diff: +93 / -64.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
Live smoke: /api/batches 200 JSON; /api/batches/{id} 404 on missing;
NDJSON form returns the expected summary envelope.
Extracts GET /api/activity and GET /api/activity/stream from api.py
into cyclone.api_routers.activity. The activity router is small and
self-contained: store.recent_activity() for the snapshot half,
tail_events() (from api_helpers) for the live tail.
activity_stream is re-exported at the cyclone.api module level so
test_api_stream_live.py's direct import keeps working.
api.py: 2310 -> 2257 (-53). Net diff: +110 / -61.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
Live smoke: /api/activity 200 JSON; /api/activity/stream 200
application/x-ndjson with the expected snapshot lines.
Self-review nits from Checkpoint 2b:
- All three new router files lacked a trailing newline (same nit as 2a).
- clearhouse.py had lazy imports of serialize_837 / parse /
db as _db inside helper bodies. Hoisted serialize_837 and
parse to module-level (no import cycle risk: clearhouse.py does
not import from cyclone.api). Dropped the redundant db as _db
alias — the top-level from cyclone import db is already in scope.
No behavior change. test_clearhouse_api: 10 / 10 passing.
Checkpoint 2b. Extracts 7 more endpoints (1 + 2 + 4) into three
new routers:
- providers.py: GET /api/providers (list distinct providers from
claim rows; npi/state filter; NDJSON or paginated JSON).
- clearhouse.py: GET /api/clearhouse + POST /api/clearhouse/submit.
Carries the two serialize-from-raw helpers (_serialize_claim_for_submit,
_serialize_claim_from_raw) since they're only used here.
- config.py: GET /api/config/providers + GET /api/config/providers/{npi}
+ GET /api/config/payers + GET /api/config/payers/{payer_id}/configs.
Payer configs endpoint merges YAML-loaded blocks with any DB-overridden
live blocks per payer.
api.py shrank from 2474 to 2310 lines (-164) and no longer has any
single resource's full request/response shape — every remaining route
now lives in a dedicated router module.
Verifies:
- Full pytest: 8 failed / 735 passed / 16 skipped — identical to
clean main baseline (the 8 are pre-existing env/secret/sqlcipher).
- Live smoke: /api/health, /api/providers, /api/clearhouse,
/api/config/payers, /api/config/payers/co_medicaid/configs all 200.
/api/config/providers/{npi-not-seeded} returns 404 as expected.
See /tmp/refactor-cyclone.md for the full plan and progress.
Self-review nits from the router-split commit:
- All four new files lacked a trailing newline (PEP 8 / POSIX).
- acks.py was lazily importing ParseResult999 / serialize_999 inside
get_ack_endpoint. Hoist to module-level — there's no import cycle
(acks.py does not import from cyclone.api), so the imports are safe
to do once.
No behavior change. Targeted tests (test_acks + test_health + test_api_gets)
still pass 41/41.
Step 2 (first half) of the architecture satisfaction loop. api.py
shrank from 2595 to 2452 lines (-143) by extracting three read-only
resource groups into cyclone.api_routers:
- health.py: GET /api/health (1 endpoint)
- acks.py: GET /api/acks, GET /api/acks/{ack_id} (2 endpoints)
- ta1_acks.py: GET /api/ta1-acks, GET /api/ta1-acks/{ack_id} (2 endpoints)
Each router owns its endpoint bodies + the small UI-shape helper that
goes with them (_ack_to_ui, _ta1_to_ui, _serialize_ta1_from_row). The
helpers stay in the router file rather than being shared because each
is only used by its own endpoints.
api.py now ends the app-wiring section with three include_router()
calls. The new package is named cyclone.api_routers (not
cyclone.api.routers) to avoid the Python package-vs-same-named-module
ambiguity that would shadow the existing cyclone.api module.
Verifies: 41 targeted tests (test_acks, test_health, test_api_gets)
pass, full pytest is 8 failed / 735 passed / 16 skipped — identical
to clean main baseline. Live curl against the running server:
GET /api/health -> 200, GET /api/acks -> 200, GET /api/ta1-acks -> 200.
See /tmp/refactor-cyclone.md for the full plan.
Adds in-place key rotation for the encrypted DB at rest (HIPAA
sec.164.308(a)(4) - periodic key rotation).
- db_crypto.rotate_db_key(): opens with old key, issues PRAGMA rekey,
reopens with new key, verifies schema survived (table-count sanity).
- db_crypto.generate_db_key(): fresh 256-bit CSPRNG hex key.
- db_crypto.fingerprint(): SHA-256[:8] of a key, for the operator to
compare across rotations.
- db.dispose_engine() + db.reinit_engine(): SP15 plumbing. The
rotation endpoint disposes the pooled connections (SQLCipher
refuses to rekey while another connection holds the file), runs
the rekey, then rebuilds the engine with the new key from the
Keychain.
- API: POST /api/admin/db/rotate-key with module-level threading.Lock
to serialize rotations. 400 when encryption not enabled, 409 when
a rotation is already in flight, 503 on rekey or Keychain failure
with a reason that tells the operator what to do next.
- Engine uses NullPool when SQLCipher is enabled: the default
QueuePool returns connections to a shared queue that any thread
can pull from, which breaks SQLCipher's thread affinity. NullPool
trades connection reuse for thread safety, the only correct
behavior under FastAPI's per-request threadpool.
- Audit event db.key_rotated with old/new fingerprints and
table_count, written after the engine is rebuilt so the new key
proves it can take new writes.
- previous key is stashed to a second Keychain account so the
operator can roll back if the new key turns out to be broken.
Tests:
- test_db_crypto.py: 12 new tests for generate/fingerprint/rekey
mechanics (5 require SQLCipher at runtime; skipped otherwise).
- test_api_rotate_key.py: 6 new tests for endpoint wiring
(encryption-required, Keychain update, audit event, rekey-failure
rollback, Keychain-write-failure 503, concurrent-rotation 409).
First checkpoint of the architecture satisfaction loop. Cyclone's api.py
was a 2281-line god-module with 14 cross-cutting helpers inlined next
to the @app route declarations. This commit moves them to a dedicated
cyclone.api_helpers module:
- NDJSON wire format: ndjson_line, ndjson_stream_837, ndjson_stream_835,
ndjson_stream_list.
- Content negotiation: client_wants_json, wants_ndjson.
- Strict / raw_segments rewrites: strict_rewrite_837, strict_rewrite_835,
drop_raw_segments_837, drop_raw_segments_835.
- Validation probes: has_claim_validation_errors, has_835_validation_errors.
- Live-tail generator: tail_events, heartbeat_seconds, utcnow.
api.py re-imports them under the original underscore-prefixed names so
every route call site stays unchanged. claims_stream, remittances_stream,
and activity_stream remain exposed at cyclone.api (test_api_stream_live
imports them directly).
Verifies byte-identical NDJSON wire format, content negotiation rules,
and the tail_events async-generator semantics (deliberately polls the
EventBus queue rather than awaiting its async iterator, so heartbeats
don't poison the bus subscription).
Live-tested: GET /api/health, /api/claims, /api/remittances,
/api/activity, /api/acks, /api/providers, /api/inbox/lanes,
/api/inbox/payer-rejected/acknowledge, and the /api/claims/stream
NDJSON tail all return expected codes / payload.
Backend pytest: 29 failures identical to baseline (pre-existing
secrets env, serialize_837, db_crypto env, prodfile env failures),
700 passed. Frontend npm test: 357/357 passing.
See /tmp/refactor-cyclone.md for the full checkpoint log and the plan
for the next step (splitting api.py routes into FastAPI APIRouters).
Autoreview: /tmp/grok-review-local.md (0 bugs, 1 suggestion, 4 nits —
all addressed: dead asyncio/os imports removed, dead Any import
removed, duplicate utcnow import dropped, trailing newline added).
Closes the gap between the SP10 backend (5 lanes) and the SP6
frontend (4 lanes). The Payer-Rejected lane (277CA STC A4/A6/A7)
is now rendered alongside Rejected/Candidates/Unmatched/Done,
with an Acknowledge bulk action that drops claims from the
working surface without erasing the original 277CA rejection
event (audit log stays intact, SP11).
Backend:
* Migration 0010: add payer_rejected_acknowledged_at +
payer_rejected_acknowledged_actor columns + partial index.
* db.py: surface the two new columns on the Claim model.
* inbox_lanes.py: filter acknowledged claims out of the
payer_rejected lane; expose the new fields on the row payload
for forward-compat (e.g. a future 'Recently acknowledged' view).
* api.py:
- POST /api/inbox/payer-rejected/acknowledge
Bulk-acknowledge. Idempotent. Returns transitioned /
already_acked / not_found / not_rejected counts so the UI
can show '3 of 5 were already acknowledged' on a noop bulk.
Writes a 'claim.payer_rejected_acknowledged' event to the
SP11 hash-chained audit log.
- GET /api/inbox/export.csv: accept 'payer_rejected' lane.
* test_acks.py: bump user_version assertion to 10.
* test_lane_filter_acknowledged.py: 4 tests for the lane filter
and forward-compat row payload.
* test_payer_rejected_acknowledge.py: 6 tests for the endpoint
(happy path, idempotency, no-op on non-rejected, missing
ids, 400 on empty, audit-log wiring + chain integrity).
Frontend:
* lib/inbox-api.ts: add payer_rejected to InboxLanes, add
acknowledgePayerRejected(), update exportInboxCsvUrl union.
* hooks/useInboxLanes.ts: add payer_rejected to initial state.
* hooks/useInboxLanes.test.ts: add payer_rejected to mocks.
* components/inbox/BulkBar.tsx: add 'payer_rejected' lane with
Acknowledge action (no Resubmit, no Dismiss — payer-rejected
is not eligible for either).
* components/inbox/BulkBar.test.tsx: add payer_rejected test.
* pages/Inbox.tsx: render the 5th lane, hook up onAcknowledge,
include payer_rejected in the needEyes count.
* pages/Inbox.test.tsx: 3 new tests (5-lane render, need-eyes
count, acknowledge action hits the right endpoint).
* components/inbox/InboxHeader.tsx: doc comment now explains
why payer_rejected rolls up into need-eyes.
Pre-existing typecheck warnings in BulkBar.test.tsx / InboxRow
.test.tsx / Lane.tsx / download.test.ts are unchanged from
main — not touched here.
Test counts: backend 724 -> 734 (+10). Frontend 350 -> 354 (+4).
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).
- 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.
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.
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)
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()
- 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.
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).
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.
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.
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.
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.
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.
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
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.
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.