# Sub-project 22 — Cyclone Pipeline Agent: Design Spec

**Date:** 2026-06-21
**Status:** Draft (awaiting user review)
**Branch:** `sp22-pipeline-agent` (not yet created; will branch from `main`)
**Aesthetic direction:** n/a (backend automation; no UI surface)

## 1. Scope

A production-grade agent (sibling Python package `cyclone-pipeline`) that
automates the full submission lifecycle of an EDI 837P file through Cyclone
to the Gainwell MFT, and watches for the inbound TA1 + 999 ACKs that
confirm acceptance. The agent exists so a single human operator can drop a
file in and walk away; the agent handles the browser upload, SFTP
submission, ACK polling, and report generation end to end, and writes a
self-contained run directory that OpenClaw / Nora / any other automation
can read.

**In scope:**

- Sibling Python package at `/Users/openclaw/dev/cyclone-pipeline/` (no
  shared imports with the cyclone app; talks to cyclone exclusively over
  HTTP).
- CLI surface: `run <file>`, `run-batch <dir>`, `status <run-id>`,
  `resume <run-id>`, `check-835 <run-id>`.
- Library surface: `CyclonePipeline` class with `run()`, `run_batch()`,
  `resume()`, `check_835()` for embedding in OpenClaw / Nora.
- 7-phase state machine: pre-flight → upload (browser) → verify parse →
  SFTP submit → wait TA1 → wait 999 → scan HTML + report. The "scan
  HTML" and "final report" steps are combined as phase 7 (the HTML
  scan populates the report; the report write is phase 7's
  postcondition).
- Per-run output folder with `report.{md,json}`, `run.log`,
  `run.state.json`, and `screenshots/` with stable filenames.
- Crash-safe resume: `run.state.json` is written after each phase's
  postcondition; `resume` skips completed phases.
- Headless + visible browser modes; default visible so screenshots are
  useful for debugging.
- Defensive reliability: typed exceptions, 3× retry with backoff on
  idempotent operations, timeouts per phase, screenshots on failure,
  Keychain-failure guidance.
- Test suite: `pytest` + `pytest-asyncio` + `respx` (httpx mock) +
  `pytest-playwright` for the small browser surface. Non-browser tests
  run on every PR; Playwright tests gated to `main`.

**Out of scope (deferred):**

- **835 waiting within a single run.** CO Medicaid 835 remittances land
  the following Monday, on the payment cycle. A run that blocks for up
  to 7 days is impractical. The agent defers 835 detection to a separate
  `check-835 <run-id>` subcommand that the operator runs on/after
  Monday. The report always carries the `check-835` recipe.
- **277CA polling.** CO Medicaid does not use 277CA claim-level ACKs;
  the agent never polls for them. (The `GET /api/277ca-acks` endpoint
  exists in the API surface but is not exercised by this agent.)
- **Watch-folder / daemon mode.** The agent is on-demand. A cron job that
  invokes `cyclone-pipeline run-batch /inbox/*.txt --strict` on a 5-min
  schedule achieves the same effect with no daemon in the loop.
- **Multi-payer support.** The agent targets CO Medicaid (the only
  configured payer in `config/payers.yaml`). A new payer means new
  payer-specific regex / file-naming / SFTP block — out of scope for v1.
- **Patient peek or other drill-down features.** This is a backend
  automation; no UI changes.
- **Editing the audit log or any existing cyclone data.** The agent
  only reads + triggers; it never writes to cyclone's DB except through
  the published APIs.

## 2. Locked decisions

### 2.1 Architecture

API-first + minimal Playwright shell. The browser is used only for the
upload page (drop zone, Parse button, progress bar). Everything else
(verification, SFTP submit, scheduler control, ACK polling, claim-state
checks) hits the FastAPI directly via `httpx.AsyncClient`.

Rationale: the upload page is the only surface in cyclone with a real
interactive flow. Clearinghouse submission is API-only; the MFT
scheduler is API-only; ACK retrieval is API-only. A pure-Playwright
approach would be 3–5x more code and considerably flakier on the
API-driven phases, with no benefit.

### 2.2 Sibling package, not an extra

The agent lives at `/Users/openclaw/dev/cyclone-pipeline/` with its own
`pyproject.toml`, not as a new optional extra on the existing cyclone
package. Rationale: the agent has Playwright as a hard dependency; we
do not want Playwright in cyclone's dev env for developers who never
invoke the agent. Separate package = clean separation of concerns.

### 2.3 Real round-trip, but only the parts that are time-bounded

The agent waits for **TA1 (envelope ACK)** and **999 (file-level ACK)**.
It does **not** wait for 277CA (CO Medicaid doesn't use it) or 835
(payment cycle is next Monday). 835 detection is a separate, fast
follow-up command that the operator runs on Monday.

Rationale: blocking a run for up to 7 days is impractical and ties up
operator attention. The TA1 + 999 receipt is the unambiguous "your file
was accepted by the payer at the file level" signal; the 835 is
eventually-consistent and doesn't need a tight loop.

### 2.4 CO Medicaid / Gainwell specifics

- **SFTP server:** `mft.gainwelltechnologies.com:22`
- **Inbound path:** `/CO XIX/PROD/coxix_prod_11525703/FromHPE`
- **Outbound file naming:** `11525703-837P-{yyyymmddhhmmssSSS}-1of1.txt`
  (17-digit Mountain Time timestamp; matches the `clearhouse` config's
  `outbound` template in `config/payers.yaml`).
- **Inbound ACKs (in observed order):**
  1. TA1 (seconds to minutes) — envelope-level
  2. 999 (minutes) — file-level
  3. HTML (sometimes) — non-X12 artifact, lands in inbound, not parsed
  4. 835 (next Monday) — remit
  5. 277CA — *never used by CO Medicaid*

### 2.5 Failure semantics

- **Upload retries:** 3× attempts; each attempt is a fresh page load +
  file re-attach + click. Screenshots on every attempt.
- **SFTP submit retries:** 3× attempts with `1s → 2s → 4s` backoff. The
  submit endpoint is POST and is *not* auto-retried after a network
  timeout (would double-submit); the retry covers `409 Conflict` /
  `503 Service Unavailable` only.
- **TA1 timeout (5 min):** if no TA1 arrives, the run is **soft-fail**.
  The submission is "in flight" — the report says so and recommends
  checking Gainwell directly.
- **999 timeout (15 min):** same — soft-fail. Most production runs
  complete this phase in < 2 min.
- **999 received with status `R` (rejected):** hard-fail. The 999's
  rejection segments surface in the report with full context.
- **HTML detected in inbound:** warn in the report; do not fail.
- **835 not present at end of run:** expected; reported as
  "deferred — expected next Monday" with the `check-835` recipe.
- **Process crash (SIGKILL, power loss, etc.):** the next `resume` skips
  completed phases by reading `run.state.json`. No work is lost except
  the in-flight phase.

### 2.6 Idempotency

- The MFT outbound filename embeds a millisecond Mountain Time
  timestamp, so back-to-back submissions to the same input file are
  unique on the SFTP server. No `Idempotency-Key` header is needed.
- The agent captures the new `claim_id[]` set from phase 3 (verify
  parse) and refuses to SFTP-submit if any of those claim IDs already
  appear in a prior `processed_inbound_files` row within a configurable
  dedup window (default 24h). `--force` overrides the dedup.
- The `check-835` command is fully idempotent: querying
  `GET /api/remittances?since=<date>` is read-only.

### 2.7 Output shape

Every run writes a dated folder under `./runs/`. The folder is
self-contained — anyone reading it has the full picture without needing
the live cyclone instance.

```
runs/2026-06-21-1430-001/
├── run.log                    # structured JSON, structlog
├── run.state.json             # for resume
├── report.md                  # human-readable summary
├── report.json                # machine-readable, OpenClaw/Nora-friendly
└── screenshots/
    ├── 01-preflight.png
    ├── 02-upload-1.png        # attempts are numbered
    ├── 02-upload-2.png
    ├── 03-parse-complete.png
    ├── 04-submit.png
    ├── 05-ta1.png
    ├── 06-999.png
    ├── 07-inbound-scan.png    # HTML detection screenshot
    └── 99-final-inbox.png     # final state of the Inbox page
```

On failure, the `finally` block adds `screenshots/FAIL-{phase}.png` and
a `failure.md` with the traceback + phase + remediation hint.

## 3. Module map

| File | Role |
|---|---|
| `pyproject.toml` | Project metadata; declares httpx, playwright, click, pydantic, structlog; dev deps: pytest, pytest-asyncio, respx, pytest-playwright |
| `README.md` | Install, run, embed-in-agent examples |
| `src/cyclone_pipeline/__init__.py` | Public API re-exports |
| `src/cyclone_pipeline/__main__.py` | `python -m cyclone_pipeline run …` |
| `src/cyclone_pipeline/cli.py` | Click CLI: `run`, `run-batch`, `status`, `resume`, `check-835` |
| `src/cyclone_pipeline/pipeline.py` | `CyclonePipeline` orchestrator; 7-phase state machine |
| `src/cyclone_pipeline/api_client.py` | `httpx.AsyncClient` wrapper with typed methods |
| `src/cyclone_pipeline/browser.py` | Playwright `UploadPage` class for the upload UI |
| `src/cyclone_pipeline/waiters.py` | Reusable wait primitives (claim state, ACK arrival) |
| `src/cyclone_pipeline/state.py` | `RunState` dataclass + `run.state.json` writer/reader |
| `src/cyclone_pipeline/screenshots.py` | Dated folder + stable filename helper |
| `src/cyclone_pipeline/report.py` | `report.md` + `report.json` writer |
| `src/cyclone_pipeline/selectors.py` | Centralized Playwright selectors with fallbacks |
| `src/cyclone_pipeline/exceptions.py` | Typed errors: `UploadError`, `ParseError`, `SubmitError`, `AckTimeoutError`, `SchedulerNotRunningError`, `IdempotencyError` |
| `src/cyclone_pipeline/logging_setup.py` | `structlog` config matching cyclone's SP18 JSON style |
| `src/cyclone_pipeline/check_835.py` | The deferred 835 detector (pure API, no browser) |
| `tests/conftest.py` | Shared fixtures: fake API server, mock browser, fake clock |
| `tests/test_api_client.py` | Typed responses, retry behavior, timeouts (respx) |
| `tests/test_waiters.py` | Backoff, timeout, predicate resolution (fake clock) |
| `tests/test_browser.py` | safe_click, attach_file, progress wait (pytest-playwright) |
| `tests/test_pipeline.py` | End-to-end orchestration, state machine, resume (mocks) |
| `tests/test_report.py` | Markdown + JSON shape, screenshot paths (golden files) |
| `tests/test_cli.py` | `run`, `run-batch`, `resume`, `check-835` (Click's CliRunner) |
| `tests/test_check_835.py` | `check-835` happy path + "835 not yet present" path |
| `tests/fixtures/sample_837p.edi` | A small valid 837P file for tests (copied from cyclone's `tests/fixtures/minimal_837p.txt`) |

## 4. Pipeline phases

The state machine in `pipeline.py` advances through 7 phases. Each phase
has: `precondition`, `execute`, `postcondition`, `retry_policy`, and a
side-effect of writing `run.state.json` + a screenshot at the end of a
successful attempt.

### Phase 1 — Pre-flight (API)

- `GET /api/health` → expect `status == "ok"` and `db.ok == true`.
- `GET /api/admin/scheduler/status` → record scheduler state
  (`running` / `stopped`). If `stopped`, the agent will call
  `POST /api/admin/scheduler/tick` per-iteration in phase 5/6 instead
  of relying on the polling loop.
- Verify the browser-side URL is reachable: `HEAD {browser_base}/`
  via `httpx` (cheap, no rendering). If the frontend dev server isn't
  up, fail-fast with `BrowserNotReachableError` rather than discovering
  this in phase 2.
- Capture the API base URL + browser base URL for the report.
- Screenshot the Upload page in its empty state →
  `screenshots/01-preflight.png`.

### Phase 2 — Upload (Playwright)

- `chromium.launch(headless=VISIBLE)` (configurable).
- `page.goto(f"{browser_base}/upload")`.
- `expect(drop_zone).to_be_visible(timeout=10s)`.
- `set_input_files("input[type=file]", file_path)` (the hidden input
  behind the drop zone; `set_input_files` works on hidden inputs).
- `expect(file_name_display).to_have_text(file.name)`.
- `safe_click("button:has-text('Parse')")`.
- `wait_for_progress_complete()` — polls the progress bar
  (`text_content` containing `"100%"`) or the success toast
  (`text_content` containing `"Parsed N claims"`), whichever comes
  first, with a 5 min ceiling.
- Screenshots: `screenshots/02-upload-{attempt}.png` per attempt.

### Phase 3 — Verify parse (API)

- The browser-side toast gives the parsed claim count; capture it.
- `GET /api/claims?since=<phase1_start_ts>` → filter to claims whose
  `submission_date >= phase1_start_ts`; these are the new ones.
- Collect the set of new `claim_id` values.
- If 0 new claims after a 30s poll, fail with `ParseError`.

### Phase 4 — SFTP submit (API)

- Dedup check: query `GET /api/admin/scheduler/processed-files` for
  the past 24h; if any outbound filename pattern matches a recent
  submission, fail with `IdempotencyError` unless `--force`.
- `POST /api/clearhouse/submit` with `{payer_id: "CO_TXIX",
  transaction_type: "837P"}`. The body is small; the server picks
  the batch from the newly-parsed claims.
- Expect 200; if 5xx with a "keychain" hint, raise `SubmitError` with
  the macOS Keychain setup recipe inline.
- Screenshot: `screenshots/04-submit.png` (the Inbox page right after
  submit, showing the batch in the lane).

### Phase 5 — Wait for TA1 (API poll)

- `GET /api/ta1-acks?since=<phase4_ts>` in a loop, every 5s, with a
  5 min ceiling.
- Match by `isa13` (the control number in the submitted interchange
  envelope; surfaced in the TA1 record).
- If scheduler is `stopped`, call `POST /api/admin/scheduler/tick`
  between polls to manually drive the MFT fetch.
- On hit: capture `ta1_id`, `accepted` (vs `rejected`), screenshot the
  TA1 detail in the Acks page → `screenshots/05-ta1.png`.
- On timeout: soft-fail; record `ta1_status: "timeout"`.

### Phase 6 — Wait for 999 (API poll)

- `GET /api/acks?since=<phase4_ts>` in a loop, every 10s, with a
  15 min ceiling.
- Match by `original_batch_id` (or, if not available, by
  `functional_group_control_number` from the submitted file's GS06).
- On hit: capture `ack_id`, `status` (`A` = accepted, `R` = rejected,
  `E` = rejected with errors), the rejection segment details if
  rejected.
- On `A`: screenshot the Acks page → `screenshots/06-999.png`.
- On `R`: hard-fail; record `ack.status == "rejected"`, the rejection
  segments, and the recommended remediation in the report.
- On timeout: soft-fail; record `ack_999_status: "timeout"`.

### Phase 7 — Scan for HTML + final report (API + local)

- `GET /api/admin/scheduler/processed-files?since=<phase4_ts>` — list
  inbound files the MFT scheduler saw during the run. If any has
  `file_type == "html"` or an unrecognized extension, log a WARN,
  capture the filename, and add it to the report's "Detected artifacts"
  section.
- Generate `report.md` and `report.json` (see §5).
- Screenshot the Inbox page → `screenshots/99-final-inbox.png`.
- Emit a one-line summary on stdout for shell pipelines:
  `RESULT: PASS  run=2026-06-21-1430-001  ta1=TA1-42  999=999-43  835=deferred`.

## 5. Output format

### 5.1 `report.md` (human)

```markdown
# Cyclone pipeline run — 2026-06-21 14:30 PDT

| Field         | Value                              |
|---------------|------------------------------------|
| Run id        | 2026-06-21-1430-001                |
| Input         | /path/to/axiscare-837p.txt (12.4 KB) |
| Result        | PASS — TA1 + 999 received, 835 deferred |
| Duration      | 2m 14s                             |

## Phase summary

| Phase                  | Status | Duration | Detail                                  |
|------------------------|--------|----------|-----------------------------------------|
| 1. Pre-flight          | OK     | 0.4s     | health.ok, scheduler running            |
| 2. Upload              | OK     | 2.1s     | 1 attempt, 17 claims parsed             |
| 3. Verify parse        | OK     | 0.8s     | claim_ids: CLM-001 … CLM-017            |
| 4. SFTP submit         | OK     | 4.3s     | filename 11525703-837P-20260621143012345-1of1.txt |
| 5. Wait TA1            | OK     | 12s      | ack id: TA1-42, accepted                |
| 6. Wait 999            | OK     | 1m 14s   | ack id: 999-43, status Accepted         |
| 7. Scan HTML + report  | WARN   | 0.2s     | 1 HTML file in inbound, skipped; report.md + report.json written |

## Inbound artifacts detected

- 1 HTML file (`gainwell_status_20260621.htm`) — not parsed, not a failure.

## Expected next steps

- **835 expected Monday** (2026-06-28; computed as next Monday in
  Mountain Time at the moment of report generation; if today is
  Monday before noon, expected same day, otherwise next Monday).
  Re-run: `cyclone-pipeline check-835 2026-06-21-1430-001`

## Artifacts

- screenshots/01-preflight.png
- screenshots/02-upload-1.png
- screenshots/03-parse-complete.png
- screenshots/04-submit.png
- screenshots/05-ta1.png
- screenshots/06-999.png
- screenshots/99-final-inbox.png
```

### 5.2 `report.json` (machine)

```json
{
  "run_id": "2026-06-21-1430-001",
  "input": {
    "path": "/path/to/axiscare-837p.txt",
    "size_bytes": 12700,
    "sha256": "…"
  },
  "result": "pass",
  "result_detail": "TA1 + 999 received, 835 deferred",
  "started_at": "2026-06-21T14:30:00-06:00",
  "finished_at": "2026-06-21T14:32:14-06:00",
  "duration_s": 134,
  "phases": [
    {"n": 1, "name": "preflight", "status": "ok", "duration_s": 0.4},
    {"n": 2, "name": "upload",    "status": "ok", "duration_s": 2.1, "attempts": 1},
    {"n": 3, "name": "verify_parse", "status": "ok", "duration_s": 0.8, "claim_ids": ["CLM-001", "…"]},
    {"n": 4, "name": "submit",    "status": "ok", "duration_s": 4.3, "filename": "11525703-837P-…"},
    {"n": 5, "name": "wait_ta1",  "status": "ok", "duration_s": 12, "ta1_id": "TA1-42", "accepted": true},
    {"n": 6, "name": "wait_999",  "status": "ok", "duration_s": 74, "ack_999_id": "999-43", "status": "A"},
    {"n": 7, "name": "scan_and_report", "status": "warn", "duration_s": 0.2, "html_files": ["gainwell_status_20260621.htm"], "report_paths": {"md": "report.md", "json": "report.json"}}
  ],
  "artifacts": {
    "screenshots": ["01-preflight.png", "…", "99-final-inbox.png"],
    "report_md": "report.md",
    "report_json": "report.json",
    "run_log": "run.log"
  },
  "next_steps": {
    "check_835": "cyclone-pipeline check-835 2026-06-21-1430-001",
    "expected_835_by": "2026-06-28"
  }
}
```

On soft-fail (TA1 or 999 timeout) or hard-fail (999 rejected), the
`result` is `"soft_fail"` or `"hard_fail"`, the offending phase gets
`status: "timeout"` / `"rejected"`, and a `failure` block is added
with the traceback, recommended remediation, and the `failure.md` path.

### 5.3 Exit codes

| Code | Meaning |
|------|---------|
| 0    | PASS — TA1 + 999 both received, both accepted |
| 2    | SOFT_FAIL — TA1 or 999 timed out; submission in flight, manual check needed |
| 3    | HARD_FAIL — 999 rejected, upload failed irrecoverably, or submit refused |
| 4    | USAGE — bad CLI args, missing file, backend unreachable after retries |

This mirrors common CI conventions so OpenClaw / Nora / shell scripts
can branch on `$?` without parsing JSON.

## 6. Library API (for OpenClaw / Nora)

```python
from cyclone_pipeline import CyclonePipeline, RunResult

async def main():
    pipeline = CyclonePipeline(
        api_base="http://127.0.0.1:8000",
        browser_base="http://127.0.0.1:5173",
        run_dir="./runs",
        headless=False,
        timeouts={"ta1_s": 300, "ack_999_s": 900},
    )

    result: RunResult = await pipeline.run(file_path="axiscare-837p.txt")
    # result.outcome: "pass" | "soft_fail" | "hard_fail" | "usage"
    # result.exit_code: int (0/2/3/4; mirrors CLI exit codes in §5.3)
    # result.report: RunReport (Pydantic model; serializes to report.json)
    # result.run_id: str

    # Or batch:
    results = await pipeline.run_batch(file_paths=[...], strict=False)

    # Or resume:
    result = await pipeline.resume(run_id="2026-06-21-1430-001")

    # Or check 835 later:
    ack = await pipeline.check_835(run_id="2026-06-21-1430-001")
    # ack.found, ack.remittance_id, ack.total_paid, ...
```

`RunResult` is a Pydantic model so OpenClaw / Nora can serialize it
straight to JSON without any extra glue.

## 7. Tests

| Test file | Strategy |
|---|---|
| `tests/test_api_client.py` | `respx` mock; verify typed responses, retry-on-idempotent, no-retry-on-POST, timeout behavior |
| `tests/test_waiters.py` | Inject fake clock + fake state; verify backoff math, timeout, predicate resolution |
| `tests/test_browser.py` | `pytest-playwright` against a Vite dev server spun up in CI; covers `safe_click`, `set_input_files`, progress wait |
| `tests/test_pipeline.py` | Mock both `api_client` and `browser`; drive fake "happy path" through every phase + "phase 5 timeout" + "phase 3 parse error" + "phase 6 reject" |
| `tests/test_report.py` | Golden-file comparison for `report.md` and `report.json` |
| `tests/test_cli.py` | Click's `CliRunner`; covers all 5 subcommands + error paths |
| `tests/test_check_835.py` | Mock `GET /api/remittances?since=…`; happy path + "835 not yet present" path |

CI: GitHub Actions matrix on Python 3.11 + 3.12. Non-Playwright tests
run on every PR; Playwright tests gated to `main` push (the Vite
dev-server + chromium setup is too slow for every PR).

Coverage target: 90% line coverage on `pipeline.py`, 85% on the rest.
`browser.py` is exempt from the strict target (Playwright's own
assertion failures are the real test).

## 8. Files to add

- `pyproject.toml` — package metadata, deps, dev deps, scripts entry
- `README.md` — install, run, embed-in-agent examples
- 14 source files under `src/cyclone_pipeline/` listed in §3
- 8 test files under `tests/` listed in §3
- `tests/fixtures/sample_837p.edi` — small valid 837P for tests
  (copied from cyclone's `tests/fixtures/minimal_837p.txt`)

Total: 24 files (1 `pyproject.toml`, 1 `README.md`, 14 src `.py`, 8
test `.py`, 1 fixture).

## 9. Files NOT touched

- No changes to the cyclone repo. The agent is a strict HTTP consumer
  of cyclone's published API.
- No changes to cyclone's DB schema, API, CLI, or UI.
- No new dependencies added to cyclone's `pyproject.toml`.

## 10. Future work (explicit non-goals for v1)

- **Watch-folder daemon** — `cyclone-pipeline watch /inbox/*.txt` that
  runs continuously and submits files as they appear. Achievable with
  the current `run-batch` + a 1-line shell loop; the daemon is just UX.
- **Multi-payer support** — adding a new payer (e.g., a second
  Medicaid MCO) means new payer-specific SFTP block, file naming
  template, and ID-matching strategy. Worth doing once a second payer
  is on the books.
- **Slack / email notification on completion** — `report.json` is the
  integration point; a 50-LOC notifier could post a summary. v1 just
  prints the one-line summary and writes the file.
- **Pushing reports to S3 / a share** — same as above; `report.json`
  is the integration point.
- **Web UI for run history** — out of scope; OpenClaw / Nora can read
  the `runs/` directory and surface the JSON.
- **277CA polling for payers that use it** — straightforward to add
  behind a `--payer-config` flag once a second payer is configured.
  277CA support in cyclone is already shipped; only the agent's wait
  list changes.

## 11. Open questions (none for v1)

None. The design is locked pending user review of this spec.
