payprobe-distributed-topology-campaign
Executable, decision-gated campaign for implementing ADR-0001 — moving participant-flow topologies off the orchestrator process onto the Redis-coordinated worker fleet. Load this skill when the task m
SKILL.md.claude/skills/payprobe-distributed-topology-campaign/SKILL.md
GitHubDistributed Topology Campaign (ADR-0001)
The hardest live problem in PayProbe, as an executable campaign. Everything in "Current state" below was verified against the repo on 2026-07-03; everything in Phases 2+ is NOT BUILT — this is a plan over verified ground truth, not a description of existing code.
Jargon (defined once):
| Term | Meaning here |
|---|---|
| Participant flow | A graph (trigger → logic → reply nodes) that stands in for one party in a payment network (an issuer, a switch, an HSM). Runs as a listener. |
| Topology | A named set of participant flows × N instances, started/stopped as one unit (scenario-service models/topology.py). |
| FlowResponder | The worker class that hosts one flow instance as a TCP listener (worker/adapters/tcp/flow_responder.py, class FlowResponder(FlowTraceMixin, TcpResponder)). HTTP flows use HttpFlowResponder. |
| LoadBus | The coordinator↔worker transport for load runs (worker/engine/load/bus.py): InMemoryLoadBus (one process, dev/CI) and RedisLoadBus (real fleet). |
| Worker fleet | Separate python -m worker.load_worker processes coordinating through Redis; tracked by TTL heartbeats (loadworker:{id} keys, WORKER_TTL_S = 15). |
| Endpoint registry | NOT BUILT. ADR-0001's name for the map instance → live host:port that callers must consult once instances live on many hosts. |
| ISO 8583 | The binary message standard for card transactions; most PayProbe listeners speak it over TCP. |
When NOT to use this skill
- General architecture questions, invariants, "why is it built this way" → payprobe-architecture-contract.
- Running/operating load tests, compose anatomy, scaling an existing fleet → payprobe-run-and-operate.
- Classifying/gating a change, flag rules, review protocol → payprobe-change-control (this campaign's promotion phase routes through it).
- Debugging a broken topology today → payprobe-debugging-playbook.
Ground rules
Non-negotiables (binding for every phase):
- Measure, never assume. Every phase starts by reproducing its claim with a command and comparing against the stated expected observation.
- Reversibility mandatory. All new behaviour lands behind a flag, default
OFF (proposed name:
PAYPROBE_TOPOLOGY_FLEET; it does not exist yet — creating it is part of Phase 4). The in-process path must remain the dev/CI default. - Suite green before anything.
make testpasses before you start and after every phase. Known failures may not accumulate.
Design convention (not a non-negotiable — deviation requires an ADR, per
payprobe-change-control): reuse existing seams (LoadBus, heartbeats,
run_control, SecretBox) — ADR-0001 and ADR-0002 both reuse machinery. Do not
fork a parallel transport or clustering stack without an ADR making the case.
Phase 0 — Mission + current-state verification gate
Objective: prove the "flows are orchestrator-local" premise still holds
before planning anything on top of it. If any check below diverges, STOP and
re-read docs/adr/0001-distributed-topology-on-worker-fleet.md plus recent
git log — someone may have started this campaign already.
Run from repo root:
# 0.1 The ADR exists and is still Proposed (not Accepted/Superseded)
grep -n "Status" docs/adr/0001-distributed-topology-on-worker-fleet.md
# EXPECTED: **Status:** Proposed
# 0.2 The orchestrator process itself imports and hosts FlowResponder
grep -n "from worker.adapters.tcp.flow_responder import FlowResponder" packages/orchestrator/api/main.py
grep -n "^PARTICIPANTS: dict\[str, FlowResponder\]" packages/orchestrator/api/main.py
# EXPECTED: one hit each (import near top; module-level in-memory registry).
# This is the smoking gun: listeners live in the orchestrator's event loop.
# 0.3 Topology start launches instances in-process, not on any fleet
grep -n "_launch_participant\|def start_topology" packages/orchestrator/api/main.py | head
# EXPECTED: start_topology calls _launch_participant in a loop; no bus involved.
# 0.4 None of ADR-0001's new components exist yet
grep -rn "flow_host\|topology_coordinator\|topology\.place\|instance\.up" packages/ --include='*.py' | grep -v __pycache__
# EXPECTED: no output. If anything matches → the campaign has started; audit
# what exists against the phase list before writing code.
# 0.5 The LoadBus has no topology vocabulary
grep -n "topology" packages/worker/engine/load/bus.py
# EXPECTED: no output. Bus primitives today: push_shards / claim_shard /
# pending_shards / report_sample / samples / signal_stop / is_stopped /
# mark_done / signal_retune / get_retune / worker_heartbeat / list_workers /
# drop_worker / signal_worker_stop / is_worker_stopped.
# 0.6 Suite is green (env caveat: crypto/aiohttp failures in a sandbox are
# missing-deps, not regressions — see payprobe-build-and-env)
make test
# EXPECTED: exit 0. Collected sizes on 2026-07-03: orchestrator/tests = 274
# tests, worker/tests = 308 tests. Counts may only have GROWN since.
Which process hosts the listeners (live proof, if a deployment is running):
# Start any topology, then look at who owns the sockets. The orchestrator
# API is ${ORCH_PORT:-8100} in infra/docker/docker-compose.yml:
curl -s localhost:8100/topology-runs | python3 -m json.tool
# EXPECTED: endpoints all on the orchestrator host (127.0.0.1 / orchestrator
# container). In compose: `docker compose exec orchestrator ss -ltnp` shows the
# planned ports bound INSIDE the orchestrator container, none in any worker.
curl -s localhost:8100/load-workers
# EXPECTED: [] unless a load run is active — proving today's fleet only exists
# for load generation, never for hosting listeners.
#
# NOTE: the orchestrator auth gate FAILS CLOSED outside dev (packages/
# orchestrator/api/auth.py). Unless PAYPROBE_ENV=dev, add
# -H "Authorization: Bearer $API_TOKEN"
# to every curl in this campaign.
Gate G0 (all must hold): 0.1–0.5 match expectations exactly; make test
exit 0. If 0.4 or 0.5 shows matches instead → inventory what exists, map it to
the phase list below, and resume at the first phase whose gate fails.
Phase 1 — Baseline characterization (measure current behaviour)
Objective: a written, numeric baseline of the single-host implementation so every later phase can prove "no regression" instead of eyeballing it.
The current implementation, by symbol (all in packages/orchestrator/api/main.py
unless noted; re-locate with grep -n <symbol> — line numbers drift):
| Concern | Symbol(s) | Verified behaviour (2026-07-03) |
|---|---|---|
| Instance launch | _launch_participant, _build_flow_responder |
Builds FlowResponder/HttpFlowResponder (gRPC inbound rejected 400) in-process; registers in PARTICIPANTS dict. |
| Per-instance port plan | start_topology planning loop, _flow_listen_target |
N instances of one flow get base, base+1, … from the inbound connection's single port (listen_port is legacy, folded on read); no base → ephemeral (port 0). Cross-flow (host,port) duplicate → HTTP 409 before any listener starts. |
| Readiness gate | _run_health, _topology_is_up, create_run |
ready = total > 0 and live == total (live = registered AND bound port). A run with requires_topology set gets HTTP 424 until ready. |
| Rollback on partial start | start_topology except-branch |
Any launch failure stops every already-started instance; nothing lingers. |
| Persistence | _persist_runtime, _autostart_runtime, RUNTIME_STATE_FILE (ORCH_RUNTIME_FILE, default :memory:; compose sets /data/runtime.json) |
Desired state = standalone flow ids + topology ids; restored on boot unless DISABLE_PARTICIPANT_AUTOSTART=1. |
| Bound initiator | start_topology initiator block, stop_topology |
Topology's driver scenario auto-runs on start (failure surfaces as initiator_error, never blocks start) and is cancelled on stop. |
| Ownership guard | _flow_in_running_topology, start_participant |
Starting a flow standalone while a topology owns it → 409. |
| Observability | /peers, /participants/traces, /participants/trace/{cid}, /participants/capture |
All iterate the in-process PARTICIPANTS dict directly. |
| Chaos | _chaos_capable, /simulators/{sid}/chaos* |
Chaos dial targets the SIMULATORS dict ONLY. Participants have no chaos endpoint today (FlowResponder inherits set_chaos from TcpResponder, but nothing exposes it). Do not "restore" participant chaos that never existed. |
Commands:
# 1.1 The topology + participant suites pass and cover the behaviours above
cd packages && python -m pytest orchestrator/tests/test_topology.py orchestrator/tests/test_participants.py -q
# EXPECTED (2026-07-03): 10 + 6 = 16 tests, all pass.
# test_topology.py proves: distinct ports 8400/8401/8402 for 3 instances;
# 409 collision pre-bind; 424 gate incl. half-dead topology; persistence
# snapshot/restore; initiator run + cancel; cross-listener trace stitching.
# 1.2 The machinery to REUSE is healthy
cd packages && python -m pytest worker/tests/test_load.py orchestrator/tests/test_load_workers.py orchestrator/tests/test_run_control.py orchestrator/tests/test_worker_provisioner.py -q
# EXPECTED (2026-07-03): 30 + 4 + 5 + 18 = 57 tests, all pass.
# 1.3 Record the live baseline (running deployment only): start your reference
# topology, capture /topology-runs health JSON, /participants ports, and — if
# an initiator drives traffic — steady-state by_mti counts after 60s. Save the
# JSON; Phases 4-7 diff against it.
Gate G1: 1.1 and 1.2 fully green; baseline JSON captured (or explicitly recorded as "no live deployment — tests only"). Write the numbers down in your working notes; they are the regression reference for every later gate.
Phase 2 — Bus protocol design (topology messages on the LoadBus)
Objective: extend the LoadBus vocabulary with placement messages (ADR-0001 action item 2) — design reviewed and written down BEFORE code.
What exists today vs what is needed:
| Message | Today | Needed (NOT BUILT) |
|---|---|---|
push_shards / claim_shard |
Redis list + BLPOP — exactly one consumer per shard, fire-and-forget |
topology.place {placement_id, flow_id, instance_idx, config, flow_doc, downstream} — same claim-queue pattern works: each placement claimed by exactly one host |
| — | — | instance.up {placement_id, instance_id, host, port} — advertisement; feeds the endpoint registry |
signal_stop(run_id) |
stop flag key, polled | `topology.stop {instance_id |
worker_heartbeat / list_workers |
TTL key loadworker:{id} (ex=WORKER_TTL_S=15) + index set; refresh every WORKER_HEARTBEAT_S=5 |
Reuse as-is for flow hosts; heartbeat info gains role: "flow_host" + hosted instance ids |
Theory obligations — write the answers into the design note before coding:
- Delivery semantics.
BLPOPclaim = at-most-once: a host that crashes after claiming but before binding loses the placement silently. For load shards that is acceptable; for listeners it is not. You must state the recovery authority: the coordinator treats "placement claimed but noinstance.upwithin T_bind" as failed and re-queues it. Derive T_bind (bind is milliseconds; config fetch is one Redis read since the flow doc is embedded in the placement — mirror the load shard pattern where "a worker needs nothing but the bus to start"; 10s is generous, justify your number). - Advertisement transport.
instance.upas a Redis Stream (XADD, like metric samples) gives replayable history but requires the registry to compact; a TTL key per instance (topoinst:{id}, likeloadworker:{id}) makes liveness and registration THE SAME mechanism — recommended, because the readiness gate then needs no second liveness source. State your choice and why. - Heartbeat/failover math. Worst-case dead-listener detection with TTL
keys =
WORKER_TTL_S= 15s. Failover budget = detection (≤15s) + re-place (one queue round-trip) + re-bind + re-advertise. If the topology readiness gate must not flap during a single missed beat, require age > TTL (not age > heartbeat interval) before declaring an instance dead. Write the numbers down; Phase 6's chaos test asserts them. - In-memory parity. Every new bus method gets an
InMemoryLoadBusimplementation with identical semantics — that is what keeps dev/CI Redis-free (ADR-0001 action item 7). The Protocol class inworker/engine/load/bus.pyis the single source of the contract.
Gate G2 (measurable): a design note exists in the repo (docs/ or the ADR's
own file, per payprobe-docs-and-writing) answering obligations 1–4 with
numbers; new Protocol methods stubbed on BOTH buses; make test still green
(stubs may not break the 57-test reuse baseline from G1).
Phase 3 — Worker-side hosting: the flow_host role
Objective: a python -m worker.flow_host process that claims placements,
runs FlowResponders, advertises endpoints, heartbeats (ADR-0001 action item 1).
Rules:
- Model it on
packages/worker/load_worker.py(362 lines — read it first): envREDIS_URLrequired, heartbeat task refreshing inside the TTL, cleandrop_workeron exit. Key difference:load_workerclaims ONE shard and exits when done;flow_hostis long-lived and hosts MANY instances. - The placement payload must embed everything (flow doc, resolved listen config,
resolved downstream adapter map) so the host never calls scenario-service.
The resolution logic already exists orchestrator-side
(
_participant_listen_config,_participant_downstream,_build_flow_responder) — move/share it, do not duplicate it. Watch the relay special case:_relay_nodeterminal → TcpProxy, not FlowResponder. - Secrets in downstream configs travel like load shards do today; anything
encrypted at rest uses SecretBox (
packages/payprobe_common/crypto.py; background indocs/history/project-review.md).
Commands / expected:
# 3.1 New unit tests, in-memory bus only (no Redis in CI):
cd packages && python -m pytest worker/tests/test_flow_host.py -q
# EXPECTED: covers at minimum — claim→bind→advertise happy path (instance.up
# carries the REAL bound port, incl. ephemeral port-0 case); topology.stop for
# one instance stops only that instance; host crash simulation = heartbeat key
# expiry; two hosts + N placements = each placement claimed exactly once.
# 3.2 Manual smoke with real Redis (optional but recommended):
# terminal A: REDIS_URL=redis://localhost:6379 python -m worker.flow_host
# terminal B: push a placement via a scratch script; then:
redis-cli keys 'topoinst:*' # or XLEN of your advertisement stream
# EXPECTED: one advertisement with a live host:port; `nc <host> <port>` connects.
Gate G3: new flow_host tests green; full make test green; worker test
count strictly greater than the 308 baseline; ZERO changes yet to
orchestrator start_topology behaviour (G1's 16 topology/participant tests
still pass byte-identical).
Phase 4 — Orchestrator delegation: topology_coordinator + fallback retention
Objective: a topology_coordinator (parallel to load_coordinator) that
places instances over the bus, collects advertisements into the endpoint
registry, rolls back partial placement, and tracks fleet health in
TOPOLOGY_RUNS (ADR-0001 action item 3) — behind the new flag, default OFF.
Rules:
- Flag:
PAYPROBE_TOPOLOGY_FLEET(unset/0 = today's in-process path, bit-for-bit). Register the flag per payprobe-config-and-flags' add-a-flag checklist. The in-process path is not "legacy" — it is the permanent dev/CI fallback (ADR-0001 explicitly recommends keeping it the default there). - Mirror
LoadCoordinator's structure: constructor takes bus + run_store-like deps;start()pushes placements; background tasks aggregate advertisements and watch liveness. Reuserun_controlfor cross-replica stop registration (payprobe:runshash +payprobe:run-cancelpub/sub) — do not invent a second cancel path. - Partial placement rollback now spans hosts: on any placement failing its
T_bind deadline, issue
topology.stopfor every already-up instance of that run, then surface the same HTTPException the in-process path raises today. - Follow the in-proc cap pattern:
LoadCoordinatorrefuses in-process work aboveinproc_max_tps=2000/inproc_max_connections=5000and emits an actionable notice instead of silently degrading. The topology analog: with the flag ON but zero live flow hosts, FAIL the start with a notice containing the copy-paste worker command (seeworker_provisioner.manual_commandfor the pattern) — never silently fall back to in-process hosting when the operator asked for fleet hosting, and never host unbounded listeners in the orchestrator loop.
Commands / expected:
# 4.1 Flag OFF (default): the entire existing suite is untouched
cd packages && python -m pytest orchestrator/tests -q
# EXPECTED: ≥274 tests, green, no test edited to accommodate the new code path.
# 4.2 Flag ON, in-memory bus, in-proc flow-host task (the CI stand-in for a
# fleet — same trick LoadCoordinator uses with in_proc_worker):
cd packages && python -m pytest orchestrator/tests/test_topology_coordinator.py -q
# EXPECTED: place → advertise → run ready; partial failure → cross-host
# rollback → 4xx; registry lists every instance's host:port.
# 4.3 Flag ON with no hosts:
# EXPECTED: topology start fails fast with the actionable notice; nothing binds
# in the orchestrator process.
Gate G4: 4.1–4.3 as stated; git grep PAYPROBE_TOPOLOGY_FLEET shows the
flag read in exactly one place (coordinator selection), documented default OFF.
Phase 5 — Port plan and readiness on a multi-host fleet
Objective: adapt the per-instance port planner and the requires_topology
gate to registry reality (ADR-0001 action items 4 and 5).
Theory obligation — port collision across hosts. Today's planner allocates
base+i and rejects duplicate (host, port) pairs — sound on ONE host. Across
hosts, (host, port) uniqueness is only known AFTER placement (two hosts can
both bind 8400 legitimately; one host given two instances of base 8400 cannot).
Two policies (ADR-0001 "Revisit" leaves this open):
| Policy | Mechanics | Verdict |
|---|---|---|
| Ephemeral + advertise (recommended) | Fleet placements always bind port 0; the OS picks; instance.up advertises the real port; callers only ever read the registry |
Eliminates the collision class entirely; the fixed-port UX survives on the in-process path where it is actually meaningful |
| Assigned ranges per worker | Each host owns a port range; coordinator allocates within it | Preserves predictable ports but adds range bookkeeping, exhaustion handling, and a new config axis — only justify if some caller genuinely cannot do discovery |
Whichever you pick, derive: max instances per host, behaviour when a host restarts and re-binds different ports (registry must be updated, callers must re-resolve — this is why group resolution moves to dispatch time).
Discovery: groups/connections resolve to registry endpoints at dispatch /
run-build time — extend _attach_groups / _attach_connections (they already
rewrite step targets; verified in orchestrator/tests/test_groups.py and
test_connections_wiring.py). Readiness becomes: every placement registered
AND advertisement fresh (age < TTL) — _run_health / _topology_is_up consult
the registry when the flag is ON.
Gate G5 (measurable):
cd packages && python -m pytest orchestrator/tests/test_topology.py orchestrator/tests/test_groups.py -q
# EXPECTED: original port-plan tests (8400/8401/8402, 409 pre-bind) still pass
# UNCHANGED with flag OFF; new flag-ON tests prove: registry-resolved group
# dispatch reaches a fleet instance; 424 until ALL advertisements fresh; a
# stale advertisement (simulated expiry) flips ready → false.
Phase 6 — Lifecycle: cross-replica stop, worker death, crash/reconcile
Objective: the failure modes ADR-0001 flags as "Harder" (action item 6), proven by tests, not argued.
Sub-tasks:
- Cross-replica stop. Register topology runs with
run_controlso a stop arriving at replica B reaches the coordinator on replica A (exact pattern: load runs already do this —orchestrator/tests/test_run_control.py, 5 tests, is your template). - Worker death → re-place. Coordinator watches instance liveness (TTL expiry); on death, re-queues the instance's placement and updates the registry. Bound the flap: derive from Phase 2 math (detection ≤15s + re-place + re-bind) and assert the budget in the test.
- Crash/reconcile interplay. Study
run_store.reconcile_orphans+_reconcile_stuck_load_runs+POST /load-runs/reconcile(built for load runs stranded as "running" after an orchestrator crash). Topology runs today live only in the in-memoryTOPOLOGY_RUNSdict + the desired-state file. Under fleet hosting, an orchestrator crash leaves REAL listeners running on workers with no owning run record. Decide and document: on boot, the coordinator re-adopts fleet instances whose advertisements match persisted desired state, and stops orphans that match nothing (the topology analog of reconcile_orphans; expose it on the existing reconcile surface, don't invent a second one). - Persistence.
_persist_runtime/_autostart_runtimekeep working under the flag: desired state (topology ids) is host-agnostic, so restore = ask the coordinator to ensure placements, adopting live instances instead of double-placing them.DISABLE_PARTICIPANT_AUTOSTART=1must still bypass.
Gate G6 (measurable):
cd packages && python -m pytest orchestrator/tests -q
# EXPECTED: green, and new tests exist proving each: (a) stop routed via
# run_control from a non-owning registry; (b) killed in-proc host task →
# instance re-placed, registry updated, ready flips false→true within the
# derived budget; (c) coordinator restart with live instances → re-adopt, not
# double-place (instance count unchanged); (d) orphan instance with no desired
# state → stopped by reconcile.
Phase 7 — Feature-parity checklist (flag ON must lose nothing)
Every row below is a behaviour verified to exist on the in-process path on
2026-07-03. Parity means: with PAYPROBE_TOPOLOGY_FLEET=1, the same API call
gives the same shape of answer, now sourced from the fleet.
| # | Feature | Today's source | Parity proof (flag ON) |
|---|---|---|---|
| P1 | /peers participant rows |
Iterates in-proc PARTICIPANTS, calls r.peers() |
Peers of fleet-hosted listeners appear (needs a worker→orchestrator peers report; the heartbeat info dict is the cheap channel — same trick load workers use to ship tps/rss) |
| P2 | /participants/traces + /participants/trace/{cid} |
Iterates r.traces() in-proc |
Cross-hop trace stitches hops recorded on DIFFERENT hosts for one correlation id (this is the ADR's "aggregated across the fleet" item 8) |
| P3 | /participants/capture pause/resume + DELETE /participants/traces |
Flips _capture on live objects |
Capture toggle propagates to fleet hosts (bus control message), returns accurate buffered counts |
| P4 | Chaos dial | /simulators/{sid}/chaos* — SIMULATORS only; participants have no chaos endpoint |
No regression required: simulator chaos is orchestrator-local and unaffected. Do NOT invent participant chaos in this campaign; if product wants it, that is a separate change through change control. Record this explicitly so a reviewer doesn't flag "missing" parity. |
| P5 | requires_topology → 424 |
_topology_is_up over in-proc dict |
Same 424 until fleet-ready (G5 test) |
| P6 | Persistence + boot restore | _persist_runtime/_autostart_runtime |
G6(c,d) tests |
| P7 | Reconcile | load-run reconcile only | G6(d) topology reconcile |
| P8 | Bound initiator run/cancel | start_topology/stop_topology |
Initiator fires only after fleet-ready; cancelled on stop |
| P9 | Standalone-vs-topology 409 guard | _flow_in_running_topology |
Guard consults registry too |
| P10 | Network graph / topology map pages, MCP list_topologies/list_topology_runs |
/network-graph, MCP proxy |
Same JSON shapes; portal renders fleet endpoints without code changes (or the portal change ships in the same gated slice) |
Gate G7: a written checklist run — each row marked with the test name or curl transcript proving it. Any row without evidence blocks promotion.
Solution menu (ranked)
- Option B of ADR-0001 (chosen there; this campaign): LoadBus + topology coordinator + endpoint registry, in-memory fallback preserved. Obligations: Phase 2 items 1–4 (delivery semantics, advertisement transport, heartbeat math), Phase 5 port policy derivation.
- Option B, minimal slice first: ship only
flow_host+ placement + registry for SINGLE-instance flows, keep groups/multi-instance in-process; widen later. Lower risk, two flag states to test. Acceptable if a deadline forces it — the gates still apply per slice. - Option C (k8s deployments per flow): rejected by the ADR — platform coupling, slow start/stop vs the ephemeral test-network workflow, and you STILL must build the endpoint registry. Only revisit if PayProbe becomes k8s-only operationally; that is an ADR supersession, not a campaign detour.
- Option A (status quo): legitimate "do nothing" — the ADR itself notes the single-host path is solid post port-fix. Choosing it = closing ADR-0001 as Rejected with the scaling targets re-scoped. That is a product decision; route it through change control, don't let the campaign silently stall.
Wrong paths — fenced, with archaeology
- Do NOT reintroduce per-connection
endpoints[]/routing.py. Built, then REMOVED in commitfe59454("remove deprecated endpoint selection model") because it confusingly overlapped participant groups. The endpoint registry is RUNTIME state keyed by placement, not a config-model field on connections. If you find yourself editing the connection model, stop. - Do NOT run flows in the orchestrator loop beyond the capped in-proc
pattern. The whole ADR exists because listeners contend with the API event
loop. Follow
LoadCoordinator's cap-and-notice discipline (Phase 4); a silent in-process fallback under the fleet flag recreates the problem while claiming to fix it — silent degradation is a documented costliest-failure class in this repo. - Do NOT fork a parallel event/coordination transport. One bus
(
LoadBus, Redis + in-memory twin), one heartbeat scheme, one run_control. A second Redis schema or a new pub/sub layer doubles the operational model the ADR explicitly chose Option B to avoid. - Do NOT break the single-port model or typed groups. Connections carry ONE
portfor both directions (listen_portis legacy, folded on read — self-heals); groups are typed to one adapter family and validated. The registry maps logical instance → advertised address; it must not resurrect per-direction ports or untyped member lists. - Do NOT edit existing green tests to make new code pass. The 16 topology/participant tests and the 57 reuse-machinery tests are the contract; flag-OFF behaviour changes are regressions by definition.
Validation and promotion protocol
Route through payprobe-change-control. Order is mandatory:
- Flag
PAYPROBE_TOPOLOGY_FLEETexists, default OFF, documented (payprobe-config-and-flags checklist). make testgreen with flag OFF (whole suite) AND the flag-ON test set green on the in-memory bus (Gates G3–G6).- Feature-parity checklist G7 completed with evidence per row (P1–P3, P5–P10; P4 recorded as intentionally out of scope).
- Real-Redis staging soak: compose deployment with ≥2
flow_hostprocesses, reference topology up ≥1h, one deliberate host kill mid-soak; assert recovery within the Phase 2 budget and zero orphan sockets after stop (ss -ltnon every worker container). - Flip the flag ON in
infra/docker/docker-compose.yml(orchestrator env block — same block that already setsREDIS_URL,ORCH_RUNTIME_FILE: /data/runtime.json,SIMULATORS_FILE,PAYPROBE_WORKER_PROVISIONER). Dev/CI stays flag-OFF (ADR recommendation). - Update
docs/adr/0001-distributed-topology-on-worker-fleet.md: Status Proposed → Accepted (implemented), tick the action items, record the port-policy and delivery-semantics decisions in Notes. Update docs/history/PROGRESS.md per house style (payprobe-docs-and-writing).
Rollback plan
- Instant (config): unset
PAYPROBE_TOPOLOGY_FLEETin compose, restart the orchestrator. Boot restore (_autostart_runtime) re-launches persisted topologies in-process — the desired-state file is host-agnostic by design, so no data migration exists in either direction. - Cleanup after rollback: stray fleet listeners are stopped by the Phase 6
reconcile (orphans matching no desired state) or, bluntly,
docker compose stop <flow-host services>; verify withcurl /peersandss -ltnon worker hosts showing zero topology ports. - Code rollback: the flag isolates every new call site; reverting the enabling commit(s) must leave the flag-OFF suite green — which is exactly what Gate G4.1 proved continuously.
- Trigger examples (decide thresholds before the flip, per change control): readiness flapping without host deaths, initiator error rate above the G1 baseline, or any parity row regressing in production use.
Provenance and maintenance
All "current state" claims verified 2026-07-03 against the working tree
(orchestrator suite = 274 collected, worker = 308 collected; ADR-0001 Status:
Proposed; no flow_host/topology_coordinator/topology.place symbols).
Re-verify before executing:
grep -n "Status" docs/adr/0001-distributed-topology-on-worker-fleet.md
grep -rn "flow_host\|topology_coordinator\|topology\.place\|instance\.up" packages/ --include='*.py' | grep -v __pycache__
grep -n "topology" packages/worker/engine/load/bus.py
cd packages && python -m pytest orchestrator/tests worker/tests --collect-only -q | tail -1
git log --oneline -5 -- packages/orchestrator/api/main.py packages/worker/engine/load/ docs/adr/
If any check diverges from Phase 0's expectations, this campaign has moved:
re-run Phase 0, map findings onto the phase gates, and resume at the first
failing gate. Line numbers cited nowhere, symbol names everywhere — use
grep -n <symbol> to relocate. Maintainer of record for the phase structure:
whoever last edits this file (update the date above when you do).