payprobe-diagnostics-and-tooling
Measure, don't eyeball: every PayProbe diagnostic tool with an interpretation guide plus ready-to-run scripts. Load this when you need to answer "is the platform healthy?", "why can't X reach Y?", "wh
SKILL.md.claude/skills/payprobe-diagnostics-and-tooling/SKILL.md
GitHubPayProbe diagnostics and tooling
The house rule is measure, never assume — no fix ships without a measured
root cause. This skill is the toolbox that makes measuring cheaper than
guessing: every diagnostic surface in the platform, what its output means, and
executable helpers in scripts/.
All commands are repo-root-relative. All facts below were verified against the code on 2026-07-03; the "Provenance and maintenance" section at the end has one-line re-verification commands.
Setup: URLs and auth
Default ports (from infra/docker/docker-compose.yml):
| Service | Base URL | Notes |
|---|---|---|
| orchestrator | http://localhost:8100 |
runs, load runs, simulators, diagnostics |
| scenario-service | http://localhost:8000 |
scenarios, connections, ISO 8583 inspector |
| auth-service | http://localhost:8300 |
mints JWTs (POST /token) |
| mcp-server | http://localhost:8200 |
MCP Streamable HTTP at /mcp, liveness at /healthz |
| assistant | http://localhost:8600/assistant |
config assistant, mounted inside agent-hub (ADR-0010 D2; no :8400 container) |
| portal | http://localhost:8080 |
UI (Diagnostics, Trends, run reports, Network Trace, Peers pages) |
Auth (orchestrator api/auth.py, scenario-service api/auth.py — same model):
- Always public, never need a token:
/health,/ready,/metrics,/status,/reference,/openapi.json,/docs,/redoc. - Everything else (including
/diagnosticsand all/runs/*) goes through a fail-closed gate: open only whenPAYPROBE_ENVisdev/development/test/localand no credential is configured; otherwise you needAuthorization: Bearer <token>(staticAPI_TOKENor a JWT). - Get a JWT:
curl -s -X POST http://localhost:8300/token -d username=admin -d password=<pw> | jq -r .access_token
Every script in scripts/ honors ORCH_URL (default http://localhost:8100)
and TOKEN (optional bearer; omit in open dev mode).
Tool map — pick by question
| Question | Tool | Where |
|---|---|---|
| Is the platform up at all? | GET /status (public) |
orchestrator |
| Can this replica serve traffic? | GET /ready (503 = no) |
orchestrator |
| Why can't X reach Y? | GET /diagnostics platform doctor |
orchestrator |
| Why did this run fail? | GET /runs/{id}/diagnose |
orchestrator |
| Is this scenario flaky or broken? | GET /runs/flakiness |
orchestrator |
| Getting better or worse over days? | GET /runs/trend |
orchestrator |
| What changed vs the last run? | GET /runs/{id}/compare |
orchestrator |
| Did load performance regress? | GET /load-runs/{id}/compare |
orchestrator |
| Does the client survive chaos? | POST /resilience/runs + scorer |
orchestrator |
| What went over the wire, step by step? | run detail trace (GET /runs/{id}) |
orchestrator / portal Trace tab |
| Where did this transaction travel? | GET /participants/traces |
orchestrator |
| Who is connected right now? | GET /peers |
orchestrator |
| Is this ISO 8583 message well-formed? | POST /iso8583/analyze |
scenario-service |
| Continuous metrics / alerting | /metrics + observability compose profile |
all services + infra/ |
Rule of thumb for connectivity questions, from broad to narrow:
/status (service pulse) → /diagnostics (whole stack, cross-correlated) →
/connections/test (one link) → /runs/{id}/diagnose (one finished run).
1. GET /diagnostics — the platform doctor
One layered pass over every hop that can break, with a concrete fix hint per
failure. Engine: packages/orchestrator/api/diagnostics.py; endpoint wiring in
packages/orchestrator/api/main.py.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/diagnostics" | jq .
# scope it:
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/diagnostics?layers=connections,listeners"
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/diagnostics?connection=visa&environment=mock"
Or: scripts/payprobe-doctor.sh (pretty-prints only the problems + hints).
Portal: Diagnostics page. MCP: diagnose_platform.
Four layers, each check {id, layer, name, target, status, latency_ms, detail, error, hint} with status ∈ ok|warn|fail|skip|disabled:
| Layer | What it checks | Typical failure it catches |
|---|---|---|
services |
scenario-service, auth-service, Redis PING, optional MCP/assistant, run store | dead container, wrong SCENARIO_API_URL, Redis down |
connections |
every saved connection, staged: config sanity → DNS → live adapter probe (env overrides applied first — it tests what a run would actually dial); participant-group member sanity | missing host/port, hostname typo / Docker-vs-localhost mixup, dead target, wrong credentials, group members missing/disabled |
listeners |
every running simulator/participant listener really accepts on its port | listener registered but crashed / failed to bind |
runs |
last 20 runs' connectivity-class step errors, bucketed by taxonomy and cross-linked to live connection state | "recent runs failed 'unreachable' against visa AND visa's probe fails right now" — two mysteries become one answer |
Interpretation:
- Top-level
status:ok(no warn/fail),degraded(warns only),failing(any fail).summarygives counts per status. - Read
failchecks first, in layer order — aservicesfail (e.g. Redis down) explains cascading fails below it. Connection stages short-circuit: a config fail means DNS/probe were never attempted, so the first failing stage is the real problem, not the deepest. hintis the next action, not a restatement. Two hints worth trusting: the localhost cross-check ("dials localhost:5010 but nothing listens there" → start the listener or fix the port), and "TCP connect worked but the sign-on/health exchange failed" → wrong adapter family or dialect mismatch, NOT a network problem.skip/disabledare intentional posture (no connections saved, Redis single-node mode) — they never degrade the verdict.- Checks in the
runslayer carry arunsarray of affected run ids — follow up with/runs/{id}/diagnoseon one of them.
Good output: status: "ok", every check ok/skip/disabled, service
latencies single-digit-to-low-hundreds ms. Bad: any fail; a warn on a
participant group ("unusable members") means the fleet is silently smaller
than configured — fix before trusting load numbers.
2. GET /runs/{id}/diagnose — run failure taxonomy
Turns a finished (or live load) run into ranked findings {code, severity, title, cause, suggestion, evidence}. Pure logic in
packages/report_service/diagnose.py (classify_error, diagnose_run,
diagnose_load). MCP: diagnose_run.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/runs/$RUN_ID/diagnose" | jq '{headline, ok, findings: [.findings[] | {code, severity, title, suggestion}]}'
The taxonomy — what each verdict means and the next step:
| Category | Matched error text (essence) | Meaning | Next step |
|---|---|---|---|
unreachable |
refused / reset / no route / cannot connect / name not known | Nothing accepted the connection — target down or wrong host/port | Start the target (Simulators page) or fix the Connection; confirm with /diagnostics?connection=<name> |
timeout |
timed out / deadline exceeded | Connected but no answer in time — firewall drop, hung target, or overload | Raise timeout only after checking target health; under load this usually means the target is the bottleneck |
auth |
401 / 403 / unauthorized / invalid token | Target rejected credentials | Fix the Connection's key/secret (check the environment override too) |
assertion |
assertion / expected…got / response_code / did not match | The link works; the response content mismatched | Functional issue, not connectivity — verify expected values or fix upstream data (evidence bar: see payprobe-validation-and-qa) |
tls |
tls / ssl / certificate / handshake | Handshake failed | Check http vs https, cert validity, client-cert requirement |
binding |
no connection / not bound / unknown target / not configured | Scenario target isn't bound to a real endpoint in this environment | Attach a Connection or pick an environment that defines it (mock for a dry run) |
reference |
unresolved reference / unknown variable | A ${step.response.field} points at data that doesn't exist at run time |
Open the Trace tab, find the referenced step: did it run, succeed, and return that field? Authoring problem, not network |
url |
no usable base_url / missing http(s):// | Malformed/empty URL — adapter had nothing valid to dial | Set the connection's base_url (or fill the *_base_url variable it interpolates) |
error / unknown |
anything else / no message | Unclassified | Read the raw error and the run trace |
Load-run findings (from diagnose_load), with their exact triggers:
| Code | Trigger (in code) | Meaning |
|---|---|---|
no_workers |
workers expected > 0 but workers_reporting == 0 |
No load was generated at all — start the worker fleet (LOAD_RUN_ID=<id> python -m worker.load_worker) |
all_failing_<cat> |
sent > 0, received == 0, errors > 0 |
Every transaction failed; <cat> is the taxonomy class of last_error |
partial_failures_<cat> |
error rate ≥ 20% | Elevated failures, same taxonomy drill-down |
below_target |
achieved TPS < **0.6 ×** target with pending > 0 |
The target or network is the bottleneck, not the generator |
healthy |
none of the above | No action needed |
Doctor ride-along: when a finding is connectivity-class, the endpoint also
runs a scoped doctor pass (connections + listeners) and attaches its
problems under doctor — "this failed because visa was unreachable, and visa
is STILL down". If doctor.problems is empty, the outage was transient.
3. GET /runs/flakiness — intermittent scenarios
packages/orchestrator/api/run_store.py::flakiness(). Portal: Trends page
"Flaky scenarios" card. MCP: run_flakiness.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/runs/flakiness?days=30&min_runs=3" | jq .
# or: scripts/flakiness-report.sh 30
Definition (exact, from code): for each scenario seen ≥ min_runs times
(default 3) in the window, take its time-ordered pass/fail sequence. A
scenario is flaky only if it both passed and failed — always-pass is
stable, always-fail is a regression (shows up in trend/compare instead).
flips = count of consecutive-run result changes;
score = flips / (runs − 1), in 0..1.
Interpretation:
score = 1.0— alternates every single run: almost certainly ordering, shared-state, or timing dependent, not a real product bug.- A single blip in many runs scores low (e.g. 1 flip in 10 runs = 0.11) — check
last_status: iffailed, it may be a fresh regression that just started, not flakiness. - High
score+ roughly evenpassed/failedsplit is the classic race/timeout signature. (Bands like "≥0.5 = severe" are guidance, not code.) - Empty result ≠ all good — it means nothing flipped. Pair with
/runs/trendto catch stable-but-failing scenarios. - Known failures may not accumulate (house rule): a flaky scenario is work to schedule, not noise to ignore.
4. GET /runs/trend — daily regression health
run_store.trend(): per-day buckets {date, runs, passed, failed, scenarios_total, scenarios_passed, pass_rate} (last days buckets with
data; pass_rate = scenario-level, rounded to 4 places, null on days with
no scenario counts). MCP: run_trend.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/runs/trend?days=30&label=nightly" | jq .
Interpretation: watch pass_rate direction, not single days. A step-change
down that persists = a regression landed that day (bisect with
/runs/{id}/compare). Sawtooth pass_rate with flat run counts = flakiness —
cross-check section 3. Use label= to keep like-for-like (e.g. only the
nightly schedule).
5. GET /runs/{id}/compare — run-vs-run functional diff
Diffs per-step statuses against a base run (default: previous run with the
same label, falling back to the previous run of any label). MCP:
compare_runs.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/runs/$RUN_ID/compare" | jq '{base_run_id, base_found, summary}'
Change kinds: regressed (was passing → now failing) — the only one that
blocks; fixed (failing → passing); changed (e.g. failed → error);
added / removed (scenario set drifted — if unexpected, your comparison is
not like-for-like and the counts are noise). Always check base_found: true
and that base_run_id is the run you think it is before trusting the verdict.
6. GET /load-runs/{id}/compare — performance regression verdict
Diffs a load run against a base (default: previous finished load run with the
same label; load labels carry a load: prefix so it never falls back to a
functional run). Implementation: _compare_load_summaries in
packages/orchestrator/api/main.py. MCP: compare_load_runs. Portal: "vs
previous run" panel on the Load page.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/load-runs/$RUN_ID/compare" | jq '{base_run_id, summary, deltas}'
Six deltas, each {current, base, delta, pct, direction, regressed, improved}:
| Metric | Worse when | Notes |
|---|---|---|
latency_p50 / latency_p95 / latency_p99 |
up | p99 moves first under saturation |
error_rate |
up | errors / (errors + received) |
tps |
down | achieved throughput |
tps_cv |
up | throughput stability (see below) |
Dead-band: changes under 2% relative (eps = 0.02) read as flat —
never regressed/improved — so sub-2% jitter can't cry wolf.
summary.regressions / summary.improvements count metrics outside the band.
How to read a regression verdict:
regressions ≥ 1witherror_rateup → look aterror_categories(list of{category, current, base, delta}, taxonomy names from section 2): a new or growing category names the failure mode (e.g.timeoutgrew = target saturating;unreachableappeared = something died mid-run).tpsdown + latencies up +error_rateflat → the target got slower, the client is fine.tps_cvup alone → same average throughput but unstable delivery.tps_stability(persisted on the run summary) ={mean, stdev, cv, min, max, samples}over the per-secondtps_series, leading warm-up zeros trimmed.cv= stdev/mean, unit-free; 0.0 is perfectly flat, and an interior dropout shows asminfar belowmeaneven whenmeanlooks fine. (Exact bands are guidance: single-digit-% cv is steady.)base_found: false→ no verdict, onlycurrentvalues. Pick a base explicitly with?to=<run_id>.
7. Resilience certification — /resilience/runs
Scores whether a client survives injected chaos. Scorer:
packages/orchestrator/api/resilience.py (pure, unit-tested); endpoints
GET/POST /resilience/runs, GET /resilience/runs/{rid}. Portal:
/resilience page. Prereqs: a running load run (load_run_id) and a
running chaos-capable simulator (target_sim_id) — the chaos dial lives
at GET/PUT /simulators/{sid}/chaos and storms at
POST /simulators/{sid}/chaos/storm. On a shared deployment, chaos on a
simulator that other runs/schedules depend on is behavior-changing — coordinate
per payprobe-change-control.
Three sampled stages: baseline (calm) → storm (chaos on) → recovery
(chaos lifted). Per-second cumulative samples feed four component scores:
| Component | Weight | Measures |
|---|---|---|
availability |
0.35 | storm success rate as a fraction of baseline success rate |
absorption |
0.30 | injected faults the client recovered from (retry/reconnect): 1 − error_rate/fault_rate during the storm |
recovery |
0.25 | post-storm success rate vs baseline |
latency |
0.10 | storm p95 between baseline p95 (full marks) and the ceiling (zero) |
Hard gates (any failure ⇒ FAIL regardless of score; defaults, tunable via the
request's thresholds):
| Gate | Passes when | What a failure means |
|---|---|---|
availability |
storm success ≥ baseline × 0.90 | client lost >10% of its success rate under chaos |
no_wedge |
NOT (sim answered some requests cleanly AND client got nothing through) | the client wedged — hung connections, no failover |
recovery |
recovery success ≥ baseline × 0.98 | client did not heal after the storm passed — the worst verdict (leaked sockets, poisoned pools) |
latency |
storm p95 ≤ baseline p95 × 4.0 | latency blew through the ceiling |
Verdict: PASS iff overall score ≥ 70 (pass_score) AND no gate
failed. Score = weighted component sum, 0–100. Grades: A+ ≥95, A ≥90, B ≥80,
C ≥70, D ≥55, else F.
Interpretation: read blocking (failed gate ids) before the grade — a B
with a failed recovery gate is a FAIL and worse than a plain C. Check
notes: "no faults were injected during the storm stage" or "no client
traffic recorded" means the certificate measured nothing — fix the storm
config / load run and re-run rather than quoting the number. Component
absorption mirrors availability when no faults landed (neutral, not
earned).
8. Prometheus /metrics + Grafana
Every service exposes /metrics (Prometheus text format, public). Orchestrator
series are defined in packages/orchestrator/api/observability.py; the full
operator guide is infra/OBSERVABILITY.md.
# spot-check without Prometheus:
curl -s "$ORCH_URL/metrics" | grep '^payprobe_' | head -30
# bring up Prometheus (:9090) + Grafana (:3000, admin/admin):
docker compose -f infra/docker/docker-compose.yml --profile observability up
Key metric names (grep-able):
| Metric | Labels | Reading it |
|---|---|---|
payprobe_http_requests_total |
method, route, code | request rate + error codes per route |
payprobe_http_request_duration_seconds |
route | service latency |
payprobe_http_inflight_requests |
— | concurrency right now |
payprobe_runs_inflight / payprobe_runs_total |
— / status | scenario-run load and outcomes |
payprobe_load_runs_inflight |
— | active load runs on this replica |
payprobe_load_run_tps |
run_id | achieved throughput, live |
payprobe_load_run_errors |
run_id | cumulative errors, live |
payprobe_load_run_workers_reporting |
run_id | fleet liveness — fewer than expected ⇒ fleet not fully up |
payprobe_load_worker_rss_bytes |
run_id, worker_id | per-worker resident memory — the soak leak signal |
payprobe_simulator_up / _messages_received / _rps / _faults |
sim_id, label | simulator liveness, traffic, chaos faults injected |
Interpretation shortcuts (from infra/OBSERVABILITY.md):
workers_reporting < expected ⇒ fleet not fully up; errors rising with tps
flat ⇒ the target is rejecting. Soak/leak: a clean cyclic soak's RSS
saw-tooths and its 10-minute growth rate averages ~0; a monotonic climb trips
the WorkerMemoryLeak alert (infra/prometheus/rules/payprobe-soak.rules.yml).
Grafana auto-provisions "PayProbe — Load & Service Health" plus a simulators
dashboard (infra/grafana/provisioning/dashboards/). Scrape targets:
infra/prometheus/prometheus.yml (orchestrator:8100, scenario-service:8000,
auth-service:8300; workers not scraped yet — roadmap).
Optional tracing: init_tracing() is a no-op unless the OTel SDK is installed
AND OTEL_EXPORTER_OTLP_ENDPOINT is set.
9. Execution trace — wire bytes + engine log + waterfall
Every step outcome carries (defined in packages/worker/engine/runner.py::StepOutcome):
started_at_ms(epoch ms) +duration_ms— lay steps on a shared timeline; the portal Trace tab renders this as the timing waterfall.raw_log— adapter-provided wire detail (e.g. exact ISO 8583 bytes sent/received), secret-redacted before it leaves the worker.trace— structured engine-log lines{level, msg}with levelsinfo(dispatch/response),debug(request/response payloads),wire(raw_log folded in),pass/fail(per assertion, with expected → actual),error.
Where to get it: GET /runs/{id} (the full run detail JSON contains all of
it, under summary.scenarios[].steps[]), or portal run report → Trace
tab → ⤓ Trace JSON export. scripts/collect-run-bundle.sh extracts a
standalone trace.json for you.
How to read it: find the first fail/error line — everything after is
usually consequence. For an assertion verdict, the pass|fail lines show
assert <field> <op> <expected> → <actual> — the actual value came off the
wire, so compare it with the wire lines to decide whether the target
returned the wrong thing or the scenario expected the wrong thing. For a
reference verdict, walk back to the referenced step's debug response
payload line and check the field really exists. Gaps in the waterfall (step
start ≫ previous step end) are orchestration/queueing time, not target time.
10. Network trace — /participants/traces
Cross-hop, per-transaction trace across the participant-flow network (a correlation id ties hops together; for ISO 8583 that's derived from the message, e.g. searchable by STAN). Portal: Network Trace page.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/participants/traces?limit=50&q=000123" | jq .
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/participants/trace/$CID" | jq .
Index rows: {correlation_id, ts, mti, kind, flows[], hops, ok} — ok:false
means at least one hop failed. Detail: every hop tagged with that correlation
id across all live listeners, time-ordered, each hop including its downstream
calls.
Interpretation: ok:false → open the detail and find the first failing
hop — failed downstream hops are preserved in the flow trace, so a missing
reply from the issuer shows as a failed call on the switch hop, not as
silence. Fewer hops than your topology has participants = the message never
reached the later legs (cross-check /diagnostics listeners layer). Traces
live in memory on the listeners: DELETE /participants/traces clears them for
a clean experiment window.
11. GET /peers — who is connected right now
Live Sessions view: every inbound client socket connected to a listener we
host (simulators + participant flows), plus the outbound upstream legs of
transparent proxies. Per-run engine adapter sockets in the worker are NOT
reported (known limitation, in code docstring). Portal: /peers page.
Per-simulator counters: GET /simulators/{sid}/metrics.
curl -s -H "Authorization: Bearer $TOKEN" "$ORCH_URL/peers" | jq '{counts, inbound: [.inbound[] | {peer, label, local_port, protocol}]}'
Interpretation: an expected client absent from inbound = the client side
never connected — go to /diagnostics (its dial is failing) rather than
staring at the listener. Persistent-connection protocols (ISO 8583 over TCP)
should show long-lived peers; a churn of short-lived entries for the same
source is a reconnect loop (guidance: check the client's timeout vs the
simulator's chaos/latency settings).
12. ISO 8583 inspector — analyze / build / diff
Scenario-service endpoints (packages/scenario-service/api/main.py), also on
MCP as iso8583_analyze / iso8583_build / iso8583_diff /
iso8583_tlv_build. Portal: Inspector page. Default field table is the 1987
dialect; pass fields (a {de: {name,len_type,length,type}} table) or a
saved Message Format's fields for other dialects; encoding accepts
"ascii", "binary", or a fine-grained dict — binary messages travel as hex
strings.
# decode + validate a wire message:
curl -s -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-X POST "$SCENARIO_URL/iso8583/analyze" -d '{"message": "<wire or hex>"}' | jq .
# build (validates values first):
curl -s -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-X POST "$SCENARIO_URL/iso8583/build" \
-d '{"mti":"0200","values":{"2":"4111111111111111","4":"000000010000","49":"840"}}' | jq .
# field-level diff of two messages:
curl -s -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-X POST "$SCENARIO_URL/iso8583/diff" -d '{"a":"<msg1>","b":"<msg2>"}' | jq .
Interpretation of analyze output: mti_info decodes the four MTI digits
(version / class / function / origin — e.g. 0210 = 1987 Financial
request-response from acquirer). bitmap.present lists the DEs actually on
the wire; bitmap.secondary non-null means DEs > 64 are present. Each
fields[] row carries de, value, a human interpretation (DE39 00 =
"Approved", 51 = "Insufficient funds"; DE49 840 = USD; PANs come back
masked), and — the important one — an error key when the value violates the
field spec (wrong length/type). Any error = the message is malformed per the
table you gave it; if the counterparty accepts it anyway, your field table
(dialect) is wrong, not the message. build returns {message, errors[]} and
refuses to pack invalid values — use it to produce known-good probes. diff
returns added/removed/changed DEs — ideal for "what changed between the
request that works and the one that doesn't".
13. MCP diagnostic tools
The MCP server (packages/mcp-server, Streamable HTTP :8200/mcp or stdio)
proxies the same APIs for AI sessions. Diagnostics-relevant tool names
(verified in packages/mcp-server/mcp_server/tools.py):
diagnose_platform, diagnose_run, test_connection, run_flakiness,
run_trend, compare_runs, compare_load_runs, run_junit, list_runs,
get_run, list_load_runs, get_load_run, list_load_workers,
iso8583_analyze, iso8583_build, iso8583_diff, iso8583_tlv_build,
list_simulators, get_simulator, list_participants, list_topology_runs,
run_certification.
The registry is far larger (144 registered tools + 8 resources as of 2026-07-06 — CRUD for every store; the count drifts, recount before quoting). List them all:
# authoritative count (imports the registry; def-grep undercounts helpers)
cd packages/mcp-server && python3 -c "import sys; sys.path.insert(0,'.'); \
from mcp_server import registry; \
print(len(registry.TOOL_SPECS), 'tools,', len(registry.RESOURCE_SPECS), 'resources')"
MCP liveness is GET :8200/healthz (the /mcp route only answers POST; a
bare GET is a 405, and a 421 means the DNS-rebinding guard rejected your Host
header — set MCP_ALLOWED_HOSTS).
Scripts
Executable helpers in scripts/ (bash + curl + jq only; each has a usage
header; all honor ORCH_URL and TOKEN):
scripts/payprobe-doctor.sh — full platform verdict
cd <repo-root>/.claude/skills/payprobe-diagnostics-and-tooling
./scripts/payprobe-doctor.sh # liveness + /ready + /status + /diagnostics
TOKEN=$(curl -s -X POST localhost:8300/token -d username=admin -d password=admin | jq -r .access_token) \
./scripts/payprobe-doctor.sh
LAYERS=connections,listeners CONNECTION=visa ./scripts/payprobe-doctor.sh # scoped doctor pass
Exit code: 0 healthy, 1 degraded/failing, 2 something unreachable. Prints only warn/fail checks, each with its fix hint.
scripts/collect-run-bundle.sh — one directory with all evidence for a run
./scripts/collect-run-bundle.sh <run_id> # → ./run-bundle-<run_id>/
./scripts/collect-run-bundle.sh <run_id> /tmp/evidence
Fetches run.json (full detail), extracts trace.json (per-step timing +
wire log + engine log — the waterfall data), junit.xml, report.html,
diagnose.json, compare.json, and — when the id is a load run —
load-run.json + load-compare.json. Missing artifacts are skipped with a
warning, never fatal. Attach the directory to a bug report or hand it to a
review; it is the "measured, not assumed" evidence pack.
scripts/flakiness-report.sh — flakiness + trend in one view
./scripts/flakiness-report.sh # last 30 days, min 3 runs
./scripts/flakiness-report.sh 14 nightly # 14 days, label=nightly only
MIN_RUNS=5 ./scripts/flakiness-report.sh 30
Prints the flaky-scenario table (score, flips, runs, last status) and the daily pass-rate trend, followed by the interpretation cheat-sheet.
Script assumptions (documented, verified): endpoints as in this skill; jq
and curl on PATH; orchestrator on ORCH_URL (default http://localhost:8100);
TOKEN optional only when the dev auth gate is open. Syntax-checked with
bash -n.
When NOT to use this skill
- You measured it and now need to fix it →
payprobe-debugging-playbook(symptom→triage, time-costing traps). - You need to decide whether evidence is good enough to claim pass/ship
(acceptance thresholds, golden inventory, certification/sign-off standards)
→
payprobe-validation-and-qa. - The stack won't come up at all / compose anatomy →
payprobe-run-and-operate; dev-env setup traps →payprobe-build-and-env. - What a config knob does →
payprobe-config-and-flags. - History of a past investigation →
payprobe-failure-archaeology.
Provenance and maintenance
Authored 2026-07-03 against the live repo; every endpoint, field, threshold and metric name above was read from source (not from docs or memory). Volatile facts and how to re-verify each:
| Claim | Re-verify with |
|---|---|
| Orchestrator routes (diagnostics, diagnose, flakiness, trend, compare, resilience, peers, traces, metrics) | grep -n '@app\.\(get|post\)' packages/orchestrator/api/main.py |
| Doctor layers, statuses, hints | sed -n '1,60p' packages/orchestrator/api/diagnostics.py |
| Taxonomy categories + load findings (20% / 0.6× triggers) | sed -n '32,190p' packages/report_service/diagnose.py |
| Flakiness score = flips/(runs−1), min_runs=3 | grep -n -A15 'def flakiness' packages/orchestrator/api/run_store.py |
| Load-compare metrics + 2% dead-band | grep -n -B2 -A20 'def _delta' packages/orchestrator/api/main.py |
| Resilience weights 35/30/25/10, gates 0.90/0.98/4.0, pass 70, grade bands | grep -n -A8 'WEIGHTS|class ResilienceThresholds|def _grade' packages/orchestrator/api/resilience.py |
| Metric names | grep -n 'payprobe_' packages/orchestrator/api/observability.py and infra/OBSERVABILITY.md |
| StepOutcome trace fields | grep -n -A20 'class StepOutcome' packages/worker/engine/runner.py |
| ISO 8583 endpoints + shapes | grep -n 'iso8583' packages/scenario-service/api/main.py; examples in packages/scenario-service/tests/test_iso8583_analyzer.py |
| MCP tool names | grep -E '^def [a-z_]+\(' packages/mcp-server/mcp_server/tools.py |
| Ports / compose profiles / token mint command | infra/docker/docker-compose.yml |
| Public (unauthenticated) paths | grep -n -A4 'PUBLIC_PATHS' packages/orchestrator/api/auth.py packages/scenario-service/api/auth.py |
| Scripts still parse | bash -n .claude/skills/payprobe-diagnostics-and-tooling/scripts/*.sh |
If any re-verification disagrees with this file, the code wins — update this skill in the same change.