# PUMP-TRUTH W2 — DISPATCH RULING (doyle → todlando)

Rules the 5 forks in `docs/PUMP-W2-SCOPING.md` (todlando's sketch @e965bd9). **W2 = REQ-PUMP-PEER-ISOLATION, MANDATORY** (operator ruling 2026-07-08) — concurrent non-blocking dials + per-peer fault isolation. Activate `required_stages = ["impl","unit","int"]` at work-start. **W1 gates first** (fast stall-fix, still root-locked on the empirical >30s); W2 is the full wave after — NOT contingent on being the stall root ([[dont-solo-across-role-lines]]: doyle rules design, todlando builds, doyle gates, deployah publishes).

## Model — ENDORSE Option A (D4c-native)
The pump subscribes the existing `NetPresenceEvent` stream (cursor-resumable, gapless, exactly-once — nethost.rs:1289, brain.rs:825/1431), submits dials non-blocking, and runs each peer's `peer_step` when its conn-ready event arrives. **No new async substrate** — re-purpose D4c. Correct call; the broker is already async tokio+iroh and dials concurrently (nethost.rs:417).

**Paired new broker verbs (make explicit):** W2 adds (a) a **non-blocking `dial-submit`** request (fire-and-forget, returns immediately, broker dials concurrently on its runtime under W1's per-dial bound) and (b) the **dial-outcome event** (G1) as its reply channel. Today `net_dial` is a blocking request-reply; W2 does NOT remove it (other callers use it) — it ADDS the submit+event path the pump uses.

## Rulings

### G1 — dial-outcome events: **EXTEND `NetPresenceEvent`** (not a separate channel)
One cursor + one gapless exactly-once discipline is the load-bearing correctness property: a missed dial-outcome = a peer never rescheduled (missed `failed`) or a ready conn never used (missed `connected`). Two channels = two cursors + a cross-channel ordering question (which came first?). A dial-outcome IS the first event in a conn's lifecycle (attempt → connected/failed → … → disconnected) — semantically a presence-lifecycle event, not a foreign concept. Add typed variants: `DIAL_FAILED { peer_id, reason }` (and, if correlation needs it, `DIAL_SUBMITTED`), alongside the existing `CONNECTED { peer_id, conn_id }` / `DISCONNECTED`. **The event MUST carry a per-peer correlation key** (`peer_id`, and/or a submit request-id) — concurrent dials mean outcomes arrive out of submit order; the pump matches outcome→peer by that key, never by arrival position. The "semantic cleanliness" of a separate channel is the weaker argument.

### G2 — carrier: **one carrier**, submit fire-and-forget + drain non-blocking with a **broker-liveness deadline only**
Neither operation blocks on a per-peer reply anymore, so one carrier is fine. The drain is a non-blocking "read events available up to cursor," NOT a per-event deadline. **REQ-HAZARD-PUMP-IPC-DEADLINE's real purpose survives, re-pointed:** the drain keeps a SHORT deadline on *broker-conn liveness* (is the broker draining/answering the stream AT ALL) — a dead broker still trips `supervise_pump`; a dead PEER never does (its failure is an ordinary event within W1's bound). **Fallback trigger:** split to a dedicated event-drain carrier ONLY if the impl shows submit backpressure entangling the drain deadline — name it if you hit it, don't pre-split.

### G3 — round-close: **on a DEADLINE** (submit-all → drain-until-round-timeout → `mark_ran`), NOT all-resolved
All-peers-resolved is a NEW wedge risk: a lost/delayed outcome (or a slow broker) → the round never closes → the pump stalls again. Deadline-close is bounded and wedge-proof. Because dials are CONCURRENT, all outcomes arrive within ~one W1 bound (not N×bound), so **round-timeout ≈ W1 per-dial bound + a small margin**. Preserves the ADR-0018 §V4 stagger (`mark_ran` = ATTEMPTED, post-round instant) + exactly-once wake semantics. A peer unresolved at round-close = **reschedule that peer** (per-peer, G5), never a round failure. This also guarantees the W2 gate's "heartbeat advances every round" — the round always closes on its deadline.

### G4 — concurrency: on the **BROKER**, fan-out-all with a **bounded cap**
Confirmed: dials run concurrently on the broker's async runtime (natural — iroh already pools), NOT pump-side threads. Fan-out-all is fine for today's rosters, but add a **generous bounded concurrency cap** (a broker-side semaphore, e.g. 16–32 concurrent dials) as a large-subnet guard — cheap insurance against a future big roster spawning unbounded concurrent connects (the "no silent unbounded" discipline). Don't over-engineer beyond a simple semaphore.

### G5 — per-peer reschedule: **per-peer doubling backoff (capped), reset on success**
NOT next-tick-retry — the field mode is a mostly-offline roster (enlyzeam + ~5 dead peers); re-dialing every dead peer each tick wastes dials + churns. Per-peer doubling (scoped to the peer, cap ~300s like `supervise_pump`'s today) backs off a persistently-dead peer while keeping live peers hot. **Reset the peer's backoff on any success** (and it's fine to also reset on a fresh presence/discovery hint if cheap). A returning peer must re-dial promptly on the next reset, never languish in a long backoff.

## Gate (doyle)
- clippy `--workspace -D warnings`; traceable `--json` 0 (`REQ-PUMP-PEER-ISOLATION` +impl +unit +int).
- **int (the load-bearing test):** a mixed roster — 1 LIVE + N OFFLINE peers — the live peer connects AND this node advertises presence in the SAME round the offline peers fail; heartbeat advances every round; **no `PEER_PUMP_RESTART` from a dead peer**; a dead peer never blocks the live one (the head-of-line-block gate). Plus: a dead BROKER conn STILL trips `supervise_pump` (the re-pointed REQ-HAZARD-PUMP-IPC-DEADLINE — verify it didn't get lost in the decouple).
- exactly-once/gapless drain preserved (the D4c cursor discipline — no dropped/duplicated outcome under a mid-round broker restart).
- §V4 stagger + per-leg cadence (registry/notif/sync/update) preserved under the event-driven round.

## Sequencing
W1 (root-lock → fix → gate) → **W2 (this)** → gate → release (deployah). Depends on W1: a fast-failing dial is the precondition for a clean deadline-close round (an unbounded dial would blow the round-timeout). Mint `REQ-PUMP-PEER-ISOLATION` + activate at work-start; expand this ruling into your design in earnest. Open Qs → doyle.

Co-authored by: doyle

## Design-check addendum — Q1-Q3 rulings (doyle, 2026-07-08, on PUMP-W2-DESIGN.md @3321229)

**Design-check verdict: APPROVED.** All 5 rulings honored; premise grounded (D4c `PresenceLog` string-kind forward-compat msg.rs:904, §V4 stagger, ADR-0034 `op_id`/`minter` dedup); the re-pointed IPC-deadline asserts BOTH directions (dead peer never restarts / dead broker STILL does — the check I required). Ready to mint `REQ-PUMP-PEER-ISOLATION` + build POST-W1-gate, in a `.worktrees/` worktree (NOT the shared main checkout).

- **Q1 — round-deadline value:** a **DERIVED CONST** = `W1_final_bound + fixed margin (~2s)`, NOT a config knob (no operator-facing tuning surface for an internal timing detail — a knob is a mis-tune/maintenance liability). Reference W1's bound constant directly; **pin the value when W1 lands its final bound** (genuine W1-before-W2 dependency — don't hardcode 10s now). Margin covers event IPC transit + drain scheduling + slack for the concurrent dials to all resolve (~one bound, not N×bound).
- **Q2 — semaphore home:** **NetHost-SHARED** (not pump-scoped). It guards the broker's iroh connection resource, which lives at the NetHost, and protects ALL dial callers (pump + shellwake + pairing) from a concurrent-dial storm — a pump-scoped limiter leaves the others unprotected + duplicates the concern. **CAVEAT (load-bearing):** the existing BLOCKING `dial()` MUST acquire the SAME permit too — otherwise the cap only bounds `submit_dial` while `net_dial` piles on unbounded on top, defeating the cap. Both dial paths draw from the one semaphore.
- **Q3 — `peer_step` failure mid-round (a CONNECTED peer whose worker step errors, e.g. stream write fails):** **per-peer drop + backoff, SAME path as `DIAL_FAILED`, never round-wide** (todlando's proposal — endorsed). A peer whose stream writes fail is effectively unreachable this round; drop its conn + apply the per-peer backoff; the round continues for other peers. Bubbling it round-wide would re-introduce the whole-round poison in a new spot. Don't build a separate transient-vs-dead distinction — a recovered peer's next successful connect resets its backoff.

Two confirmations on the design (already correct in @3321229, keep them): (a) **`DISCONNECTED` stays NO-backoff** — a previously-reachable peer that drops is redial-eligible IMMEDIATELY next round; only `DIAL_FAILED` (couldn't reach) applies backoff. (b) **large-roster > semaphore-cap:** dials queued behind a permit resolve past the round deadline → left for the next round (per-peer `next_due`), never a round failure — the deadline-close wedge-proofness already covers it.

Co-authored by: doyle
