payprobe-debugging-playbook
Symptom-to-cause triage for PayProbe failures. Load this when something is broken and you need to find out WHY: load runs stuck as "running" forever, crypto nodes returning {"error": ...} dicts, tests
SKILL.md.claude/skills/payprobe-debugging-playbook/SKILL.md
GitHubPayProbe Debugging Playbook
Triage runbook for PayProbe's known failure modes. Every endpoint, flag, and symbol below was verified against the repo on 2026-07-03.
Jargon, defined once:
- Orchestrator — FastAPI service (
packages/orchestrator/api/main.py) that owns run lifecycle, simulators, topologies, load runs. - Scenario-service — FastAPI service (
packages/scenario-service) holding scenarios, connections, formats, environments. - Worker engine —
packages/worker/engine/runner.py; executes scenario steps via protocol adapters. - Load run — distributed load test coordinated by the orchestrator; its run rows carry a
load:label prefix. - Topology — a set of participant flows (simulated network nodes) started/stopped as one unit.
- ISO 8583 — the binary message format card networks use; PayProbe frames it over TCP with a length prefix.
When NOT to use this skill
- You want to measure something (latency, TPS, memory) or need interpretation guides for the diagnostic endpoints →
payprobe-diagnostics-and-tooling. - You suspect the problem was fought before and want the investigation history, dead ends, and rejected fixes →
payprobe-failure-archaeology. - The environment itself won't come up (deps, docker, portal build) →
payprobe-build-and-env. - You have the root cause and are ready to ship a fix →
payprobe-change-control(measured root cause + reproducing test are mandatory before any fix lands).
1. First five minutes
Run these in order; stop at the first thing that looks wrong. $ORCH is the
orchestrator base URL (compose default http://localhost:8100, mapped via ORCH_PORT in infra/docker/docker-compose.yml; adjust to your
deployment). Add -H "Authorization: Bearer $API_TOKEN" to every call except
the public paths (/status and /health among them; full 8-path allow-list in payprobe-diagnostics-and-tooling).
# 1. Is the platform up at all? (public, no auth needed)
curl -s $ORCH/status
# 2. Full platform doctor: services → connections → listeners → recent-run errors,
# each failure with a concrete fix hint.
curl -s -H "Authorization: Bearer $API_TOKEN" $ORCH/diagnostics
# 3. If ONE run failed: automated triage against the error taxonomy
# (unreachable / timeout / auth / assertion / tls / binding / reference / url).
curl -s -H "Authorization: Bearer $API_TOKEN" $ORCH/runs/<run_id>/diagnose
# 4. Is the suite itself green? (ground truth — never debug on a red baseline)
cd <repo-root> && make test
# 5. If load-run history shows phantom "running" runs after a restart:
curl -s -X POST -H "Authorization: Bearer $API_TOKEN" $ORCH/load-runs/reconcile
Checklist mindset:
-
/statusreachable → services up, move to layer 2. -
/diagnosticsclean → problem is scenario/config-level, not platform-level. -
/runs/{id}/diagnosenames a category → jump to that row in the table below. -
make testgreen → your bug is not a pre-existing regression. - Not sure what changed?
git log --oneline -10before touching anything.
2. Symptom → cause → discriminating experiment → fix
| Symptom | Likely cause | Discriminating experiment | Fix pointer |
|---|---|---|---|
Load run shows running forever; portal Load history never settles |
Orchestrator crashed/restarted while the run was live; the in-memory coordinator that would have finalized the SQLite row is gone, so the row is stranded | curl $ORCH/load-runs shows running but the run does not appear in any live coordinator (a fresh process has none). Stranded = row status running, no live owner |
POST /load-runs/reconcile (marks them interrupted), or the portal Load page "Fix stuck runs" button. A startup hook (@app.on_event("startup") → _reconcile_stuck_load_runs, main.py ~line 1872) also runs this on boot; POST /load-runs/{id}/stop reconciles a single stranded run |
Crypto step "fails" with response {"error": "pycryptodome is not installed in this runtime"} |
worker/engine/crypto_tools.py needs Crypto.Cipher (pycryptodome) and degrades to an error dict instead of raising |
python -c "from Crypto.Cipher import DES, DES3" in the runtime that executes the worker. ImportError = dependency; success = different problem |
pip install pycryptodome (declared as pycryptodome>=3.19 in packages/worker/pyproject.toml since 2026-06). Then re-run the failing scenario |
worker/tests/test_engine.py crypto/EMV tests fail locally but pass in CI |
Environment, not regression: sandboxes may lack pycryptodome or the crypto key material these tests need (docs/history/project-review.md records exactly this: "missing key material in this sandbox; they pass in CI") |
Run one failing test with -x and read the actual error. An {"error": ...} dict about pycryptodome or key material = environment. An assertion mismatch on computed values = real regression |
Fix the environment (install deps per payprobe-build-and-env), never edit the test expectation. If CI is also red, treat as regression |
Starting an HTTP-trigger participant flow returns an error naming aiohttp |
The slim orchestrator image doesn't ship aiohttp; HTTP participant listeners import it lazily (main.py ~line 2683 catches ModuleNotFoundError) |
Error text literally says HTTP listeners need aiohttp. python -c "import aiohttp" inside the orchestrator container |
Rebuild the image with aiohttp installed (the current Dockerfile does), or use a TCP trigger connection |
Step errors with KeyError: 'target' on a crypto/code/http node |
Scenario has NO edges array, and something is routing edge-less (linear) execution through the action-step path that reads step["target"]. This exact bug was fixed (see trap story 2) — a recurrence means a new code path bypasses _execute_node kind dispatch in worker/engine/runner.py |
Reproduce with the regression test: cd packages && python -m pytest worker/tests/test_engine.py::test_edgeless_non_action_nodes_run_without_target_keyerror -q |
If that test fails, the linear-path dispatch regressed; diff runner.py around the edge-less loop (~line 1129, 1194) |
| EVERY API call rejected — but is it 401 or 503? | Fail-closed auth gate (packages/orchestrator/api/auth.py, mirrored in packages/scenario-service/api/auth.py) |
503 "auth is not configured; refusing to serve" = no credential configured AND not a dev env. 401 = auth configured but your token is missing/wrong. This status-code split IS the discriminating experiment | Local dev: export PAYPROBE_ENV=dev (accepted values: dev, development, test, local). Real deployments: set API_TOKEN (static bearer) or AUTH_JWT_SECRET/AUTH_JWT_PUBLIC_KEY (JWT), then send Authorization: Bearer <token> |
| WebSocket run stream connects then instantly closes (code 1008) | WS routes skip the app-level auth dependency and are guarded by authorize_websocket, which closes unauthenticated sockets. Browsers can't set WS headers |
Same WS URL with ?token=<API_TOKEN or JWT> appended works |
Pass the credential as ?token= on GET /runs/{run_id}/stream |
| Refreshed the browser mid-run — did I lose events? | No. The stream replays from a cursor | Reconnect with ?last_event_id=<id>; server replays everything after it, then tails live |
Working as designed. Backbone is in-process by default; set REDIS_URL for Redis Streams (needed for multi-replica) |
POST /topologies/{tid}/start → 409 "port collision in topology" |
A flow with N instances shares one inbound connection; the orchestrator plans ports base, base+1, … per instance BEFORE starting anything, and two flows' plans overlap | The 409 body names both flow ids and the exact host:port contested | Adjust the connection's port or instance counts so plans don't overlap. Partial starts are rolled back automatically, so nothing lingers |
| Scenario run → 424 "required topology '…' is not running" | The scenario declares requires_topology and the readiness gate (_topology_is_up) found no run where EVERY participant is still registered and bound — a half-dead topology counts as down |
curl $ORCH/topology-runs and read health ({total, live, ready}) for the topology's run |
Start it: POST /topologies/{tid}/start. If live < total, find which participant died (/diagnostics?layers=listeners) |
| TCP step hangs then times out; the peer definitely answered | Framing mismatch: adapter and responder must agree on framing.length_prefix_bytes (default 2, big-endian). With mismatched framing the reader waits for bytes that never complete a frame |
Read the step's wire log: run detail → step raw_log / Trace tab. You'll see the request go out and either garbage or silence come back. Compare framing blocks on both connection configs |
Align framing config (see packages/worker/adapters/tcp/README.md). Note the chaos dial's bad_length fault deliberately fakes this — check no chaos block is active: GET /simulators/{sid}/chaos |
| TCP steps fail once after a simulator restart, then recover | Expected: the TCP adapter auto-reconnects with exponential backoff + re-sign-on (adapter.py _reconnect, backoff_initial_sec 0.5 → backoff_max_sec 30, max_attempts 0 = unlimited); in-flight exchanges wait for the reconnect, bounded by the response timeout |
Failures only in the window right after a restart, then clean = reconnect working. Persistent failures = the listener never came back (check /peers and /diagnostics?layers=listeners) |
Nothing to fix if it recovers. Tune reconnect.* keys on the connection if the window is too long |
| Simulators / participant listeners gone after orchestrator restart | Runtime desired-state persistence is off: ORCH_RUNTIME_FILE and SIMULATORS_FILE default to :memory: |
echo $ORCH_RUNTIME_FILE $SIMULATORS_FILE in the orchestrator env — empty or :memory: means nothing survives restarts |
Point both env vars at files on a volume; the orchestrator re-launches persisted listeners on boot |
| Run history empty after restart | RUN_DB unset in a dev-ish environment → in-memory SQLite (explicit RUN_DB always wins; non-dev defaults to RUN_DB_PATH or /data/runs.db) |
Check RUN_DB in the orchestrator env |
Set RUN_DB to a file on a volume |
| Simulator keeps failing after a chaos storm "ended" | A manual chaos block is still applied, or the storm never restored baseline (it restores on normal end/cancel; a manual PUT /simulators/{sid}/chaos cancels the storm and takes over) |
GET /simulators/{sid}/chaos — shows the live chaos block, fault count, storm status |
PUT /simulators/{sid}/chaos with {} turns chaos off |
| Load run slower / more errors than last time — regression or noise? | Could be either; never eyeball it | GET /load-runs/{run_id}/compare — diffs vs the previous like-for-like run (error categories, TPS series, stability). Functional runs: GET /runs/{run_id}/compare reports regressed/fixed per step |
If regressed, bisect with the run history; escalate measurement methodology to payprobe-diagnostics-and-tooling |
| A scenario passes sometimes, fails sometimes | Flakiness, not a hard bug | GET /runs/flakiness — per-scenario flip score across history |
Quarantine per the flaky marker convention; root-cause before un-quarantining |
3. Trap stories (why these rows exist)
1. The silently degrading crypto node (2026-06-18). The suite baseline was
180 passed / 3 failed, all crypto scenarios. Nothing crashed: run_crypto()
in worker/engine/crypto_tools.py returns {"error": "pycryptodome is not installed in this runtime"} as a result, so steps "completed" with error
payloads. The worker's pyproject.toml declared python-pkcs11 and iso8583
but omitted pycryptodome. The trap: an error-shaped response is easy to
mistake for a scenario-logic bug or a simulator fault. The fix was one
dependency line (docs/history/PROGRESS.md, Iteration 1). Lesson: when a crypto/code node
"fails", read the response body first — a dependency confession beats an hour
of scenario archaeology.
2. KeyError: 'target' on edge-less scenarios (2026-06, docs/history/PROGRESS.md
Iteration 2). Found while reproducing trap 1. The graph execution path
dispatched steps by kind; the linear (no-edges) path called _run_step()
for everything, which reads step["target"] unconditionally — so any
crypto/code/http node in an edge-less scenario crashed with KeyError: 'target'. Fixed by routing the linear loop through kind-aware
_execute_node(); guarded by
test_edgeless_non_action_nodes_run_without_target_keyerror. Lesson: PayProbe
has TWO execution paths (graph and linear); a bug that "only happens in simple
scenarios" is often the linear path missing something the graph path has.
3. Stuck "running" load runs (fixed 2026-07-03). Load-run state lived in
two places: a durable SQLite row and an in-memory coordinator. Kill the
orchestrator mid-run and the row stays running forever — history lies, and
"is anything running?" checks return phantoms. The fix is reconciliation, not
deletion: rows with no live owner become interrupted with a
{"reconciled": true, "reason": ...} summary (run_store.reconcile_orphans).
It runs at startup, on POST /load-runs/reconcile, and inside
POST /load-runs/{id}/stop. Lesson: any "durable record + in-memory owner"
split needs an orphan-reconcile story; when state looks impossible, ask who
was supposed to write the final status and whether that process survived.
4. The lopsided auth gate (fixed per scenario-service auth.py docstring).
The orchestrator verified JWTs and failed closed; the scenario-service only had
an optional static bearer, off by default. You could lock the orchestrator down
completely while the service holding every scenario, connection, and secret
stayed wide open. Now both services share one gate contract (same env vars,
same fail-closed 503). Lesson: when debugging 401/503s, remember there are
multiple gates that must agree — and that 503 means "gate unconfigured",
not "service down".
5. The slim image without aiohttp. HTTP participant listeners need
aiohttp; the slim orchestrator image historically didn't install it, and a
top-level import would have crashed orchestrator startup entirely. It's
imported lazily and converted to a clear HTTP error instead
(main.py ~2683). Lesson: "works with TCP flows, breaks only with HTTP
trigger connections" is an image-contents problem, not a code problem.
4. Wire-level debugging arsenal
When triage says "the message is wrong" rather than "the platform is broken":
| Tool | What it shows | Where |
|---|---|---|
| Execution trace | Per-step wire bytes (raw_log) + structured engine events (trace) with timing, built by _build_trace in worker/engine/runner.py; secrets redacted |
Run report → Trace tab; fields on each step in GET /runs/{id} |
| Network trace | One transaction's hop-by-hop path across a participant-flow network, including failed downstream hops | GET /participants/traces (index), GET /participants/trace/{cid} (one transaction) |
| Connected peers | Who is actually connected to each listener right now (incl. a proxy's upstream leg) | GET /peers |
| Chronoscope | Time-travel replay of the live network map (8-min buffer), exportable as a replay JSON file and re-importable for offline review | Portal /topology-map-3; recorder in packages/portal/src/app/topologies/chronoscope/ |
| ISO 8583 Inspector | Decode/build raw ISO 8583 messages against a format | Portal Inspector page |
5. Escalation
- Need numbers, not vibes (latency breakdowns, TPS math, interpreting
/diagnosticsoutput in depth, helper scripts): →payprobe-diagnostics-and-tooling. - "Has this been fought before?" (a symptom smells familiar, a fix feels like it was tried and removed — e.g. anything touching connection/environment modeling): →
payprobe-failure-archaeologyBEFORE re-attempting a known dead end. - Ready to fix: no fix ships without a measured root cause and a reproducing test (
payprobe-change-control).make testgreen before and after.
Provenance and maintenance
Authored 2026-07-03 against the repo at that date. Everything above was verified by reading the cited files. Re-verify before trusting, from repo root:
# stuck-run machinery
grep -n "reconcile_orphans" packages/orchestrator/api/run_store.py packages/orchestrator/api/main.py
grep -n "load-runs/reconcile\|Fix stuck runs" packages/orchestrator/api/main.py packages/portal/src/app/load/load.component.ts
# crypto dependency story
grep -n "pycryptodome" packages/worker/pyproject.toml packages/worker/engine/crypto_tools.py docs/history/PROGRESS.md
# edge-less KeyError regression guard
cd packages && python -m pytest worker/tests/test_engine.py::test_edgeless_non_action_nodes_run_without_target_keyerror --collect-only -q; cd ..
# auth gate env vars (both services)
grep -n "PAYPROBE_ENV\|API_TOKEN\|AUTH_JWT" packages/orchestrator/api/auth.py packages/scenario-service/api/auth.py
# diagnose taxonomy + doctor
grep -n "def classify_error" -A 25 packages/report_service/diagnose.py
grep -n '@app.get("/diagnostics")\|/runs/{run_id}/diagnose\|/runs/{run_id}/compare\|/load-runs/{run_id}/compare\|/runs/flakiness' packages/orchestrator/api/main.py
# topology port plan + readiness gate + 424
grep -n "port collision\|requires_topology\|_topology_is_up" packages/orchestrator/api/main.py
# stream resume + persistence env vars
grep -n "last_event_id\|ORCH_RUNTIME_FILE\|SIMULATORS_FILE\|RUN_DB" packages/orchestrator/api/main.py
# TCP reconnect/framing
grep -n "_reconnect\|length_prefix_bytes" packages/worker/adapters/tcp/adapter.py
Volatile facts most likely to drift: orchestrator main.py line numbers
(cited approximately), the ORCH_PORT compose mapping, and the
@app.on_event("startup") style (FastAPI is migrating the ecosystem to
lifespan handlers).