Commit Graph

134 Commits

Author SHA1 Message Date
Nora 1ca50e2bc0 test(auth): tighten duplicate-username test + add update_password test 2026-06-22 15:32:56 -06:00
Nora 74d7056284 feat(auth): users module with bcrypt hashing + CRUD 2026-06-22 15:32:56 -06:00
Nora 0ba91040f1 feat(migration): 0010 users + sessions, 0011 audit_log.user_id 2026-06-22 15:32:56 -06:00
Nora dc83d7bef2 feat(db): add User and Session models 2026-06-22 15:32:56 -06:00
Nora 9d4798a124 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 15:32:56 -06:00
Tyler 05b43078b9 chore(deps): consolidate dev deps into [project.optional-dependencies] dev
Follow-up to 7c1be58. pytest-randomly was added to a PEP 735
[dependency-groups] dev block by 'uv add --dev', leaving the
project with two 'dev' groups (the legacy [project.optional-
dependencies] dev and the new [dependency-groups] dev). To get
the full dev env someone had to run 'uv sync --extra dev --group
dev', which is easy to miss.

This commit moves pytest-randomly>=4.1 into the
[project.optional-dependencies] dev list (where pytest,
pytest-cov, pytest-asyncio, and httpx already live) and drops
the [dependency-groups] block. 'uv sync --extra dev' now
installs the full dev toolchain. uv.lock updated accordingly:
the [package.dev-dependencies] and [package.metadata.requires-
dev] sections are gone, and pytest-randomly is part of the
standard dev extra.
2026-06-22 11:44:39 -06:00
Tyler 7c1be58860 chore(deps): add cryptography dep, pytest-randomly dev dep
Two follow-ups to the v0.2.0 release:

1. Add cryptography>=49.0,<50 to the [project.dependencies] list.
   cyclone.backup / cyclone.backup_service import it at module top
   level, so it has to be a hard dep — not an extra — or the test
   suite fails to collect on a fresh 'uv sync'. A clean install
   (without this commit) gets 28 collection errors with no tracked
   file changes. This was latent because the original 99M venv
   had cryptography installed out-of-band and masked the missing
   dep declaration.

2. Add pytest-randomly>=4.1.0 to the dev dep group.
   Used to characterize the test suite's order-dependence: under
   alphabetic order the suite shows 7 failures; under seed=12345
   it shows 34. That variance is evidence of test-order state
   interactions, not venv issues. Keeping pytest-randomly in
   the dev deps so this can be re-verified on demand.

Note: pytest-randomly was added with 'uv add --dev', which uses
the PEP 735 [dependency-groups] syntax. pyproject.toml now has
two 'dev' groups — the legacy [project.optional-dependencies] dev
= [pytest, pytest-cov, pytest-asyncio, httpx] and the new
[dependency-groups] dev = [pytest-randomly]. To get the full dev
env, run: uv sync --extra dev --group dev. Worth consolidating
in a follow-up.
2026-06-22 11:43:32 -06:00
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
2026-06-22 11:01:58 -06:00
Tyler 65d98cf674 feat(dashboard): Recent activity events route to entity by kind
SP21 Task 2.5 — the Dashboard's 'Recent activity' card now routes clicks
to the matching entity drawer / page by event kind:

  - claim_*       → /claims?claim=<id>  (drawer in Phase 5)
  - provider_added → /providers?provider=<npi>  (ProviderDrawer)
  - remit_received → toast 'coming in a later phase' (RemitDrawer in Phase 4)
  - anything else  → toast (manual_match, unknown kinds)

Implementation:
  - New src/lib/event-routing.ts with the eventKindToUrl() helper,
    plus a unit test covering all 6 + default branches.
  - src/components/ActivityFeed.tsx gains an optional onItemClick
    prop; when set, each row gets role='button', tabIndex=0, the
    drillable hover affordance (chevron + tint), and an Enter/Space
    keybinding. e.stopPropagation() is called before the handler so a
    parent row click can't double-fire (same fix as Task 2.4).
  - src/pages/Dashboard.tsx wires the handler on the 'Recent activity'
    card via eventKindToUrl + sonner toast for unhandled kinds.
  - Backend: CycloneStore.recent_activity() now exposes claimId and
    remittanceId on each row (read from ActivityEvent.claim_id /
    remittance_id) so the routing helper has the entity ids it needs.
  - The frontend Activity interface gains optional claimId /
    remittanceId fields; the in-memory sample data and the
    addClaim store action populate them so the dashboard works in
    both API-configured and sample-data modes.
2026-06-21 16:41:15 -06:00
Tyler b6607b2009 feat(api): harden CORS and surface 500/409 errors with proper CORS headers
Three independent improvements that fix real browser-facing bugs:

1. CORS: allow 127.0.0.1:5173 in addition to localhost:5173. Both
   resolve to the same Vite dev server but CORS treats them as distinct
   origins, so tabs opened via the IP form silently break.
2. CORS: support CYCLONE_ALLOWED_ORIGINS env var (comma-separated) for
   LAN / staging hosts. The middleware reads it at module import.
3. Catch-all exception handler: returns JSON 500 with CORS headers
   instead of a bare Uvicorn text/plain response. Without this, any
   unhandled exception is misreported by browsers as a CORS error
   because the body can't be read without the allow-origin header.
4. IntegrityError → 409: when (batch_id, patient_control_number) is
   UNIQUE-constrained and a duplicate collides, return 409 with the
   batch id instead of letting the exception 500. Same problem as (3)
   for the most common ingest failure mode.

Tests added:
- test_cors_headers_present_for_loopback_ip
- test_cors_extra_origins_via_env (uses importlib.reload because the
  allow-list is built at module import)
2026-06-21 16:29:59 -06:00
Tyler e6ae364dad feat(api): extend /api/config/providers/{npi} with recent_claims + recent_activity 2026-06-21 14:28:49 -06:00
Tyler 50a454db59 feat(api): GET /api/payers/{payer_id}/summary with 60s cache + pubsub invalidation 2026-06-21 14:28:49 -06:00
Tyler 1942a22629 feat(sp20): NPI Luhn checksum + Tax ID (EIN) format validation
Adds pure local validators for the 10-digit NPI Luhn checksum (CMS-
published algorithm with the '80840' NPPES prefix) and 9-digit EIN
format (rejects reserved prefixes 00/07/80-89). No NPPES round-trip,
no IRS e-file lookup — catches the 99% typo case at parse time.

Surface:
- cyclone.npi.is_valid_npi / is_valid_tax_id / normalize_tax_id
- CLI: 'cyclone validate-npi <npi>' and 'cyclone validate-tax-id <ein>'
- API: GET /api/admin/validate-provider?npi=&tax_id=
- Parser validator: new R021_npi_checksum rule (warning, not error,
  to keep test fixtures with placeholder NPIs ingestible)
- minimal_837p.txt fixture NPI updated from '1234567890' to the
  Luhn-valid '1993999998' so strict-mode CLI parses still pass

Tests:
- test_npi.py — 27 cases (Luhn math, valid/invalid NPIs, EIN cases,
  normalize_tax_id edge cases)
- test_api_validate_provider.py — 4 cases (both valid, both invalid,
  omitted NPI, omitted tax_id)
- test_cli_validate.py — 8 cases (valid/invalid for both subcommands,
  exit codes, malformed inputs)
- test_validator.py — 4 new R021 cases (valid Luhn silent, bad Luhn
  warning, skipped when format bad, skipped when NPI missing)

Total: 923 tests pass.
2026-06-21 10:46:10 -06:00
Tyler c835996bd6 feat(sp19): security hardening + rich health probe
Three pure-ASGI middlewares close completeness-review gaps §3.1.4
(no body/rate limits) and §3.1.25 (no security headers):

- BodySizeLimitMiddleware — rejects oversized uploads (50 MB
  default, CYCLONE_MAX_BODY_BYTES override). 413 on over-cap
  Content-Length and on chunked reads that cross the cap.
- RateLimitMiddleware — sliding-window per-IP limiter (300/min
  default, CYCLONE_RATE_LIMIT_PER_MIN override). 429 over the
  window. /api/health is exempt.
- SecurityHeadersMiddleware — stamps X-Content-Type-Options,
  X-Frame-Options, Referrer-Policy, Permissions-Policy, and a
  strict Content-Security-Policy on every response.

Every 413/429 also writes a tamper-evident api.request_rejected
event into the SP11 audit chain so an operator can correlate
rejections with the SP18 JSON logs.

GET /api/health is rewritten to return a subsystem snapshot:
DB connectivity (SELECT 1), MFT scheduler state, backup scheduler
state, live pubsub subscriber counts, last batch id + timestamp.
Returns status='degraded' if any subsystem is unhappy; per-subsystem
errors surfaced in the respective dict.

Cyclone.pubsub.EventBus.stats() — new method for live subscriber
counts.

13 new tests (test_security.py) + 1 updated (test_api.py health
endpoint). All 883 backend tests pass.
2026-06-21 10:21:01 -06:00
Tyler 47e0f80786 feat(sp18): structured JSON logging + PII scrubber
Cyclone previously emitted stdlib default-formatted log lines that
operators couldn't parse with anything beyond grep. SP18 replaces
that with:

- JsonFormatter: newline-delimited JSON, ISO-8601 ms timestamps,
  structured "extra" dict, exception tracebacks serialized as a
  single string.
- CycloneDevFormatter: tabular format for tail -f in dev.
- PiiScrubber: logging.Filter that redacts NPIs, SSNs, DOBs,
  patient names — both inline in the message ("npi 1881068062")
  and via PHI-keyed extras ({"dob": "1980-04-12"}).
- setup_logging(): idempotent entry point used by the API lifespan
  and CLI main; respects CYCLONE_LOG_LEVEL/FILE/JSON/NO_PII_SCRUB.
- CLI --log-format=json|dev + --log-file=… flags.
- Migrate 9 highest-value log sites in scheduler/backup_scheduler/
  backup_service to use extra={...} (input_filename, claims, parser,
  backup_id, db_fingerprint, etc.).

34 new tests (test_logging_formatter + test_logging_scrubber +
test_logging_setup). All 867 backend tests pass.
2026-06-21 09:59:34 -06:00
Tyler f003c1f73a feat(sp17): encrypted DB backup automation
Adds automated encrypted backups of the live SQLite file. Closes the
'no backup automation' gap called out in the completeness review
(docs/reviews/2026-06-20-cyclone-completeness-review.md §3.1 #3) and
gives the SP16 MFT scheduler a recovery path when the MFT pipeline
loses days of inbound 999/277CA work in a single crash.

Architecture
------------
- AES-256-GCM with PBKDF2-HMAC-SHA256 (200,000 iters, 16-byte salt)
- Online backups via SQLite's .backup() API — no app downtime
- Salt + passphrase persisted to macOS Keychain (separate accounts
  backup.passphrase + backup.salt) so the key is reproducible across
  processes
- Two-step restore (initiate → confirm) with a one-shot 64-char hex
  token; the second call disposes + rebuilds the engine only if the
  token matches within a 5-minute TTL
- Tamper-evident audit chain (SP11) — db.backup_created,
  db.backup_failed, db.backup_pruned, db.backup_restored,
  db.backup_passphrase_set
- BackupService + BackupScheduler + module-level singletons
- 8 admin endpoints + 6 CLI subcommands
- Auto-start opt-in via CYCLONE_BACKUP_AUTOSTART=true; default
  interval 24h, default retention 30 days
- Fallback posture: if no separate passphrase is set and SQLCipher
  is enabled, the key is derived from the SQLCipher DB key with a
  fixed salt + WARNING log (degraded but never plaintext)

New modules
-----------
- cyclone.backup          — PBKDF2, AES-GCM, sidecar format
- cyclone.backup_service  — create_now / list / verify / restore / prune / status
- cyclone.backup_scheduler — async tick loop with audit hooks

New surface
-----------
- 8 admin endpoints under /api/admin/backup/*
- 6 CLI subcommands under cyclone backup (init-passphrase, create,
  list, verify, restore, prune, status)
- Migration 0012_backups.sql + DbBackup ORM
- store.add_backup_pending()

Tests
-----
- 14 unit tests in test_backup_crypto.py (key derivation, encrypt/
  decrypt round-trip, tampered ciphertext, wrong passphrase, sidecar
  round-trip, filename format)
- 19 tests in test_backup_service.py (create/list/verify/restore/
  prune/status, error handling, fallback key, module singleton)
- 14 API tests in test_api_backup.py (all 8 endpoints + scheduler
  endpoints, two-step restore, error responses)
- 10 tests in test_backup_scheduler.py (tick / start / stop /
  audit / coalescing / module singleton)
- 5 CLI tests in test_cli_backup.py (create / list / verify /
  restore confirm prompt / prune confirm prompt /
  init-passphrase minimum-length check)

Total new tests: 62. All pass. Full backend suite: 833 passed,
9 skipped (gitignored prodfiles), 1 warning.

Design doc: docs/superpowers/specs/2026-06-21-cyclone-encrypted-backup-design.md
README: new 'Encrypted Backups (SP17)' section, SP17 entry in
Roadmap, retention default documented in §Project layout.
2026-06-21 09:43:51 -06:00
Tyler 40f184c858 feat(sp16): live MFT polling scheduler
Adds an asyncio-based background scheduler that polls the Gainwell
MFT inbound path, downloads new files, and routes them through the
appropriate parser (999 / 835 / 277CA / TA1). Idempotent (re-ticks
and restarts skip already-processed files via the new
processed_inbound_files table). Crash-safe (per-file try/except so
one bad file doesn't stop the loop).

Lifespan auto-configures from the seeded dzinesco clearhouse's SFTP
block; auto-start is opt-in via CYCLONE_SCHEDULER_AUTOSTART.

Five admin endpoints added:
  GET  /api/admin/scheduler/status
  POST /api/admin/scheduler/start
  POST /api/admin/scheduler/stop
  POST /api/admin/scheduler/tick
  GET  /api/admin/scheduler/processed-files?status=&limit=

20 new tests (15 unit + 5 API).
2026-06-21 09:20:58 -06:00
Tyler 1267a341e3 refactor(api): trailing newlines + hoist acks.py parser imports to top-level
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.
2026-06-21 00:50:31 -06:00
Tyler a63ba5e88c refactor(api): split health + acks + ta1_acks routes into FastAPI APIRouters
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.
2026-06-21 00:49:14 -06:00
Tyler d4f6fdd49c docs: sync READMEs with SP14 (Payer-Rejected lane) + endpoint inventory 2026-06-21 00:36:33 -06:00
Tyler ab00909715 merge: SP15 SQLCipher key rotation into main 2026-06-21 00:33:40 -06:00
sp15-bot 47902fd6b2 feat(sp15): SQLCipher key rotation via PRAGMA rekey
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).
2026-06-21 00:33:37 -06:00
Tyler 6233df1270 refactor(sp): extract shared API helpers from api.py into api_helpers.py
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).
2026-06-21 00:28:58 -06:00
Tyler 5c9365ec33 feat(sp14): 5-lane Inbox UI with Payer-Rejected acknowledge action
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).
2026-06-21 00:13:47 -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