Tier 1: split scheduler.py into handlers/ subpackage, dedup helpers,
loosen INBOUND_RE, add SFTP operation timeouts, surface SFTP errors
in Scheduler.status().
Tier 2: unify 835 ingest + reconciliation into one critical section,
add GET /api/claims/{id}/chain, guard matched_remittance_id ↔
Remittance.claim_id invariant, emit claim.rejected_after_remit audit
when a 277CA rejection hits a matched claim.
Status: Draft, awaiting user sign-off.
18 KiB
Sub-project 27 — Remittances Architecture Refactor: Design Spec
Date: 2026-06-29
Status: Draft, awaiting user sign-off
Branch: sp27-remittances-architecture-refactor
Aesthetic direction: No new UI (chain view rendered into existing ClaimDrawer; no new page)
1. Scope
Cyclone today ships the full claim/ack/remit cycle end-to-end: outbound 837 submitted via SFTP, inbound 999 / 277CA / 835 / TA1 polled and parsed, remits matched against claims. Live data is now flowing on the production system (~60K remits, ~542K 999 acks since 06/19). The architecture serving that cycle has accumulated rough edges that an afternoon of operational pressure surfaced:
- Scheduler is fragile. A 06/25 silent hang (no error logged) took
the MFT poll down for ~3 days; recovered only because the operator
restarted the backend. Root cause:
sftp.listdir_attr()runs without a socket timeout, so when the MFT TCP-acks but stops responding the thread blocks indefinitely. The scheduler's status surface is too shallow to detect this in real time. - Filename classification is brittle.
edi/filenames.py:INBOUND_RErequires a_file_type.x12suffix on inbound files. Gainwell's actual 835s ship without that suffix (see the 5×835 batch from 6/15–6/19), so the only thing the scheduler could do was mark themskipped. Operators had to ingest those by hand. - Helpers duplicate API logic.
cyclone.scheduler._ack_count_summaryand_ack_synthetic_source_batch_idcopy logic fromcyclone.apiwith a comment saying so. Two places to keep in sync. - Reconciliation is decoupled from ingest. 835 ingest writes a
Remittancewithadjustment_amount=0, and a separatereconcile.run()pass later sumsCasAdjustment.amountrows and overwritesadjustment_amount. The UI sees stale values until the pass runs, and the two phases can drift if reconcile is skipped or crashes mid-way. - No unified claim-chain view. A claim's 837 submission, 999 ack, 277CA status, and 835 remit are reconstructed by joining across four pages and three queries. The "did this remit's claim ever get matched?" question requires manual SQL.
In scope
Tier 1 (scheduler/ingest split, SFTP hardening, filename classifier):
- Move the four per-file-type handlers (
999,835,277CA,TA1) out ofbackend/src/cyclone/scheduler.pyintobackend/src/cyclone/handlers/<type>.py. Each handler exposes onehandle(text, source_file) -> (parser_used, claim_count)function. - Pull
_ack_count_summary,_ack_synthetic_source_batch_id, and_277ca_synthetic_source_batch_idout ofscheduler.pyand out ofapi.pyinto a newbackend/src/cyclone/handlers/_ack_id.py. Both callers import from one module. - Loosen
edi/filenames.py:parse_inbound_filenameto accept filenames lacking_file_type.x12; fall back toorig_txas the provisionalfile_type, and accept the file as either835(iforig_txends in835) or999/277CA(iforig_txdoes). - Wrap
clearhouse.SftpClient._list_inbound_paramiko/_list_inbound_names_paramiko/_download_inbound_paramiko/_read_file_paramikocalls inasyncio.wait_for(... timeout=N)viaasyncio.to_thread, withNconfigurable viaCYCLONE_SFTP_OP_TIMEOUT_SECONDS(default 30s). - Surface SFTP errors in
Scheduler.status()—last_error_at,last_error,consecutive_failures,last_sftp_attempt_at. Whenconsecutive_failures >= 3, the operator pill flips to a destructive state until a successful tick clears it. - Stop swallowing
IOErrorfromsftp.listdir_attr()— return the error inresult.errorsand let the scheduler recordlast_error.
Tier 2 (reconciliation atomicity, chain view, invariant guards):
- Unify 835 ingest +
reconcile.run()into a single critical section insidecyclone.handlers.handle_835. The whole flow (parse → validate → persist batch + remits + CasAdjustments → match claims → write backadjustment_amountandmatched_remittance_id) happens in onedb.SessionLocal()(). TheRemittance.adjustment_amountis computed in the same transaction as theCasAdjustmentrows. - Add
GET /api/claims/{id}/chainreturning the claim's full chain in one response:submission(837 →Claimrow),ack_999(matchingAckrows),ack_277ca(matchingTwo77caAckrows),remittance(Remittancerow including adjustments and matched claim FK). Empty slots when a piece is missing — never 404 the whole endpoint just because one piece is absent. - The 999 ack handler and 277CA ack handler — when they reject a
claim, and the claim is already matched to a remit — emit an
ActivityEventclaim.rejected_after_remitso the operator surfaces the conflict in the Activity page rather than silently breaking the manual-match workflow. - Verify the denormalized pair
Claim.matched_remittance_id↔Remittance.claim_idis written in the same transaction on every write-path (store.manual_match,store.manual_unmatch, the new atomic 835 handler). Add a startup invariant check: if any Claim hasmatched_remittance_idbut the matching Remittance'sclaim_idis unset (or vice versa), log a structured error so an operator sees the drift in the logs.
Out of scope (deferred to future increments)
- Frontend scheduler operator UI (start/stop/tick/status page,
reconnect button, live log tail). The
/api/admin/scheduler/*JSON endpoints remain sufficient; the operator uses curl. - Multi-tenant SFTP blocks (per-payer or per-tenant). Single dzinesco block remains.
- Docker-secret
_FILEconventions beyond what SP26 already added. - Outbound file pickup verification (polling HPE's MFT to confirm the operator-dropped 837 was received).
- Frontend chain drawer component. The
GET /api/claims/{id}/chainendpoint is exposed; rendering it inside the existingClaimDraweris a separate UI increment.
2. Decisions (locked during brainstorming)
-
Branch base is
Version-1.0.0, notmain.mainis at74aa64f(SP26 only) and is missing the SP25+26 SFTP work, the[sftp]docker extra, and the permission-matrix fixes. The production system runsVersion-1.0.0, so the refactor lands there. A subsequent forward-merge fromVersion-1.0.0→maincan pull SP27 in if the operator wants it on main. -
One module per handler, not a class. Each handler exposes one pure function
handle(text: str, source_file: str) -> HandleResult, whereHandleResultis a small dataclass withparser_used,claim_count,batch_id?, and per-handler extras (e.g. 835 carriesmatched_count). No class hierarchy, no plugin registry — the scheduler keeps itsHANDLERSdict as the registry. -
The
_FILEenv-var convention from SP26 stays. This spec doesn't add a new secret tier; the existingsecrets.get_secret()three-tier lookup (env var → file → Keychain) is reused. -
Time-bounded SFTP operations use
asyncio.to_thread+asyncio.wait_for.paramikois synchronous; the scheduler already wraps calls inasyncio.to_threadfor the stub path. Real-mode calls get the same treatment so a hanginglistdir_attron the worker thread can be cancelled by the event loop and surfaced as aScheduler._last_error. -
Default SFTP operation timeout is 30s.
CYCLONE_SFTP_OP_TIMEOUT_SECONDSlets operators tune it down (e.g. 10s for a flaky VPN uplink) or up for slow connections. The poll-interval itself remainsCYCLONE_SCHEDULER_POLL_SECONDS(default 60s). -
Reconciliation runs inside the 835 ingest session. No background reconciler process. If a future need emerges (catch-up reconcile on old batches) it lands in a future SP with a separate
cyclone reconcileCLI subcommand. -
Chain endpoint renders even when slots are empty. The contract is "give me this claim's chain; if any piece is missing, return
nullfor that field withmissing: ["277ca_ack"]so the UI can show a placeholder." This matches the operator mental model: "I submitted 837 week-1, the 999 came back accepted, the 277CA came back accepted, but I haven't seen the 835 yet — show me that intermediate state." -
Scheduler.status()gainsconsecutive_failures,last_error_at,last_error,last_sftp_attempt_at. The existing fields (running,poll_interval_seconds,sftp_block_name,last_poll_at,poll_count, totals, last_tick) are unchanged for backward-compat. -
The
claim.rejected_after_remitaudit event is informational, not blocking. The 999/277CA handlers still write the rejection even when a matched remit exists; the audit event is the operator's signal to manually unmatch the pair. We do NOT auto-unmatch (would violate the manual-match semantics from T15). -
The startup invariant check is log-only. It does not block boot. A drift between
Claim.matched_remittance_idandRemittance.claim_idindicates a historical bug; we surface it in the logs and continue. Operators who want to reconcile the drift have a one-shot CLI subcommand (cyclone reconcile reindex-matches) deferred to a follow-up SP.
3. Architecture (after SP27)
backend/src/cyclone/
scheduler.py — slim: Scheduler class + HANDLERS registry + lifecycle
handlers/ NEW subpackage
__init__.py
_ack_id.py — ack_count_summary, ack_synthetic_source_batch_id, *_277ca_*
ack.py — top-level ack dispatch (re-exported helpers)
handle_999.py — text → parsed 999 → ack row → claim rejections
handle_835.py — text → parsed 835 → batch + remits + CasAdjustments
+ match claims (atomic one-section)
handle_277ca.py — text → parsed 277CA → ack row + claim rejections
+ emit claim.rejected_after_remit
handle_ta1.py — text → parsed TA1 → ack row
clearhouse/__init__.py — _paramiko ops wrapped in asyncio.wait_for via to_thread
edi/filenames.py — parse_inbound_filename accepts suffix-less names
store.py — manual_match / manual_unmatch keep matched_remittance_id
and Remittance.claim_id in sync (same transaction);
startup invariant check
api.py — /api/parse-835 deduped vs scheduler handlers via
handlers.handle_835; new /api/claims/{id}/chain endpoint
reconcile.py — run() folded into handle_835; top-level `match()`
kept for the CLI subcommand (deferred)
api_routers/chain.py NEW — GET /api/claims/{id}/chain (auth, matrix_gate)
src/
hooks/useClaimChain.ts NEW — TanStack Query wrapper around /api/claims/{id}/chain
pages/ClaimDrawer.tsx — one new section: "Chain" with submission / 999 / 277CA / remit
placeholders; renders adjustments inline
The scheduler shrinks from 860 LOC to ~250 LOC (the Scheduler class +
singleton plumbing). Each handle_*.py is ~80–150 LOC and can be
tested in isolation against a real prodfiles fixture.
4. Files
New:
backend/src/cyclone/handlers/__init__.pybackend/src/cyclone/handlers/_ack_id.pybackend/src/cyclone/handlers/handle_999.pybackend/src/cyclone/handlers/handle_835.pybackend/src/cyclone/handlers/handle_277ca.pybackend/src/cyclone/handlers/handle_ta1.pybackend/src/cyclone/api_routers/chain.pysrc/hooks/useClaimChain.tsbackend/tests/test_handlers_999.pybackend/tests/test_handlers_835.pybackend/tests/test_handlers_277ca.pybackend/tests/test_handlers_ta1.pybackend/tests/test_inbound_filename_loose.pybackend/tests/test_sftp_op_timeout.pybackend/tests/test_scheduler_status_errors.pybackend/tests/test_handler_835_atomic_reconcile.pybackend/tests/test_api_claim_chain.pybackend/tests/test_handler_277ca_rejected_after_remit.pybackend/tests/test_store_match_invariant.pysrc/hooks/useClaimChain.test.tssrc/pages/ClaimDrawer.test.tsx(extension — the chain drawer section)
Modified:
backend/src/cyclone/scheduler.py— slim to Scheduler class + lifecyclebackend/src/cyclone/edi/filenames.py—parse_inbound_filenameaccepts suffix-less inbound filenames;is_inbound_filenamelikewisebackend/src/cyclone/clearhouse/__init__.py— wrap paramiko operations inasyncio.wait_for(asyncio.to_thread(...), timeout=N)backend/src/cyclone/store.py—manual_match/manual_unmatchwrite the pair in one transaction; startup invariant log;add(835 branch) callshandlers.handle_835instead of inliningbackend/src/cyclone/reconcile.py—runexposed for the follow-up CLI subcommand;matchkept; the scheduler / 835 handler stop callingrundirectlybackend/src/cyclone/api.py—/api/parse-835reuseshandlers.handle_835;/api/parse-999,/api/parse-277ca,/api/parse-ta1likewise; add/api/claims/{id}/chainroutebackend/src/cyclone/api_helpers.py(or new file) — wire/api/claims/{id}/chainif the route doesn't fit the existing patternsrc/pages/ClaimDrawer.tsx— one new "Chain" sectionsrc/lib/api.ts— exposeapi.fetchClaimChain(id)src/types/index.ts— addClaimChaintype
New migrations: none. The DB schema is unchanged.
5. API surface
| Method | Path | Returns | Auth |
|---|---|---|---|
| GET | /api/claims/{id}/chain |
ClaimChain JSON |
matrix_gate (any logged-in user) |
No new endpoints beyond this one. No new env-var-driven behavior
beyond CYCLONE_SFTP_OP_TIMEOUT_SECONDS (default 30s).
// GET /api/claims/{id}/chain
{
"claim_id": "...",
"submission": {
"batch_id": "...",
"patient_control_number": "...",
"service_date_from": "2026-06-15",
"service_date_to": "2026-06-15",
"charge_amount": "125.00",
"state": "submitted",
"submitted_at": "2026-06-16T..."
},
"ack_999": {
"source_batch_id": "999-PCN-12345678",
"ack_code": "A",
"received_count": 1,
"accepted_count": 1,
"rejected_count": 0,
"received_at": "2026-06-17T..."
} | null,
"ack_277ca": {
"source_batch_id": "277CA-000000001",
"classification": "accepted",
"received_at": "2026-06-18T..."
} | null,
"remittance": {
"id": "...",
"payer_claim_control_number": "...",
"total_paid": "85.00",
"patient_responsibility": "15.00",
"adjustment_amount": "25.00",
"status_label": "Paid",
"received_at": "2026-06-19T...",
"matched": true,
"adjustments": [
{ "group_code": "CO", "reason_code": "97", "amount": "25.00", "label": "..." }
]
} | null,
"missing": ["277ca_ack"] // list of any of the above that came back null
}
6. Env vars (operator-facing)
| Variable | Default | Purpose |
|---|---|---|
CYCLONE_SFTP_OP_TIMEOUT_SECONDS |
30 |
Per-operation SFTP timeout in clearhouse. Lower for flaky links, higher for slow ones. The existing CYCLONE_SCHEDULER_POLL_SECONDS and CYCLONE_SFTP_PASSWORD[_FILE] from SP25+26 are unchanged. |
No new env vars beyond this one.
7. Validation rules
No new R-code rules. The 835 validator (parser_835.validator_835)
is called unchanged by handlers.handle_835 inside the same
transaction; the new chain endpoint doesn't introduce new parser
or validation logic.
8. Testing plan
Target backend test count after SP27: current + 17 new tests.
- Per-handler unit tests (4 files × ~3 tests each):
test_handlers_999.py,test_handlers_835.py,test_handlers_277ca.py,test_handlers_ta1.py— call each handler against a prodfiles fixture and assert the persisted rows. test_inbound_filename_loose.py(4 cases):- filename with
_835.x12suffix →file_type="835"(existing behavior). - filename without
_file_type.x12andorig_tx=835→file_type="835"(new). - filename without suffix and
orig_tx=999→file_type="999"(new). - filename without suffix and
orig_tx=837P→file_type="999"rejected byALLOWED_FILE_TYPES— no false positive for 837s.
- filename with
test_sftp_op_timeout.py(3 cases): stub SFTP returns a slowsftp.listdir_attr(raises after 60s); assert the scheduler times out withinCYCLONE_SFTP_OP_TIMEOUT_SECONDS+ slack; stub returns fast → assert happy path still works.test_scheduler_status_errors.py(4 cases):consecutive_failuresincrements on tick error;last_errorpopulated;status_sftp_healthflips tofailingafter 3 fails; flips back on next success.test_handler_835_atomic_reconcile.py(3 cases): 835 ingest persistsRemittancewithadjustment_amountcorrect from the start; reconcile crash mid-transaction leaves no orphans; an existing claim getsmatched_remittance_idset in the same transaction.test_api_claim_chain.py(5 cases): happy path with all four slots populated; missing ack_999 →null + missing=["ack_999"]; missing remittance →null + missing=["remittance"]; missing claim → 404; auth → 401.test_handler_277ca_rejected_after_remit.py(2 cases): 277CA rejects a claim that is already matched → emitsclaim.rejected_after_remitaudit event with both IDs; 277CA rejects an unmatched claim → no audit event.test_store_match_invariant.py(3 cases):manual_matchwritesClaim.matched_remittance_idandRemittance.claim_idin one transaction;manual_unmatchclears both; pre-existing drift is logged at startup without blocking boot.- Frontend useClaimChain.test.ts (3 cases): hook fetches the chain and returns typed data; loading/error states; caches for 30s.
- Frontend ClaimDrawer.test.tsx extension (2 cases): renders the chain section; renders placeholders for missing pieces.
The full backend suite (cd backend && .venv/bin/pytest) and
frontend suite (npm test) remain the merge gate.
9. Out of scope (future SPs)
- Frontend scheduler operator UI (start/stop/tick/status page).
- Multi-tenant / per-payer SFTP blocks.
<NAME>_FILEDocker-secret convention beyond what SP26 added.- Outbound file pickup verification.
- Auto-unmatch on rejection (would violate T15 manual-match contract).
cyclone reconcile reindex-matchesone-shot CLI for historical drift.- Per-payer chain-fanout (different payer-specific parsing of the same 277CA segment).
- Persisting the chain response server-side (it is a join, not a stored view; keeping it computed avoids drift).