payprobe-architecture-contract
Load this skill when you need to understand WHY PayProbe is built the way it is before changing it: adding/modifying a service, adapter, connection, environment, group, or run-control path; wondering
SKILL.md.claude/skills/payprobe-architecture-contract/SKILL.md
GitHubPayProbe Architecture Contract
The decisions that hold the platform together, WHY each one exists, and what breaks if you violate it. Verified against the repo 2026-07-03. This is a contract: if a change you're planning contradicts something here, either the change is wrong or this document and an ADR must change first — never silently.
Jargon, defined once:
- ISO 8583 — the wire format banks/card networks use for card transactions (bitmap header + numbered data elements).
- HSM — Hardware Security Module; a network appliance that does payment crypto (PIN blocks, CVVs, MACs). PayProbe simulates a Thales payShield 10K.
- Adapter — worker-side client class that speaks one protocol (TCP/ISO 8583, HTTP, gRPC, HSM host commands, DB probe) to a target system.
- Connection — a named, saved configuration record (host/port/protocol…) that becomes an adapter instance at run time.
- Participant flow / group / topology — simulated network members: a flow is one scripted responder, a group is N interchangeable members behind one name, a topology starts a whole set as a unit.
- TPS — transactions per second.
When NOT to use this skill
| You want | Use instead |
|---|---|
| The story of how a decision was reached, dead ends, removed features | payprobe-failure-archaeology |
| Concrete env var / flag values, defaults, add-a-flag checklist | payprobe-config-and-flags |
| Symptom-driven debugging | payprobe-debugging-playbook |
| The ADR-0001 implementation campaign | payprobe-distributed-topology-campaign |
1. Service topology — what runs where and why it's separate
All code lives under packages/. Run the suite from repo root: make test.
| Package | Boundary (what it owns) | Why it is separate |
|---|---|---|
packages/worker |
The async execution engine: adapters, three-phase engine (worker/engine/engine.py), load engine (worker/engine/load/), simulators/responders (worker/adapters/), code-node runner. No HTTP API of its own; it is a library plus python -m worker.load_worker processes. |
Execution must scale horizontally and crash independently of control. Anything that opens sockets to targets lives here so the orchestrator can stay a control plane. |
packages/orchestrator |
Control plane (FastAPI): run lifecycle, phase gating, WebSocket event streaming, load coordination (api/load_coordinator.py), topology/simulator lifecycle, chaos, diagnostics, reports endpoints. |
One place that owns "what is running"; it must survive worker churn and be replaceable per-replica (see run_control invariant). |
packages/scenario-service |
Configuration of record (FastAPI): scenarios, connections, environments, formats, starter flows, participant groups, test data, secrets inventory, agent tools. | Config outlives any run and is edited by humans/AI independently of execution. Separating it lets the orchestrator treat config as read-mostly input (_attach_connections fetches over HTTP). |
packages/auth-service |
Identity: issues short-lived HS256 JWTs; user/role management. Orchestrator + scenario-service only verify (shared AUTH_JWT_SECRET). |
Single source of identity; verification stays local and cheap in every other service (see packages/auth-service/README.md). |
packages/report_service |
A pure library, not a service: JUnit/HTML/certification/diff generators over a run-detail dict (packages/report_service/__init__.py states this contract). Imported by the orchestrator. (packages/report-service/ with a hyphen is a README-only stub.) |
Format logic is dependency-free and testable in isolation; it can be lifted into a real service later without touching format code. Do not add I/O or FastAPI imports to it. |
packages/mcp-server |
AI surface: FastMCP proxy exposing scenario-service + orchestrator tools over Model Context Protocol. Authenticates outward via _auth_header() in packages/mcp-server/mcp_server/tools.py (static bearer or self-minted JWT). |
AI clients get a curated, typed tool surface instead of raw HTTP; keeps agent capabilities auditable in one file. |
packages/payprobe-assistant |
Unified LLM gateway + the multi-turn tool-calling configuration assistant (standalone service). | LLM credentials/routing in exactly one process; other services call it, never providers directly. |
packages/portal |
Angular UI. Talks only to the HTTP APIs. | UI cadence and toolchain are decoupled from backend releases. |
packages/payprobe_common |
Shared small utilities (e.g. SecretBox in payprobe_common/crypto.py). |
Avoids cross-service imports of service internals. |
packages/helpers |
Misc dev helpers. | Not part of the runtime contract. |
Design convention (strong, but not a confirmed hard rule): no parallel stacks. New capabilities reuse existing seams — adapters, the LoadBus, the event backbone — rather than introducing sibling mechanisms. ADR-0001 and ADR-0002 both follow this pattern.
2. Execution model: three phases as blast-radius gating
Where: packages/worker/engine/engine.py (module docstring is the spec).
- Phase 1 — Components:
health_check()on every adapter in the environment. - Phase 2 — Integration: scenarios with
test_classin{"component", "integration"}(PHASE_2_CLASSES). - Phase 3 — End-to-End: scenarios with
test_class"e2e"(PHASE_3_CLASSES).
WHY: blast-radius containment. A scenario is marked BLOCKED (not FAILED) when it depends on a target that failed an earlier gate: phase-1 failures block any scenario touching the unhealthy target; a phase-2 failure blocks e2e scenarios touching that target. This keeps a single dead host from producing hundreds of misleading FAILED results and makes reports readable.
What breaks if violated: if you run e2e scenarios against targets that
failed health checks, every downstream failure is noise and the report's
per-phase table (packages/report_service/generators.py, "Phase results")
loses meaning. Diagnosis regresses to log archaeology.
Also load-bearing here: two semaphores cap total outbound connections and
concurrent steps (connection_semaphore, ops_limiter), sized from the
environment's connection_budget. Phase 1 retries unhealthy components once
after phase1_retry_delay_sec (default 1.0s) — long enough for a relay's
stale-upstream reconnect.
3. Invariants
Each entry: the rule, WHY, what breaks if violated, and the enforcing file.
3.1 Adapter registry: instance name ≠ implementation
Rule: environments declare named instances under adapters; each picks
its implementation via the adapter (alias type) key. Real adapter classes
are imported lazily and registered in ADAPTER_MAP only if their optional
dependencies are installed.
Enforced by: packages/worker/adapters/registry.py (ADAPTER_MAP,
AdapterRegistry, _register_real_adapters). Config composition:
per-instance extends + env-level adapter_defaults, deep-merged, instance
wins (_config_for).
WHY: one deployment can talk to several switches, a primary and a backup HSM, etc., without one class per target; and a missing optional dep (grpcio, crypto libs) must not break import of the whole worker. Import failures are logged at debug so a broken adapter module is distinguishable from "dep not installed" — this distinction was learned the hard way (silently vanishing adapters resurfacing as "Unknown adapter").
What breaks: hard-importing an adapter at module top level makes the worker unimportable wherever that dep is absent (CI, slim images). Keying behaviour on the instance name breaks multi-instance environments.
3.2 Derived adapters are named {target}@{env}
Rule: when a step pins environment_override, the orchestrator registers
that environment's adapter for the step's target under the derived name
f"{target}@{override}" and re-points only that step at it.
Enforced by: _attach_step_environments in
packages/orchestrator/api/main.py (tests:
packages/orchestrator/tests/test_step_environments_wiring.py).
WHY: one run can hit staging for one step and the default env for the rest without mutating the shared adapter entry. The derived name is collision-free and self-describing in traces/reports. Resolution is deliberately best-effort: unknown env / unreachable scenario-service / missing adapter leaves the step on its default target rather than failing the run.
What breaks: writing the override into the base adapter entry silently changes every step using that target; failing hard on a missing override env turns a cosmetic config gap into a dead run.
3.3 Connection = hub; the override matrix is the single per-env value source
Rule: a connection is the single source of truth for its values. Base
config holds the shape and defaults; environment_overrides (dict keyed by
environment name, each a partial worker-shaped config) holds per-environment
values, shallow-merged over base at attach time. Metadata keys
(_NON_ADAPTER_KEYS = {"name", "environments", "environment_overrides", "default"}) are stripped before the worker sees the config.
Enforced by: ConnectionDraft.environment_overrides in
packages/scenario-service/api/connection_store.py; merge in
_connection_effective + _attach_connections in
packages/orchestrator/api/main.py. The flag _CONNECTION_OVERRIDE_WINS
(env PAYPROBE_CONNECTION_OVERRIDE_WINS, default ON) makes the
connection's resolved config win over a same-named inline env adapter; =0
restores legacy precedence.
WHY: the costliest churn in project history was per-environment values living in two places (environment adapter blocks and connections). One value source per env, editable from either the Connection or the Environment editor but stored on the connection, ends the ambiguity.
What breaks: reintroducing a second value source re-creates split-brain config — a run's effective host/port depends on merge order nobody remembers.
REMOVED — do not reintroduce: per-connection endpoints[] + selection
multiplicity. It overlapped confusingly with participant groups, which are the
supported way to fan across several targets. The removal note is in the model
itself (comment block above listen_port in connection_store.py); the model
is extra="allow" so legacy records still load, and their endpoint fields are
ignored. If you need N interchangeable targets behind one name: use a
participant group.
3.4 Single port, both directions (listen_port deprecated, self-heals)
Rule: one port field serves outbound (dial) and inbound (bind). mode
selects direction; port: 0 on inbound means pick a free port at bind time.
Enforced by: _unify_port model validator in
packages/scenario-service/api/connection_store.py — it folds any legacy
listen_port into port on every load and nulls it, so old records
self-heal without a re-save; listen_port is never persisted going forward.
WHY: two port fields caused the classic failure of dialing one port while listening on another. A single field cannot disagree with itself.
What breaks: re-adding a second port field (or writing listen_port)
resurrects the split, and any code reading listen_port sees None after
validation.
3.5 Participant groups are typed to one adapter family
Rule: every member of a group must resolve to the same adapter family; the
family is validated and stamped onto the group as adapter_type.
Enforced by: _type_participant_group in
packages/scenario-service/api/main.py (raises HTTP 400 on mixed members or a
mismatched pre-set adapter_type; test:
packages/scenario-service/tests/test_participant_group_typing.py).
WHY: a group is one name serving one kind of traffic — the same action/payload cannot serve both an HSM and an ISO 8583 host. The stamp lets the portal and orchestrator know the family without re-deriving it.
What breaks: a mixed group would route ISO 8583 frames at an HSM (garbage in, connection resets out) and the group adapter could not pick a protocol.
3.6 Event core: append-only stream, sink-agnostic, resumable
Rule: run events go through the StreamBackbone protocol
(append/stream(after_id)), not pub/sub. Two implementations:
InMemoryStreamBackbone (tests/dev/mock e2e) and RedisStreamBackbone
(Redis Streams XADD/XRANGE/XREAD) — the WebSocket handler is identical over
both.
Enforced by: packages/worker/engine/stream.py (the module docstring is
the rationale of record).
WHY: pub/sub is fire-and-forget — a dropped WebSocket or late-joining watcher permanently loses history. Every event stored with a monotonic id means a client reconnecting says "give me everything after id X", replays what it missed, then tails live. This is exactly why portal refresh/reconnect resumes mid-run instead of showing a blank monitor.
What breaks: publishing any run event outside the backbone makes it
invisible to replay; consumers that don't pass after_id back re-download or
lose history.
3.7 Durable RUN_DB + cross-replica run control
Rule: run history persists to SQLite by default outside dev
(_default_run_db in packages/orchestrator/api/main.py: explicit RUN_DB
wins; dev/test/CI get :memory:; anything else gets RUN_DB_PATH, default
/data/runs.db). Liveness and cancel of in-flight runs go through
packages/orchestrator/api/run_control.py: InMemoryRunControl (single
process) or RedisRunControl — registry in Redis hash payprobe:runs, cancel
fan-out on pub/sub channel payprobe:run-cancel; the replica owning the
asyncio task acts, others ignore.
WHY: the asyncio task executing a run lives in exactly one process. Without
this split, "stop run" only worked on the replica that happened to receive the
HTTP request, and a restart erased history. RunStore = durable document any
replica can read; run_control = the missing piece, acting on a run you don't
own. (Companion: run_store.reconcile_orphans marks runs stranded as
"running" after a crash.)
What breaks: cancelling by touching the local task dict only works on one
replica — users behind a load balancer get non-deterministic stop buttons.
Setting RUN_DB=:memory: in prod silently loses all history on restart (the
fail-safe default exists precisely because this happened).
3.8 Auth fails closed everywhere
Rule: every request is rejected unless a credential verifies OR the
deployment is explicitly dev. packages/orchestrator/api/auth.py is the gate;
packages/scenario-service/api/auth.py mirrors it by design ("fail closed
identically" — its docstring records the incident: orchestrator locked down
while scenario-service sat wide open). Accepted credentials: static bearer
API_TOKEN or a JWT verified with AUTH_JWT_SECRET (HS256) /
AUTH_JWT_PUBLIC_KEY (RS256), issued by auth-service. The one escape
hatch: PAYPROBE_ENV in {dev, development, test, local} makes auth
optional. PUBLIC_PATHS (health/ready/metrics/status/reference/openapi/docs)
stay open for probes and scraping.
WHY: misconfiguration in prod must be a loud 503, never an open door. The default posture (unset env, no credentials configured) is reject.
What breaks: adding a route that bypasses require_auth, or widening
PUBLIC_PATHS, silently reopens the pre-hardening posture. Test:
packages/orchestrator/tests/test_auth_gate.py.
3.9 Code-node sandbox: netns isolation, auto/strict/off
Rule: user-supplied code nodes run in a subprocess wrapped in unshare
(--user --map-root-user --net, falling back to --net) so the snippet has
no network egress. Mode via PAYPROBE_CODE_SANDBOX: auto (default —
isolate if the host can, else run best-effort), strict (no sandbox available
⇒ refuse to run, raising the dedicated error), off.
Enforced by: packages/worker/engine/code_runner.py (_sandbox_mode,
the unshare probe; tests: packages/worker/tests/test_code_sandbox.py).
WHY: code nodes are arbitrary Python from the scenario editor. Network
isolation is the boundary that keeps "test helper snippet" from becoming
"exfiltration primitive" on a shared deployment. strict exists because
auto on a host without unshare degrades silently.
What breaks: executing code nodes in-process, or ignoring strict, gives
editor users the worker's network identity and credentials.
3.10 In-process load fallback is capped — the orchestrator refuses above it
Rule: when no external python -m worker.load_worker processes claim the
shards, the coordinator can run load workers inside the orchestrator event
loop — but only up to PAYPROBE_INPROC_MAX_TPS (default 2000) /
PAYPROBE_INPROC_MAX_CONNECTIONS (default 5000). Above the cap it refuses
the run, logs, and the portal shows "Action needed: start external workers".
PAYPROBE_LOAD_EXTERNAL_WORKERS=1 disables the fallback entirely on a real
fleet.
Enforced by: _inproc_demand + the refusal branch in
packages/orchestrator/api/load_coordinator.py; cap values wired in
packages/orchestrator/api/main.py.
WHY: driving 20K TPS in the control plane's own event loop starves the API/WebSocket loop — the platform would DoS itself and drop the very event stream you're using to watch the run. Refusing loudly beats degrading silently.
What breaks: raising the caps instead of starting workers turns every big load run into an orchestrator brownout with garbage latency numbers (the load generator competing with its own measurement plane).
3.11 Default-connection resolution (bare steps → type default, flag ON)
Rule: an action step that names NO connection resolves to the default
connection for its adapter type (at most one default per type, enforced by
ConnectionStore demoting the previous default). No default for the type ⇒
the step falls back to the environment's inline adapter — retained on purpose
so a not-yet-seeded type degrades gracefully. Flag
PAYPROBE_DEFAULT_CONNECTION_RESOLUTION defaults ON; =0 restores legacy
(bare targets hit the env adapter).
Enforced by: _DEFAULT_CONNECTION_RESOLUTION, _conn_type_key,
_target_type_key, and the "Phase C" block of _attach_connections in
packages/orchestrator/api/main.py; default: bool on ConnectionDraft in
packages/scenario-service/api/connection_store.py (spec:
docs/history/DEFAULT-CONNECTION-MODEL-SPEC.md at repo root).
WHY: authors shouldn't need to bind every step; "the ISO 8583 step goes to
the ISO 8583 default" matches intent. Type keys are deliberately coarse — the
TCP adapter's two wire protocols are distinct types (tcp/iso8583 vs
tcp/header_echo), everything else is one type per adapter.
What breaks: two connections marked default for one type makes resolution
order-dependent; changing _target_type_key's mapping silently rebinds
existing scenarios' bare steps to different hosts.
4. ADRs (docs/adr/)
- ADR-0001 — Run participant-flow topologies on the worker fleet.
Status: Proposed (not built). Today
start_topologylaunches every flow instance inside the orchestrator process, so a topology is single-host, dies with the orchestrator, and cannot back the 20K TPS / 100K-connection targets. The accepted direction: move flow instances onto the existing Redis-coordinated worker fleet, reusing LoadBus, heartbeats, and the provisioner rather than building a parallel stack. This is the hardest live problem — the executable campaign lives inpayprobe-distributed-topology-campaign. - ADR-0002 — Transparent TCP proxy / tap. Status: Proposed, partially
realized: stage-1 byte-exact tap relay exists (
TcpProxy, connectionkind: "proxy"), giving PayProbe a third posture — sitting in the middle of a live client↔upstream connection, relaying and observing. Intercept/stub/ capture-to-scenario and TLS termination remain unbuilt; treat them as design intent, not capability. - ADR-0003 — Report gates, provenance, two-mode rendering. Status:
Accepted — implemented. Reports serve two unrelated jobs: Improvement
(engineer mid-loop, high detail, throwaway) and Go/No-Go (approver at a
gate, defensible verdict). Implemented as explicit gates + provenance +
immutable certify + baseline in
packages/report_service/and the orchestrator sign-off endpoints; do not blur the two modes back together.
5. Known weak points — stated plainly
Verified against docs/history/project-review.md and
docs/standards-gap-analysis.md (2026-07-03). These are open; do not let new
work silently depend on them being better than they are.
ISO 8583 WIRE codec is ASCII-only.Closed 2026-09-24 by ADR-0011 (docs/adr/0011-one-iso8583-codec-binary-wire.md): one codec inpackages/payprobe_common/iso8583/speaks ASCII, binary bitmap, BCD, EBCDIC and raw binary on the live socket, with BCD/binary length indicators; the worker, the Inspector and the catalog share it. Still open on the same line: no published-edition field dictionary and no DE padding/justification rules (see payments-domain-reference §1.5 and the standards gap doc's ISO 8583 row).- Portal unit-test coverage is near zero. ~12k+ lines of Angular; a
Playwright e2e harness exists (golden paths, backend-free in CI; full flows
gated behind
E2E_FULL) but component unit coverage is thin and the auth/login UI ships essentially untested. - Tracing is not threaded end-to-end. OpenTelemetry scaffolding
(
init_tracing) exists; spans do not flow through a run. "Why is throughput low" still degrades to log reading. - Scenario
secret_varsare plaintext in Postgres. Connection-file secrets are encrypted at rest (SecretBox, Fernet,PAYPROBE_SECRET_KEY—packages/scenario-service/api/crypto.py/packages/payprobe_common/crypto.py); the scenario-storesecret_varsare masked in the UI but stored unencrypted. - The architecture overview doc is stale.
docs/architecture/overview.mdpredates the load subsystem and gRPC and draws stub services as if built. Trust this skill and the code over that diagram. - CI gaps. No coverage gate. (mock-integration DOES poll
/healthin a retry loop — an older review's "fixed sleep" complaint is fixed. A security scan workflow exists:.github/workflows/security-scan.yml— Syft SBOM + Trivy.)
Provenance and maintenance
Authored 2026-07-03 from direct code inspection. Re-verify before trusting volatile claims (all commands from repo root):
# Service layout
ls packages/
# Three-phase engine + gating
sed -n '1,20p' packages/worker/engine/engine.py
# Adapter registry
grep -n "ADAPTER_MAP" packages/worker/adapters/registry.py
# {target}@{env} derivation
grep -n '@{override}' packages/orchestrator/api/main.py
# Connection hub / override matrix / endpoints removal / port unify / default flag
grep -n "environment_overrides\|listen_port\|endpoints\[\]\|default: bool" packages/scenario-service/api/connection_store.py
# Flags and caps (defaults)
grep -n "PAYPROBE_CONNECTION_OVERRIDE_WINS\|PAYPROBE_DEFAULT_CONNECTION_RESOLUTION\|PAYPROBE_INPROC_MAX" packages/orchestrator/api/main.py
# Typed groups
grep -n "_type_participant_group" packages/scenario-service/api/main.py
# Event backbone
sed -n '1,25p' packages/worker/engine/stream.py
# Run control + durable RUN_DB
sed -n '1,25p' packages/orchestrator/api/run_control.py && grep -n "_default_run_db" packages/orchestrator/api/main.py
# Auth gates
sed -n '1,25p' packages/orchestrator/api/auth.py && sed -n '1,10p' packages/scenario-service/api/auth.py
# Code sandbox
grep -n "PAYPROBE_CODE_SANDBOX" packages/worker/engine/code_runner.py
# In-proc load cap refusal
grep -n "inproc_max\|refuse" packages/orchestrator/api/load_coordinator.py
# ADR statuses
grep -n "Status" docs/adr/000*.md
# Weak points
grep -n -i "ascii\|secret_vars\|tracing\|portal" docs/history/project-review.md docs/standards-gap-analysis.md | head
If any command's output contradicts this file, the code wins — update this skill and note the drift.