15 KiB
SP25 — SFTP Polling Enablement: Design Spec
Date: 2026-06-24
Status: Draft, awaiting user sign-off
Branch: sp25-sftp-polling-enablement
Aesthetic direction: No new UI. Tiny backend delta on top of the SP16 scheduler, plus an operator runbook. The polling loop, handlers, idempotency table, and admin endpoints already exist and are not touched.
1. Scope
SP16 shipped a complete inbound-MFT polling scheduler — handlers for TA1, 999, 835, and 277CA; admin endpoints start, stop, tick, status, processed-files; idempotency via the processed_inbound_files table; opt-in autostart via the CYCLONE_SCHEDULER_AUTOSTART env var. SP13 wired real paramiko SFTP behind the same SftpClient interface. What is missing is the last mile that lets an operator point that loop at mft.gainwelltechnologies.com and let it run unattended.
SP25 closes that gap. Three changes, all small:
- Portable secrets. Extend
cyclone.secrets.get_secret()with a plain env-var fallback ahead of the macOS Keychain lookup, so the same code path serves a macOS workstation, a Linux server, and a Docker container without conditional deployment glue. - Mutable clearhouse block. Add
PATCH /api/clearhouseso the operator can flipsftp_block.stubfromtruetofalse(and adjust host, port, paths, poll interval) without touching SQLite directly. The patch re-configures the running scheduler so the next tick uses the new block — no process restart required. - Operator runbook. A new
docs/RUNBOOK.mddocuments the exact env-var values, the order of operations, and the verification commands. The runbook is the contract between this spec and any operator bringing up SFTP polling on a fresh host.
Out of scope (explicit, each is its own future SP if requested):
- A frontend operator UI for scheduler start/stop/tick/status. SP16's endpoints exist; the operator uses curl.
- Multi-block polling (per-payer or per-tenant SFTP blocks). SP9 explicitly deferred this; today the loop watches the single dzinesco clearhouse block.
- A separate polling daemon process. SP16 runs in the FastAPI process; splitting it into a sidecar is a bigger redesign and not required for the user's "just turn it on" goal.
- The
<NAME>_FILEDocker-secret convention. The user explicitly chose plain env vars; the file-based fallback can be a follow-up if a Docker deployment lands without further changes here. - Any 277CA-specific changes. TA1, 999, 835 are the user's target; 277CA already works via the SP16 handler and is left alone.
2. Goals
- A single curl can flip the loop from stub to real. The operator does not need SQL access, a restart, or a redeploy to start polling real MFT.
- The secrets path is portable. The same binary runs on macOS (Keychain) and on a Linux server (env var) with zero code changes between the two.
- The next tick after a PATCH uses the new block. Operator-visible feedback is "I changed a setting, the next scheduler tick honors it" — not "I changed a setting, the change takes effect on restart."
- The runbook is self-contained. An operator reading
docs/RUNBOOK.mdcan bring up polling on a fresh host without reading the spec, the source, or any commit messages.
3. Locked decisions
3.1 Secret lookup order — env var first, Keychain second
cyclone.secrets.get_secret() currently calls keyring.get_password("cyclone", name). On any host without keyring installed (Linux servers, Docker containers without the keyring extra), it returns None and SftpClient._connect() then refuses to authenticate, raising a RuntimeError that the scheduler surfaces as a failed tick.
The fix is a three-tier lookup, evaluated in this order:
- Plain env var. If
<NAME>is present inos.environ, return its.strip()-ed value. The variable name matches the Keychain account name verbatim — soCYCLONE_SFTP_PASSWORDis the env-var form of the Keychain accountsftp.gainwell.password. The strip handles the common copy-paste-from-.envcase where the value has a trailing newline. - macOS Keychain. Existing behavior, unchanged. Kept as a fallback so a macOS dev box that prefers the Keychain CLI workflow (
security add-generic-password) still works without env-var exports. - None. Return
Noneso the caller can decide what to do.SftpClient._connectalready handles this with a precise error message.
Rationale for "env var first, not Keychain first": env vars work everywhere; Keychain only works on macOS. Putting env var first means a Linux server needs nothing special, and a macOS dev box that exports the env var intentionally takes precedence over an unrelated Keychain entry from a previous test. The order is the inverse of the SP23 admin-bootstrap convention (which is _FILE-then-env-var) because the user explicitly chose plain env vars over Docker-secret files for this increment.
3.2 PATCH /api/clearhouse — typed, gated, hot-reloading
A new endpoint sits next to the existing GET /api/clearhouse (which is already gated by matrix_gate). The body is the full Clearhouse Pydantic model (single source of truth — no separate request/response DTOs). Behavior:
- Validates the body via Pydantic. Invalid shape → 422. Unknown fields → 422 (Pydantic default for an extra-strict model).
- Writes the new row inside one
SessionLocal()context. - After commit, calls
scheduler_mod.configure_scheduler(sftp_block, force=True)so the module singleton is replaced. - If the scheduler was already running before the PATCH (autostart on, or a previous manual start), the helper restarts it against the new block. The in-flight tick, if any, finishes against the old client; the next tick uses the new one. Coalescing is already handled by
Scheduler._tick_in_progress. - If the scheduler was not running, the PATCH just leaves it stopped.
Auth: same Depends(matrix_gate) as every other /api/admin/* and /api/clearhouse* endpoint. No new permission tier.
Validation rules to enforce on the body:
sftp_block.stubmust be a real bool, not a stringified "true"/"false".sftp_block.host, whenstubisfalse, must be non-empty.sftp_block.paths.inboundmust be non-empty.sftp_block.password_keychain_account, whenstubisfalse, must be non-empty (otherwiseSftpClient._connectraises an unclear error at next tick).
The endpoint returns the post-update Clearhouse (same shape as GET /api/clearhouse) so the operator can pipe-verify the change in one round-trip.
3.3 No scheduler restart, just a reconfigure
The scheduler is an asyncio.Task with a single _run() loop. configure_scheduler(force=True) replaces the module-level _scheduler singleton. The helper also has to handle the "scheduler was running" case: cancel the existing task with a 30-second wait (matching the existing Scheduler.stop() timeout), then start the new instance. The implementation lives in cyclone.scheduler as a new reconfigure_scheduler() function so the API endpoint stays thin.
This avoids the operator-visible cliff of "I changed a setting, now I have to restart the API." The existing stop() semantics — drain the in-flight tick, then exit — carry over to the reconfigure path.
3.4 Runbook placement — new docs/RUNBOOK.md
A new top-level doc dedicated to operator procedures. Fits the SP23 Docker posture which already implies operator-facing documentation. The runbook contains:
- Prerequisites — Python 3.11+,
keyringoptional, outbound network reachability tomft.gainwelltechnologies.com:22. - Env-var reference — table of every
CYCLONE_*env var the operator might set for this increment (password, scheduler autostart, scheduler poll interval, log level). - First-time setup — set
CYCLONE_SFTP_PASSWORD, PATCH to flip stub false, restart (or rely on autostart on next launch), verify viaGET /api/admin/scheduler/status. - Verification commands — exact curl incantations for status, tick-now, processed-files. Including how to filter for
status=errorrows. - Troubleshooting — what each error class means (
AuthenticationExceptionfrom paramiko → wrong password;IOErrorfromlistdir_attr→ wrong inbound path; tick records zero files → SFTP server has nothing in the inbound dir yet). - macOS dev box variant — using
security add-generic-passwordinstead of the env var.
The runbook is committed in the same PR as the code. No doc-only follow-up.
3.5 Idempotency is unchanged
The processed_inbound_files table, the (sftp_block_name, name) unique index, and the STATUS_* taxonomy are all unchanged. A re-tick after a PATCH still skips files that were already processed against any previous block (keyed by sftp_block_name and the inbound filename). The PATCH does not need to clear or backfill any rows.
3.6 Audit log stays clean
Inbound file processing is operational metadata, not part of the HIPAA audit chain (per the existing SP16 scheduler docstring and the SP11 audit-log design). SP25 does not change that boundary — PATCH on the clearhouse row is itself not audited; the resulting inbound-file processing continues to surface via processed_inbound_files and the existing pubsub events (ack_received, claim.payer_rejected, claim_submitted, remittance_written).
4. Files
Modified:
backend/src/cyclone/secrets.py— add env-var lookup ahead of Keychain. Newget_secret()docstring describing the three-tier lookup. No new public symbols beyond a small constant for the env-var-prefix mapping.backend/src/cyclone/scheduler.py— addreconfigure_scheduler(sftp_block)helper that cancels any running task (with the existing 30-second drain), replaces the singleton, and restarts if the previous one was running.backend/src/cyclone/api.py— addPATCH /api/clearhousenext to the existingGET /api/clearhouseandPOST /api/clearhouse/submit. Thin handler: validate body →store.update_clearhouse()→_scheduler_mod.reconfigure_scheduler()→ return the new row.backend/src/cyclone/store.py— addupdate_clearhouse(block: Clearhouse) -> Clearhousethat replaces the singleton row in a single session. Idempotent in shape but the call site is the PATCH endpoint, not lifespan, so idempotency is a nice-to-have not a hard requirement.
New:
backend/tests/test_secrets_envvar.py— covers all three tiers plus the.strip()andNonepaths.backend/tests/test_api_clearhouse_patch.py— covers happy path, 422 on invalid body, 401 on missing auth, scheduler reconfigure call,matrix_gateintegration.backend/tests/test_scheduler_reconfigure.py— covers the cancel-then-restart behavior, the running-vs-stopped cases, and the in-flight tick drain.docs/RUNBOOK.md— the operator doc described in §3.4.
New migration: none. The clearhouse table is unchanged. The processed_inbound_files table is unchanged.
5. API surface
| Method | Path | Body | Returns | Auth |
|---|---|---|---|---|
PATCH |
/api/clearhouse |
Full Clearhouse model |
Updated Clearhouse |
matrix_gate (admin) |
No new endpoints beyond PATCH. No new env-var-driven behavior beyond the documented ones. No new pubsub events.
6. Env vars (operator-facing)
| Variable | Default | Purpose |
|---|---|---|
CYCLONE_SFTP_PASSWORD |
unset | Plain-env-var form of the MFT password. Highest-priority lookup in secrets.get_secret(). Stripped of leading/trailing whitespace. |
CYCLONE_SCHEDULER_AUTOSTART |
unset (falsy) | When 1/true/yes, the scheduler's start() is called from the FastAPI lifespan. Existing behavior, unchanged. |
CYCLONE_SCHEDULER_POLL_SECONDS |
60 |
Poll interval. Existing behavior, unchanged. |
The user explicitly chose plain env var over Docker-secret file convention. If a Docker deployment lands later, a follow-up SP can add a <NAME>_FILE tier ahead of the plain env var without changing this spec's contracts.
7. Validation rules
No new R_* rule IDs in cyclone.validation.rules. The Pydantic model for Clearhouse.sftp_block already enforces the field shapes; the endpoint just relies on those. Additional inline checks (non-empty host when stub=false, etc.) are Pydantic field_validators on SftpBlock.
8. Testing plan
-
test_secrets_envvar.py— 8 cases:- Env var set, Keychain present → env wins (returned).
- Env var set with trailing
\n→ returns stripped value. - Env var set, Keychain absent → env returned.
- Env var absent, Keychain present → Keychain returned.
- Both absent → returns
None. keyringlibrary missing (monkeypatch the lazy import) → falls through toNone.- Env var value is the empty string → treated as absent (return
None, do not propagate). - Env var present but file path is unreadable (deferred — not applicable since
_FILEis out of scope; skip).
-
test_api_clearhouse_patch.py— 7 cases:- PATCH happy path: stub=true → stub=false, host="mft.gainwelltechnologies.com", GET reflects change.
- PATCH with
stub="yes"(string) → 422. - PATCH with missing
sftp_block.hostandstub=false→ 422. - PATCH without session cookie → 401 (matrix_gate).
- PATCH with
password_keychain_account=""andstub=false→ 422. - PATCH triggers
configure_scheduler(force=True)— verified by mocking. - PATCH returns the post-update row, identical shape to
GET /api/clearhouse.
-
test_scheduler_reconfigure.py— 5 cases:- Scheduler not running → PATCH leaves it stopped, singleton replaced.
- Scheduler running → PATCH cancels old task, starts new one, no overlapping ticks.
- PATCH during a tick → in-flight tick completes against old client, next tick uses new client.
reconfigure_scheduler30-second drain timeout matches the existingScheduler.stop()semantics.- PATCH with stub=true and stub=false in sequence → both calls succeed, no leaked tasks.
Target backend test count after SP25: current + 8 + 7 + 5 = current + 20 tests.
9. Out of scope (future SPs)
- Frontend operator UI for
start/stop/tick/status/processed-files. The curl-based workflow documented in the runbook is sufficient for now. - Multi-block polling (per-payer or per-tenant SFTP blocks). The dzinesco singleton is sufficient for one billing office.
- Separate polling daemon (
python -m cyclone poll). The in-process scheduler is fine while the operator is fine with the API being up. <NAME>_FILEDocker-secret convention forget_secret(). Plain env vars are enough today; add the file tier when a Docker deployment arrives.- Per-file-type fan-out beyond TA1/999/835/277CA. Future X12 types (270/271/276/278/820/834/ENCR per the HCPF doc) can land as their parsers ship.
10. Open questions resolved this session
| # | Question | Resolution |
|---|---|---|
| 1 | What does "setup SFTP polling" mean given SP16 exists? | "Just turn it on" — small enablement increment, no from-scratch design. |
| 2 | Real MFT or stub? | Real Gainwell MFT. |
| 3 | macOS Keychain vs Docker secret file vs plain env var? | Plain env var (CYCLONE_SFTP_PASSWORD). User explicitly chose this. |
| 4 | Runbook placement? | New docs/RUNBOOK.md. |
| 5 | Hot-reload of the scheduler on PATCH, or restart required? | Hot-reload — the next tick uses the new block. |
11. Open questions still pending
None. Ready to implement.