payprobe-research-methodology
WHEN a claim counts as proven in PayProbe — the evidence bar and idea lifecycle that turn a hunch into an accepted result. Load this when you are about to claim a root cause, propose a fix or new mech
SKILL.md.claude/skills/payprobe-research-methodology/SKILL.md
GitHubPayProbe Research Methodology — from hunch to accepted result
Everything below is grounded in this repo's verified history (commits, specs, docs/history/PROGRESS.md). This is not aspiration; it is how results have actually been accepted here — plus the codified refutation step that makes the pattern explicit.
Terms used once, defined once
- Root cause — the mechanism that produces the symptom, not the place the symptom appears.
- Flag-gated — new behavior sits behind an environment variable/flag, default OFF, so one line reverts it.
- No-op — a change proven to alter nothing in the live system's observable behavior.
- TPS — transactions per second (load-test throughput unit).
- ADR — Architecture Decision Record, in
docs/adr/.
When NOT to use this skill
| You actually want | Go to |
|---|---|
| Concrete analysis recipes (queueing math, latency budgets, worked examples) | payprobe-proof-and-analysis-toolkit |
| The change gates themselves (what may merge, review rules, non-negotiables) | payprobe-change-control |
| What to research next / the four state-of-the-art ambitions | payprobe-research-frontier |
| Symptom → triage for a live bug (you're firefighting, not researching) | payprobe-debugging-playbook |
| Past investigations' outcomes (what was tried and rejected) | payprobe-failure-archaeology |
| Which doc to record a result in, and its template | payprobe-docs-and-writing |
| What counts as a passing test / acceptance threshold | payprobe-validation-and-qa |
1. The evidence bar
A root cause is accepted only when BOTH hold:
- One mechanism explains ALL observations — including the negatives. It must explain what failed, and why everything else passed. A mechanism that only covers the failing case is a correlation, not a cause.
- It survived assigned adversarial refutation. Before acceptance, a specific session/person is ASSIGNED the job of breaking the claim — propose a rival mechanism, find an observation the claim can't explain, or construct an experiment whose outcome would differ under the rival. Acceptance without a named refuter is provisional.
House precedent (verified in docs/history/PROGRESS.md, baseline 2026-06-18). Suite at
180 passed / 3 failed. The accepted root cause — worker/pyproject.toml
declared python-pkcs11 and iso8583 but omitted pycryptodome, so
run_crypto() in worker/engine/crypto_tools.py returned
{"error": "pycryptodome is not installed in this runtime"} — is recorded
under the header "Root cause (measured, not assumed)" and passes the bar:
- Explains all three failures: exactly the three
test_engine.pytests with kind=cryptonodes, no others. - Explains the negatives: the other 180 tests never enter the crypto path, so they pass. The degradation was silent (error dict, not exception) — which also explains why nothing crashed.
- Verified by prediction: declaring the dependency should flip exactly those 3 → suite went 180→183 passed, 0 failed. It did.
Note what the bar bought: while reproducing (not guessing), the investigation
surfaced a second latent bug — the edge-less scenario path raising
KeyError: 'target' for non-action nodes — fixed as its own Iteration 2 with
its own new test. Reproduction pays twice.
Refutation assignment in practice. The repo's precedent for hostile review
of one's own results is docs/history/project-review.md (ranked weak spots with
evidence, 2026-06-20) and docs/history/PROGRESS.md's closing "Self-review — weakest part
now" item. This skill codifies the stronger form: name the refuter before
accepting. Give them the claim, the raw observations, and one question: "what
observation would this mechanism NOT explain?" If they produce one, you are
back to hypothesis.
2. Hypothesis predicts numbers — before running
Write down the expected counts, latencies, status codes, pass/fail deltas BEFORE the experiment. A hypothesis compatible with any outcome is worthless — if you can't state what number would falsify it, you don't have a hypothesis yet, you have a mood.
House pattern, verified:
- docs/history/PROGRESS.md iterations record expected vs got. Iteration 1: expected the 3 crypto tests to flip → "Result: full suite 180→183 passed, 0 failed." Iteration 2: new test added, 184 passed, "No regressions." The hardening pass closes with the load-bearing sentence: "Each item verified before moving on; no expected result edited to pass." That sentence is the rule: when reality disagrees with the prediction, the hypothesis changes, never the recorded expectation.
- The platform ships machinery precisely to make numeric predictions
checkable.
GET /load-runs/{run_id}/compare(inpackages/orchestrator/api/main.py) diffs a load run against a baseline; runs persisttps_series+tps_stabilityanderror_categoriesin their summary (packages/orchestrator/api/load_coordinator.py). So a performance hypothesis is stated as "compare vs run X will show ≤N% TPS drop and no new error category" — and the endpoint settles it.
Minimum form before any experiment (put it in the PR/PROGRESS entry):
Hypothesis: <one mechanism, one sentence>
Predicts: <numbers: which tests flip, expected count, expected p99, expected status codes>
Falsified if: <the concrete observation that would kill it>
3. The idea lifecycle
Every idea that changes behavior walks this path. Both terminal states — adopted and retired — are normal; only stuck in the middle is failure.
hunch → root spec (phased) → build flag-gated, default OFF
→ verify (tests + live no-op / safe-state check)
→ flip default ON → …or documented retirement
Each stage, with its verified house exemplar:
| Stage | Rule | Verified exemplar |
|---|---|---|
| Hunch → spec | Behavior-changing ideas get a root-level spec with explicit backward-compatible phases before code | docs/history/DEFAULT-CONNECTION-MODEL-SPEC.md Phases A–F, each phase marked BUILT individually, "each backward-compatible; the fallback is removed last" |
| Build flag-gated, default OFF | The risky phase lands behind a flag | docs/history/CONNECTION-ENV-MIGRATION-PLAN.md Phase 4: "flag-gated, default OFF"; rollback for the flip is "a one-line revert" |
| Verify: tests | Per-phase test matrix in the spec, not ad hoc | Both specs carry a "Test matrix" section naming "the important one" (Phase C / Phase 4) |
| Verify: live no-op check | Before flipping, prove against live state what the flip will actually change | GET /admin/migrate/collisions returns safe_to_flip (packages/scenario-service/api/env_migration.py); the live registry check proved the migration was a no-op — "finalizing the default carried no data risk" (plan header, COMPLETE 2026-06-26) |
| Flip default ON | Only after the above; keep the escape hatch | Two flags now default ON in packages/orchestrator/api/main.py: PAYPROBE_CONNECTION_OVERRIDE_WINS (default "1") and PAYPROBE_DEFAULT_CONNECTION_RESOLUTION (default "1"), each documented in-code as an escape hatch |
| …or documented retirement | If a better model wins, delete cleanly and record why | See below |
Retirement is a result, not a failure. Two verified cases:
endpoints[]+ selection policies: built in commit7253479("implement participant groups with selection policies and routing capabilities"), removed infe59454(2026-06-28) —worker/adapters/routing.py(114 lines) andworker/tests/test_endpoint_selection.py(85 lines) deleted outright, 367 lines removed net; portal models/UI swept in the same commit. Reason: the per-connection endpoint model overlapped confusingly with participant groups. The good part survived:selection.pyand groups were kept. Lessons live inpayprobe-failure-archaeology.- Step environment override: built end-to-end (backend
_attach_step_environmentsinpackages/orchestrator/api/main.pyplus a step-editor dropdown), then the UI was removed when the per-env connection override matrix won as the single value source. Verify today: the backend resolver still exists;environment_overridesurvives only inportal/src/app/constructor/scenario.models.ts, no editor component references it. Partial retirement — keep the mechanism, retire the surface — is also a legitimate outcome.
The gate rules for who approves a flip or a removal live in
payprobe-change-control; this skill defines what evidence the gate demands.
4. Where good ideas came from (verified history)
Prospect where the repo has actually struck ore:
- Periodic honest project reviews.
docs/history/project-review.md(2026-06-20) ranked weak spots with evidence and listed quick wins + new ideas. Traceable idea→shipped: the review proposed an in-app "diagnose my run" helper and a reopenable load-run view — both now marked ✅ in the same doc's status table (report_service/diagnose.py+GET /runs/{id}/diagnose;GET /load-runs+/load/:idreplay). Reviews that name weaknesses honestly generate the roadmap for free. - Standards-gap prioritization.
docs/standards-gap-analysis.mdscores capability against ISO 8583 / scheme-conformance / test-process standards (✅/🟡/❌), tags "(highest-impact gap)" inline, and ends with "Recommended priorities". Gaps ranked against an external yardstick beat gaps ranked by annoyance. - Dogfooding the platform on itself. The review's product-polish idea is explicit that diagnose-my-run is "the manual version of what we just did for '0 TPS'" — a debugging session on PayProbe, by PayProbe's authors, became a PayProbe feature. When an investigation hurts, the pain is a feature spec.
- ADR "Forces" sections.
docs/adr/0001-*lists Forces (reuse the proven bus/heartbeat machinery; listeners are long-lived and addressable; keep the single-host dev path) before Options. Writing forces first exposes which options die on contact — cheaper than building them.
5. Checklist: hunch → accepted result (one page)
Work top to bottom; each unchecked box is where claims die later.
- Define terms. One sentence per ambiguous word in the claim ("slow", "flaky", "the env model"). If two readers could disagree on what passed, it's not defined.
- State the mechanism. One sentence, causal, specific to a code path.
- Predict numbers. Counts / latencies / status codes / which tests flip — written down BEFORE running. Include the falsifier.
- Minimal experiment. Smallest run that can distinguish your mechanism from its nearest rival. Prefer existing instruments:
make test,GET /load-runs/{id}/compare,GET /runs/{id}/diagnose,GET /diagnostics(seepayprobe-diagnostics-and-tooling). - All-observations test. Does the mechanism explain every observation, including what did NOT fail? List the negatives explicitly.
- Adversarial assignment. Name the refuter (person or fresh session with only the raw observations). They get one job: break it. Record their attempt and outcome.
- Spec if behavior changes. Root-level phased spec (model:
docs/history/DEFAULT-CONNECTION-MODEL-SPEC.md). No spec → no behavior change. - Flag if risk. Default OFF, escape hatch documented in-code, rollback = one line. (Flag registry + add-a-flag checklist:
payprobe-config-and-flags.) - Promote or retire. Flip ON only after tests green + live safe-state check (model:
safe_to_flip). If a better model won, delete cleanly in one commit and keep the salvageable parts explicitly. - Record in the right doc. Iteration →
docs/history/PROGRESS.md(expected vs got); decision → ADR; phased change → its spec's status lines; investigation post-mortem → failure archaeology. Which doc + template:payprobe-docs-and-writing.
6. Anti-patterns (each observed, each has a repo counter-example)
| Anti-pattern | Why it burns you | The house counter-example |
|---|---|---|
| Fitting the mechanism to the first observation | First observation of the pycryptodome case was "3 crypto tests fail" — a plausible-but-wrong fix was "fix the crypto math". Measuring found an undeclared dependency and a silent error-dict degradation | docs/history/PROGRESS.md insists "measured, not assumed" and categorizes before fixing ("Category (a): infra/dependency broken") |
| Fixing the symptom, not the category | Patching one failing test leaves the class of bug alive | Iteration 2 fixed the dispatch path (_execute_node, kind-aware) for ALL non-action node kinds in edge-less scenarios, not just the crypto node that surfaced it — and added a test for the category |
| Editing the expectation to match the result | Converts your test suite into a diary | "no expected result edited to pass" — docs/history/PROGRESS.md, hardening pass |
| Keeping dead code "just in case" | Dead paths accrete into a second, unowned model; the confusion cost recurs forever | This repo deletes cleanly: fe59454 removed routing.py + its 85-line test file entirely (−367 lines) the moment the endpoint model lost — no commented-out husks, no deprecation limbo |
| Un-falsifiable hypotheses | "It's probably load-related" fits every outcome | The compare endpoint exists so you must say which number moves and by how much |
| Flipping defaults on faith | A migration you believe is safe is a production incident with a delay | safe_to_flip was computed against the live registry before the precedence flip; it proved the flip was a no-op, and only then did the default change |
Provenance and maintenance
Authored 2026-07-03 against the live tree. Every citation above was verified by the commands below; re-run them if this skill looks stale.
# Evidence bar + expected-vs-got + "no expected result edited to pass"
grep -n "measured, not assumed\|no expected result edited to pass\|180→183" docs/history/PROGRESS.md
# Numeric-prediction machinery
grep -n "load-runs/{run_id}/compare\|error_categories" packages/orchestrator/api/main.py
grep -n "tps_series\|tps_stability" packages/orchestrator/api/load_coordinator.py
# Lifecycle: phases, live no-op check, flags now defaulting ON
grep -n "^### Phase" docs/history/DEFAULT-CONNECTION-MODEL-SPEC.md
grep -n "safe_to_flip\|no-op" docs/history/CONNECTION-ENV-MIGRATION-PLAN.md
grep -n "PAYPROBE_CONNECTION_OVERRIDE_WINS\|PAYPROBE_DEFAULT_CONNECTION_RESOLUTION" packages/orchestrator/api/main.py
# Retirement: built then removed, cleanly
git log --oneline 7253479 -1 && git show --stat fe59454 | tail -15
ls packages/worker/adapters/ | grep -c routing # expect 0
grep -rln "environment_override" packages/portal/src/app/constructor/ # expect only scenario.models.ts
# Idea sources
grep -n "Quick wins\|Diagnose-my-run\|Reopenable load runs" docs/history/project-review.md
grep -n "Recommended priorities\|highest-impact gap" docs/standards-gap-analysis.md
grep -n "### Forces" docs/adr/0001-distributed-topology-on-worker-fleet.md
Volatile facts to re-check on drift: the two flag defaults (they may gain a
successor model), the compare endpoint's summary keys, and whether the
environment_override model field has been fully removed from the portal.
The "assigned adversarial refutation" step is this skill's codification of the
house self-review precedent, not a pre-existing written rule — if
payprobe-change-control later formalizes it as a gate, defer to that wording.