# Roundhouse MVP9 — Build Architecture & Work Breakdown

**File: `mvp1/MVP9-SPEC.md`** (beside `roundhouse.py`; `MVP9.md` at repo root stays the contract — its acceptance checklist is the definition of done, its Out-of-scope list is binding).

Grounded in: `MVP9.md` (contract), E/F/G/H/I/J/K-series — all stand; **this spec amends exactly two frozen literals: `serverInfo.version` `'8.0'` → `'9.0'` (the catalog stays 18 — no new MCP tool, explicitly), and J8's `_probe_peer` signature gains an index into the declared table (the discipline — no host/port parameters, endpoints unrepresentable outside the startup-frozen declaration — stands unchanged).** Base: `mvp1/roundhouse.py` @ ffd22a9 (8582 lines), 798 green tests. Recon findings that shaped this spec: **(1)** `cmd_serve`'s multi-bind loop (8422–8463), listener threads (8478–8486), and ordered main-thread shutdown (8503–8515) are exactly the per-address setup/teardown the bind watch must mirror; the signal handler (8465–8476) is already minimal (set event + journal terminate) and needs zero edits. **(2)** `ThreadingHTTPServer.__init__` (3591) takes all shared state as kwargs and resolves `peer_interval` in the ctor, not the signature — the same pattern carries `bind_watch`; `server_bind` (3617) already pins `IPV6_V6ONLY`. **(3)** `take_snapshot` (3624) merges `mode`/`rollout`/`peers` under the non-reentrant `watcher_lock` — `listeners` joins the same spot, same `_unlocked` convention. **(4)** `PeerWatch.declared` is `{name: (host, port)}` consumed by `_probe_peer` (4509, injectable-`connect` default argument as the guard-passing test seam) and `_event_payload` (4302); `peer_watch_round` (4571) probes and fetches outside every lock. Dozens of test fixtures construct PeerWatch with tuple-shaped `declared` — the candidate-list change must normalize in the ctor or churn ~50 green tests. **(5)** The AST guards: `test_outbound_connect_confined` (test_server.py 1074) confines `socket.socket(...)` constructions and connect-attrs to `PROBE_CALLSITES = {'_probe_peer'}` and pins **exactly one** `create_connection` node (1134); `FETCH_CALLSITES` (1144) confines urllib; insecure-TLS constructs are asserted absent (1187). The presence probe needs a raw `socket.socket` — the callsite set must grow by exactly one named function or the guard goes red. **(6)** `validate_peer_sets` (3935) already refuses a fleet peer named as the local host (MVP8 review item b) — candidate lists change nothing there. **(7)** `allow_reuse_address` (SO_REUSEADDR) is inherited on every listener: on Linux a specific-address bind **succeeds beside our own same-port wildcard** — an optional bind behind a mandatory wildcard would silently "work" as a pointless duplicate door. That must be a startup refusal, not a runtime discovery. **(8)** All of `127.0.0.0/8` binds on `lo` with zero configuration — loopback aliases cannot prove appearance/disappearance. The drill's scratch address must be non-loopback (TEST-NET-1, RFC 5737); unit tests get a real optional bind via `127.0.0.2` and real absence via unassigned `192.0.2.x`, but *appearance* is provable only in the root container. **(9)** `'999.1.1.1'` fails `inet_pton` and would silently classify as a hostname that never resolves — all-digits-and-dots tokens and colon-bearing tokens must be literal-or-error, never names. **(10)** `getaddrinfo` has no timeout parameter and can block for seconds on a dead resolver — name resolution must live on the bind-watch thread, outside every lock, and the shutdown join must tolerate it. **(11)** `net.ipv4.ip_nonlocal_bind=1` (and IP_FREEBIND, which we never set) would make every bind succeed and blind the presence probe — documented residual, operator-controlled sysctl, out of threat model. **(12)** The version literal `'8.0'` lives at roundhouse_mcp.py 642 and test_mcp.py 209; the fleet-row shape is produced by the single `rows_unlocked` (4330), so endpoint fields appear on `/api/peers`, the snapshot, and SSE from one edit.

## 1. GLOBAL DECISIONS (L-series; implementers must not re-open them)

- **L1 — `--bind-optional ADDR|NAME`: repeatable AND comma lists; literals canonicalized, NAMES allowed and re-resolved every cycle; wildcards refused; overlap with `--bind` is a configuration error; optional failures are never fatal; loopback-mandatory is ADVISED in docs, never enforced.** Grammar §3.1. Names are the point of the flag (`ampere.vpn` exists only at the desk), so J2's literals-only rule applies to `--bind` alone and is **not** relaxed there. Wildcard tokens (`0.0.0.0`, `::`, canonical or bracketed) → startup refusal: `'<tok>': a wildcard cannot be absent — optional binds are for addresses that come and go; bind it with --bind or not at all`. An address canonically present in both lists → `'<addr>' is declared both --bind and --bind-optional — an address is mandatory or optional, not both`. An optional **literal** whose family is wildcard-covered by a mandatory bind → refusal (`'0.0.0.0' already covers '<addr>' — an optional bind behind a wildcard can never matter`; recon 7 makes this a silent no-op otherwise); name-resolved addresses of a wildcard-bound family are filtered at cycle time instead (names are unknowable at startup). The MVP7 all-or-nothing rule is untouched: mandatory binds still fail startup loudly; **no optional-bind outcome — absent, in use, permission — ever exits the process.** Loopback-mandatory is a documented recommendation (PEERS.md), NOT code: a pure-VPN host that serves only `--bind-optional` addresses plus a mandatory `::1`-less config is a legitimate operator choice, and enforcement would guess intent. Caps: `BIND_OPTIONAL_MAX = 8` declarations; `RESOLVE_ADDR_MAX = 4` addresses per name per cycle (first 4 getaddrinfo results after dedupe, deterministic). Port: always `--port` — one port, N doors, unchanged.
- **L2 — Presence: the bind attempt IS the probe; `EADDRNOTAVAIL` (alone) means absent; every other `OSError` means error-but-continue; loss is detected by a throwaway port-0 bind per cycle — not /proc/net.** Classification, frozen: bind raises `OSError` with `errno == errno.EADDRNOTAVAIL` → state `absent` (not an error; the address is not here right now); `gaierror` on a name → the name contributes no addresses this cycle (row `absent`, `last_error` = the gaierror text); ANY other `OSError` (`EACCES`, `EADDRINUSE`, everything else) → state `error`, `last_error = f'[Errno {e.errno}] {e.strerror}'`, retried next cycle, process continues, reported per-address. Loss: a removed address leaves its bound socket alive on Linux, so each cycle runs `_presence_probe(addr, family)` — `socket.socket(family, SOCK_STREAM)`, `bind((addr, 0))`, `close()` — for every bound optional listener; `EADDRNOTAVAIL` → the address left, tear the listener down. **Chosen over /proc/net parsing because:** it is the same primitive as the presence probe (the contract's "the bind attempt is the probe" applied symmetrically), one syscall triple, family-uniform — while IPv4 has no per-address /proc file (`fib_trie` parsing is fragile) and `/proc/net/if_inet6` would split the mechanism per family. False modes, stated: a tentative IPv6 address (DAD in progress) reads absent until DAD completes — self-heals within one cycle; a transient reconfiguration can cost one 30 s blip (listener closed, re-bound next cycle); false-present has no known Linux path absent `ip_nonlocal_bind`/IP_FREEBIND (recon 11, documented residual). A presence-probe failure with any errno OTHER than `EADDRNOTAVAIL` leaves the listener alone — never tear down a serving door on a weird error.
- **L3 — The bind watch: one daemon thread (`bind-watch`), `--bind-retry` cadence (default 30 s, min 1), first cycle immediately; desired-vs-actual reconciliation, idempotent; NEVER holds any lock across resolve/bind/close/join; main-thread shutdown JOINS the watch before closing listeners.** Full spec §3.3–3.4. The thread exists only when ≥ 1 optional bind is declared; a `BindWatch` object exists whenever `cmd_serve` runs (it owns the `listeners` rows, mandatory included). Adding a listener = `_make_listener` + daemon thread `http-opt-<addr>` + announce line, mirroring cmd_serve's per-address setup; removing = `shutdown()`, `server_close()`, `join(5.0)`, announce, mirroring the ordered shutdown. Startup never blocks on optional binds (recon 10: a dead resolver must not stall boot); the immediate first cycle means a laptop at home serves its home address within milliseconds of thread start. Interaction with the three existing loops: none — shared state is the lock-guarded rows and the event bus, nothing else. Concurrency rules frozen in §3.3: all socket work and resolution outside every lock; one short `watcher.lock` hold per cycle to write rows and publish events; the `servers` registry is bind-watch-thread-private until the thread is joined (single-writer, no lock); the cycle re-checks `shutdown_event` before registering a fresh listener and closes it unregistered if set.
- **L4 — `listeners` in the snapshot: one row per declaration, frozen shape `{addr, port, kind, state, since, last_error, resolved}`; merged in `take_snapshot` under `watcher_lock` like `peers`; snapshot + SSE `listener` event + UI strip — NO new route, NO new MCP tool.** `addr` = canonical literal, or the declared NAME for name rows; `port` = `--port`; `kind` = `mandatory|optional`; `state` = `bound|absent|error` (mandatory rows are `bound` always, by all-or-nothing); `since` = when the current state was entered; `last_error` = null when bound; `resolved` = null on literal/mandatory rows, on name rows the list of currently-bound canonical addresses (possibly empty). Row order = declaration order, mandatory first (binds are operator-ordered lists; honoring the order is this milestone's doctrine). A name row is `bound` when ≥ 1 resolved address is bound; `absent` when resolution yields nothing bindable; `error` when something failed with a real errno and nothing is bound. The SSE `listener` event fires on state transitions AND on a bound name row whose `resolved` set changed — that resolved-flip is precisely the roaming moment the UI must show. Payload = the row + `prev_state`. UI: a non-interactive `#listener-strip` (§5.4), textContent only, muted colors, no 44 px selector amendment.
- **L5 — Candidates are a `--fleet-peer`-only feature: `NAME=URL[,URL...]`, ordered, capped at 3; the walk is from-the-top every round, stops at the first answer; `endpoint_in_use`/`endpoint_changed_at` on the row; hysteresis counts full-walk failures only.** `--peer` stays exactly J3 (single `HOST:PORT`, no commas): the contract names only fleet peers, ampere's TCP-peer case is subsumed by declaring it a fleet peer, a comma grammar in `--peer` re-opens the quoting bugs J3 rejected, and two candidate mechanisms double the tests for zero users — **stated as decided, do not build TCP candidates.** Grammar §4.1: split first `=`, split value on `,`, each token a full MVP8-§3.1 URL (scheme http|https, no path/query/fragment/credentials, normalized `scheme://host:port`); duplicate normalized URL within one peer → error; > `FLEET_CANDIDATE_MAX = 3` → error naming the budget (§4.4). Storage: `PeerWatch.declared` becomes `{name: [(host, port), ...]}` and `PeerWatch.fleet` becomes `{name: [url, ...]}` (indices aligned); **the ctor normalizes the old single-endpoint shapes into length-1 lists** so the ~50 existing fixtures stay green (recon 4) — new code always passes lists. Walk (§4.2): probe candidates in declared order, stop at first success; the winner's index becomes `endpoint_index`, its URL (fleet) or `host:port` (tcp) becomes `endpoint_in_use`; a change of winner updates `endpoint_changed_at` and emits ONE `peer` SSE event even when reachability state did not change. **Hysteresis stays peer-level:** a round where ANY candidate answers is a success (`consecutive_failures = 0`); `cf` increments only when the whole walk fails; moving 1→2 never flaps the peer. **Re-walk from the top every round** (not only on failure): a returning preferred endpoint is picked up within one round, "the sofa is first because you said so" is honored continuously, and no sticky-endpoint state machine exists. Cost, stated: a dead candidate 1 charges its probe failure (≤ 2 s timeout, ~0 s when refused) every round — bounded by §4.4's arithmetic. The fetch uses the winner's URL and ONLY the winner's (`_fetch_peer` gains `index`); when the walk fails but the peer is still `up` (cf = 1), the fetch is **skipped** — fed data is untouched for that round (not stale; staleness comes from a failed fetch or the up→down transition, one round of fresh-but-old is accepted and stated).
- **L6 — Budget, frozen and executable: `FLEET_CANDIDATE_MAX = 3`; worst round = 4×2 + 4×((3−1)×2 + 2×4) = 56 s < 60 s; NO independent per-peer probe budget.** Worst case with fetches, per fleet peer: 2 dead candidates (4 s) + winner (≈0) + two 4 s fetches = 12 s; all-candidates-dead costs only 6 s and no fetch, so 12 s is each fleet peer's ceiling. 4 fleet peers (48 s) + 4 TCP peers × 2 s (8 s, the combined cap `PEER_MAX = 8` leaves at most 4 TCP beside 4 fleet) = **56 s**. Candidate cap 4 would have pinned 64 s > 60 — that is why the cap is 3, and ampere fits: `ampere.fritz.box`, `[fd96:cafe:cafe::1000]`, one `10.170.x` (the operator picks; documented). A per-peer probe-time cap independent of candidate count is REJECTED: it would abandon a candidate the operator declared, violating the order-is-honoured contract; the candidate cap IS the budget mechanism. The cadence remains a floor (`round; wait(interval)`), K3's overrun-delays-next-round-only reasoning carries. **The pin is executable:** a test computes the worst case from the four constants and asserts ≤ 60 (§7.4).
- **L7 — Every candidate is a full citizen of D2 and TLS: per-candidate startup refusal, per-candidate verification, and mixed http/https candidate lists are LEGAL.** D2: every candidate's derived `(host, port)` goes through `validate_peers` at startup under the synthetic name `<name>[<i>]` (1-based), so a refusal reads `peer 'bee[2]' targets 127.0.0.1:8085, which is managed unit …` and names exactly the offending candidate; host-AND-port, as ever. TLS: `_fetch_peer` builds its verified context per call — hostname checking runs against whichever candidate's host is in the URL being fetched, so per-candidate verification is automatic and bypass-free (the K9 absence guards are untouched). Mixing `http` and `https` within one peer's list is legal **because trust is a property of the path, not the peer**: a LAN candidate fronted by caddy speaks https against the fritz.box CA, while a ULA VPN candidate speaks plain http inside an already-encrypted tunnel — refusing the mix would force a fake cert onto the VPN leg or strip TLS from the LAN leg, both strictly worse. Stated in PEERS.md.
- **L8 — Guard evolution: listening-socket construction is confined to ONE named function `_make_listener` (a NEW `SERVER_CALLSITES` leg); the presence probe joins `PROBE_CALLSITES`; the probe/presence distinction is structural — `_probe_peer` connects and never binds, `_presence_probe` binds and never connects, both asserted.** Full spec §6. `cmd_serve`'s inline `ThreadingHTTPServer(...)` construction moves into `_make_listener(addr, family, port, server_kwargs)`; the bind watch calls the same function — the guard then reads: every `ast.Call` of `ThreadingHTTPServer` sits in `SERVER_CALLSITES = {'_make_listener'}`. `PROBE_CALLSITES` becomes `{'_probe_peer', '_presence_probe'}` (the one growth the raw-socket guard ever gets); the exactly-one-`create_connection` leg is unchanged (the presence probe never connects). New structural legs: zero connect-attrs inside `_presence_probe`, zero `bind` attrs inside `_probe_peer`. `FETCH_CALLSITES`, the insecure-TLS absence guard, write-verb/subprocess/file-write/section-span guards: zero edits, green over the grown source (`[A-F]` already matches; Section F grows in place). Wildcard-refusal test at parse level; three seeded violations in §6.3.
- **L9 — Docs: `docs/PEERS.md` gains the roaming section — declaring optional binds, the laptop pattern with ampere as the worked example (real addresses), candidate lists, what `listeners` state means; the packaged-unit note keeps loopback mandatory in the ExecStart example.** Outline §5.6. The ampere example uses the true facts: at home `wlP2p33s0` = 192.168.88.168 and no VPN interface; at the desk `ampere.vpn` = `fd96:cafe:cafe::1000` (plus 10.170.16.6/10.170.17.6); Roundhouse on :8099 because the packaged :8090 collided with gemma-npu — the all-or-nothing rule catching that collision is retold as the loudness doctrine working. Both sides shown: ampere's `--bind 127.0.0.1 --bind-optional 192.168.88.168,ampere.vpn`, boltzmann's `--fleet-peer ampere=http://ampere.fritz.box:8099,http://[fd96:cafe:cafe::1000]:8099`. README: one line. `roundhouse.service`: not edited.
- **L10 — The drill (`scripts/roam-drill.sh`) proves appearance/disappearance with a REAL address (`ip addr add/del 192.0.2.9/32 dev lo`, root-gated, trap-cleaned) and composes both features: instance B optionally binds the scratch address, instance A federates B through a two-candidate list whose first candidate IS that address.** Full spec §7.6. With the address absent, A reaches B via candidate 2 (`127.0.0.1`); `ip addr add` makes B bind within one `--bind-retry`, and A's next from-the-top walk flips `endpoint_in_use` to candidate 1 with exactly one `peer` event and zero state flaps — the whole roaming story, both sides, in one container. The dead-first-candidate leg and the wildcard/EADDRINUSE refusal legs ride the same script.

## 2. FILE / SECTION LAYOUT

```
mvp1/
  MVP9-SPEC.md                  # this file
  roundhouse.py                 # Section F grows; Section C ctor kwarg + snapshot merge; Section D wiring
  roundhouse_mcp.py             # version literal only ('8.0' → '9.0'); catalog stays 18
  static/index.html             # listener strip + peer-row endpoint text (additive)
  scripts/
    roam-drill.sh               # NEW: container drill (§7.6) + live ampere/boltzmann checklist
  tests/
    test_binds.py               # NEW: §7.1–7.2 (optional-bind parsing, presence classification, BindWatch cycle — injected seams, no root)
    test_peers.py               # extended: candidate parsing/walk/endpoint state (§7.3); round-level fakes gain the index param
    test_server.py              # extended: §6 guards, §7.4 integration, §7.5 statics
    test_mcp.py                 # version literal only
docs/
  PEERS.md                      # extended (§5.6)
```

**Section F additions (complete):** `BIND_RETRY_SEC = 30`; `BIND_OPTIONAL_MAX = 8`; `RESOLVE_ADDR_MAX = 4`; `FLEET_CANDIDATE_MAX = 3`; `parse_bind_optional()`; `validate_bind_retry()`; `_make_listener()`; `_presence_probe()`; `class BindWatch`; candidate growth inside `parse_fleet_peer_decls` (comma split + per-candidate normalization + dup/cap errors); PeerWatch growth (`declared`/`fleet` become lists with ctor normalization, `endpoint_index`/`endpoint_in_use`/`endpoint_changed_at` state, walk in `peer_watch_round`, `winner_index=` on `apply_result_unlocked`, `index=0` on `_probe_peer`/`_fetch_peer`). **The Section F banner text is edited in place** to add `; _make_listener is the ONE listener-construction site, _presence_probe the ONE bind-probe site` (no `_section_spans` change — `[A-F]` matches).

**Edits to existing code (exhaustive):** **Section C:** `ThreadingHTTPServer.__init__` gains `bind_watch=None` (stored, ctor-resolved like `peer_interval` — recon 2); `take_snapshot` gains `snapshot['listeners'] = self.bind_watch.rows_unlocked() if self.bind_watch else []` inside the existing lock hold. **Section D:** argparse gains `--bind-optional` (append) and `--bind-retry` (int, default `BIND_RETRY_SEC`); `cmd_serve`: parse+validate optional binds into the existing batched loud exit; per-candidate D2 (§4.1); build `server_kwargs` once; the §3.3 mandatory loop now calls `_make_listener`; construct `BindWatch` (always) and pass it to every server; start the bind-watch thread when optional decls exist (after listener threads, before the peer thread); the shutdown sequence per §3.4. **Section E / Watcher / parser / `FROZEN_POST_ROUTES` / `get_only`: zero edits** (no new route).

**Frozen-set edits permitted to existing tests (exhaustive):** `/api/peers` + snapshot peer-row key assertions gain `endpoint_in_use`, `endpoint_changed_at` (+ `endpoints` on fleet rows); `take_snapshot` key-set assertions gain `listeners`; round-level `_probe_peer` fakes in test_peers.py gain the `index` parameter; test_mcp.py version literal `'9.0'` (line 209). Nothing else in the E–K-era test files may change.

## 3. OPTIONAL-BIND SPEC

### 3.1 `parse_bind_optional(values: Optional[List[str]], mandatory: List[tuple[str, int]]) -> tuple[List[dict], List[str]]`

Input: argparse append list (None → `([], [])`) plus the already-parsed mandatory list. Flatten repeats and commas exactly as `parse_bind_list` (empty token → same error text). Per token, classify: **(a)** strip one `[...]` layer; `inet_pton` AF_INET then AF_INET6 → literal, canonicalized via `inet_ntop`. **(b)** a token that failed (a) but matches `^[0-9.]+$` or contains `:` → error `'<token>': not a valid IP literal` (recon 9 — never silently a name). **(c)** otherwise a NAME: length ≤ 253, no whitespace/`/`/`[`, must match `^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$` else error `'<token>': not a valid address or hostname`. Refusals over the classified list, all collected: wildcard literal (L1 text); duplicate (canonical literal, or casefolded name); literal ∈ canonical mandatory set (L1 both-lists text); literal whose family has a mandatory wildcard (L1 coverage text; recon 7); count > `BIND_OPTIONAL_MAX` → `too many optional binds (N > 8): each is a listener and a per-cycle presence probe`. Returns ordered decls `[{'token': str, 'kind': 'literal'|'name', 'addr': canonical|None, 'family': int|None}]`. `validate_bind_retry(v)`: min 1, message mirroring `validate_peer_interval`. Both join the single batched loud exit.

### 3.2 `_make_listener` and `_presence_probe` (frozen; the ONLY sites of their kind)

```python
def _make_listener(addr, family, port, server_kwargs):
    """THE one listener-construction site (SERVER_CALLSITES, §6.1) — cmd_serve's
    mandatory loop and BindWatch.cycle both build every server here."""
    return ThreadingHTTPServer((addr, port), RoundhouseRequestHandler,
                               address_family=family, **server_kwargs)

def _presence_probe(addr, family, sock_factory=socket.socket):
    """THE one bind-probe site (PROBE_CALLSITES, §6.1). Binds port 0, never connects.
    Returns (present: bool, error: Optional[str]) — only EADDRNOTAVAIL means absent;
    any other failure reports but reads PRESENT (never tear down a door on a weird errno)."""
    s = sock_factory(family, socket.SOCK_STREAM)
    try:
        s.bind((addr, 0))
        return (True, None)
    except OSError as e:
        if e.errno == errno.EADDRNOTAVAIL:
            return (False, None)
        return (True, f'[Errno {e.errno}] {e.strerror}')
    finally:
        s.close()
```

`server_kwargs` is built once in `cmd_serve`: `dict(watcher=…, event_bus=…, port=…, watcher_lock=…, rollout_engine=…, advertise_host=…, peer_watch=…, peer_interval=…, bind_watch=…)` — positional args in the current ctor become kwargs there (one mechanical edit, T1). New `import errno` joins the header (stdlib guard untouched).

### 3.3 `class BindWatch` + the cycle (frozen)

```python
class BindWatch:
    def __init__(self, mandatory, optional_decls, port, server_kwargs, lock, event_bus,
                 shutdown_event, now=time.time, resolver=socket.getaddrinfo,
                 presence=_presence_probe, make_listener=_make_listener):
        # rows: one per declaration, L4 shape; mandatory seeded state='bound', since=now().
        # servers: {addr: (server, thread, family)} — BIND-WATCH-THREAD-PRIVATE until join (L3).
        # wildcard_families: families wildcard-bound by mandatory (cycle-time name filter, L1).
    def rows_unlocked(self) -> List[dict]:   # L4 rows, declaration order. CALLER HOLDS lock.
    def cycle(self):                          # one reconciliation pass; idempotent
```

The cycle, in order — **no lock is held anywhere in phases 1–3**:
1. **Desire.** `desired = {}` (canonical addr → (family, decl)). Literals: always desired. Names: `resolver(name, None, type=socket.SOCK_STREAM)` in a try/except (`gaierror` → note on the row, contributes nothing); dedupe canonical, cap `RESOLVE_ADDR_MAX`, filter wildcard-covered families and addresses ∈ the mandatory set (a name resolving onto a mandatory bind is already served — skip, don't EADDRINUSE against ourselves); first declaration in order owns a contested address.
2. **Remove.** For each bound `addr` ∉ desired → remove (reason `resolution changed`). For each bound `addr` ∈ desired → `presence(addr, family)`; `(False, None)` → remove (reason `address gone`). Remove = `srv.shutdown()`, `srv.server_close()`, `thread.join(5.0)`, announce `Roundhouse optional bind released <disp>:<port> (<reason>)` (flush).
3. **Add.** For each desired `addr` not bound → `make_listener(addr, family, port, server_kwargs)`; `OSError` → classify per L2 into row state `absent`/`error`; success → **if `shutdown_event.is_set()`: `server_close()` and return** (the race gate) — else start daemon thread `http-opt-<addr>`, register in `servers`, announce `Roundhouse optional bind bound http://<disp>:<port>` (flush).
4. **Publish.** ONE short `with lock:` hold: write all row changes (state/since/last_error/resolved), and for each L4 transition (state change, or bound-name resolved-set change) `event_bus.publish('listener', row + {'prev_state': …})` (put_nowait — the recon-4 pattern from MVP7).

Thread body: `cycle()` immediately, then `while not shutdown_event.is_set(): if shutdown_event.wait(bind_retry): break; cycle()` — `wait`, never `sleep`. Idempotence: two consecutive cycles with an unchanged world make zero socket mutations and publish zero events (tested, §7.2).

### 3.4 Lifecycle + shutdown ordering (frozen; supersedes nothing — extends J1's sequence)

Construction order: engine → PeerWatch → **BindWatch (object, always)** → mandatory servers all bound via `_make_listener` (all-or-nothing loop unchanged) → announce → signal handlers → listener threads → **bind-watch thread (only if optional decls)** → peer thread → main wait loop. Signal handler: unchanged. After the wait loop exits, the main thread: **(1) joins the bind-watch thread, timeout 10 s** — with the event set, its `wait` returns immediately and any in-flight cycle finishes; a hung `getaddrinfo` (recon 10) times the join out and we proceed (the leaked daemon listener dies with the process — stated, accepted); **(2)** `shutdown()` then `server_close()` on mandatory servers in construction order (existing loops); **(3)** `shutdown()` + `server_close()` on every entry of `bind_watch.servers` (safe to read: the thread is joined or abandoned — single-writer rule, L3); **(4)** join all listener threads, timeout 5 s. Join-before-close is the load-bearing order: no listener can be added after the close pass because the only thread that adds them is gone (plus the phase-3 race gate for the timeout path).

## 4. MULTI-ENDPOINT SPEC

### 4.1 Grammar + validation

`parse_fleet_peer_decls` grows: after the first-`=` split, the value splits on `,`; each token is normalized by the existing MVP8-§3.1 URL rules (unchanged messages, now per candidate: `candidate <i> of '<name>': <msg>`). Duplicate normalized URL within a peer → `duplicate candidate for fleet peer '<name>': <url>`; > `FLEET_CANDIDATE_MAX` → `too many candidates for fleet peer '<name>' (N > 3): the probe walk must fit the 60 s round (worst case 56 s at 3, §L6)`. Return shape: `{name: [(host, port, url), ...]}`. `cmd_serve` D2: `validate_peers({f'{name}[{i}]': (host, port) for each candidate}, …)` — every candidate refused host-AND-port style with the candidate named (L7); `validate_peer_sets` unchanged (counts peers, not candidates; recon 6 carries).

### 4.2 The walk (in `peer_watch_round`; outside every lock)

```python
    for name in peer_watch.declared:
        candidates = peer_watch.declared[name]          # [(host, port)], length 1 for tcp
        winner, errors = None, []
        for i in range(len(candidates)):                # from the top, EVERY round (L5)
            ok, err = _probe_peer(peer_watch, name, index=i)
            if ok: winner = i; break
            errors.append(f'candidate {i+1}: {err}')
        error = None if winner is not None else '; '.join(errors)[:200]
        with peer_watch.lock:
            payload = peer_watch.apply_result_unlocked(name, winner is not None, error,
                                                       peer_watch.now(), winner_index=winner)
            state_now = peer_watch.peers[name]['state']
            if payload: peer_watch.event_bus.publish('peer', payload)
        if name in peer_watch.fleet and state_now == 'up' and winner is not None:
            # fetch legs exactly as MVP8 §3.4, both calls gaining index=winner
```

`_probe_peer(peer_watch, name, index=0, connect=socket.create_connection)`: endpoint = `peer_watch.declared[name][index]` — still no host/port parameters, still the single `create_connection` node (guard unchanged). `_fetch_peer(peer_watch, name, path, opener=None, index=0)`: `url = peer_watch.fleet[name][index] + path`; `assert path in FLEET_PATHS` unchanged. Trailing-default signatures keep every existing direct-call test green.

### 4.3 Endpoint state (in `apply_result_unlocked`, under the lock)

Per-peer state gains `endpoint_index` (init 0), `endpoint_in_use` (init: the single `host:port` string for tcp rows; None for fleet rows until the first winner), `endpoint_changed_at` (init None). On a successful walk: if `winner_index != endpoint_index` or `endpoint_in_use` is None → set all three (`endpoint_in_use` = the winner's normalized URL for fleet, `host:port` for tcp; `endpoint_changed_at = ts`) and **if the §4.3-MVP7 table produced no transition event, return the payload anyway with `prev_state = state`** (one event max per apply; a transition event simply carries the new endpoint fields). On a failed walk: endpoint fields unchanged — after `down`, the row honestly names where the peer was last reached. The hysteresis table itself is byte-for-byte MVP7 §4.3: `cf` counts whole-walk failures only (L5). Row/payload additions (one producer, `rows_unlocked`): `endpoint_in_use`, `endpoint_changed_at` on every row; `endpoints` (the ordered normalized URL list) on fleet rows only; fleet rows' `host`/`port` fields now reflect `declared[name][endpoint_index]` (stated in PEERS.md: they name the current endpoint, `endpoints` names the choices).

### 4.4 Budget (frozen; executable)

`PEER_TIMEOUT_SEC = 2.0`, `FETCH_TIMEOUT_SEC = 4.0`, `PEER_MAX = 8` combined, `FLEET_PEER_MAX = 4`, `FLEET_CANDIDATE_MAX = 3`. Worst round = `(PEER_MAX − FLEET_PEER_MAX) × 2 + FLEET_PEER_MAX × ((FLEET_CANDIDATE_MAX − 1) × 2 + 2 × 4)` = 8 + 48 = **56 s < 60 s**; all-candidates-dead = 32 s. Pinned by a test computing exactly that expression from the live constants (§7.4) — a future cap bump must re-do the arithmetic to go green.

## 5. SURFACES SPEC

### 5.1 Snapshot key `listeners` — L4 shape, merged in `take_snapshot` under the existing lock hold, `[]` when no BindWatch (direct-constructed test servers). No new route, no MCP tool: the snapshot and the UI are the whole surface (decided; `/api/units` consumers see it via take_snapshot-backed routes that already serve the snapshot — nothing new to freeze there).

### 5.2 SSE `listener` event — transition-only per L4 (state changes + bound-name resolved-set changes); payload = row + `prev_state`; rides the existing bus/stream mechanics; mid-life clients need no replay (the `snapshot` event carries current `listeners` — J7 logic verbatim).

### 5.3 Peer-row additions — §4.3 fields on `/api/peers`, the snapshot `peers` key, and every `peer` SSE payload, from the single producer. The `/api/peers` envelope (`probe`/`means`) is unchanged.

### 5.4 UI (additive; textContent only; neutral)

`#listener-strip` beside `#peer-strip` (same visual family), title `listening`, hidden when `listeners` is empty (never on a served snapshot, so effectively always visible — shows mandatory doors too, which is the point: "why is the fleet view empty" starts with "what am I bound to"). One `<span class="listener-cell">` per row: `addr · state` (name rows append ` → a1, a2` from `resolved` when bound); `bound` = default text color, `absent`/`error` = the existing muted variable — no red, no amber. `renderListenerStrip()` from `state.snapshot.listeners`, called on `snapshot` events and from `addEventListener('listener', …)` patching by `addr` (unique: a literal-shaped name is impossible by §3.1 classification). Peer strip cells for fleet peers append ` · via <endpoint_in_use-host>` when candidates > 1 (host part only — URLs are long); non-interactive, no 44 px amendment, no innerHTML, no localStorage.

### 5.5 MCP — catalog stays 18 (NO new tool — L4); `serverInfo.version` `'8.0'` → `'9.0'` (roundhouse_mcp.py 642, test_mcp.py 209). `peer_status`/`fleet_roster` are passthroughs and carry the new row fields automatically; docs/MCP.md is untouched (the `18-Tool` heading stands).

### 5.6 `docs/PEERS.md` roaming section (T2; ~1 page)

1. **Optional binds:** grammar (repeats/commas, literals AND names, names re-resolved every cycle — that is the point), the wildcard refusal and its reason, absent-vs-error semantics (EADDRNOTAVAIL is absence; EADDRINUSE/EACCES are per-address errors that never kill the process), `--bind-retry`, the loopback-mandatory RECOMMENDATION (advised, not enforced — a pure-VPN host is legitimate; stated). 2. **The laptop pattern, worked example = ampere** (real addresses per L9, both ExecStart lines, the :8099-not-:8090 story). 3. **What `listeners` means:** the row shape, `since`, name rows' `resolved`, the one-cycle blip on reconfiguration, the `ip_nonlocal_bind` residual. 4. **Candidate lists:** ordered, operator-owned, cap 3 and why (56 s < 60 s), re-walk from the top every round, `endpoint_in_use`/`endpoint_changed_at`, fleet-row `host:port` = current endpoint, hysteresis is peer-level, `--peer` deliberately has no candidates (declare a roaming host as a fleet peer). 5. **Mixed http/https candidates are legal** (L7's trust-is-the-path paragraph). 6. **Packaged unit note:** unchanged file; the ExecStart examples keep a mandatory loopback bind.

## 6. GUARD SPEC (L8 — security-critical, exact)

### 6.1 Guard evolution in `TestWriteGuards`

- **`test_listener_construction_confined` (NEW):** every `ast.Call` whose func resolves to the name `ThreadingHTTPServer` has `_enclosing_func ∈ SERVER_CALLSITES = {'_make_listener'}`; exactly ONE such call node exists in the file (the class *definition* is not a Call and needs no exemption). This is the "server socket we create" half of L8: construction is a named, single site referenced by cmd_serve and the bind watch alike.
- **`test_outbound_connect_confined` (EVOLVED):** `PROBE_CALLSITES` becomes `{'_probe_peer', '_presence_probe'}` (the `socket.socket(...)` leg now admits the presence probe's `sock_factory` default argument, which lives in `_presence_probe`'s node exactly as `_probe_peer`'s `connect` default does); the exactly-one-`create_connection` leg is UNCHANGED; the no-send/recv leg extends over `_presence_probe`. **Two NEW structural legs:** zero Attributes in `CONNECT_ATTRS` inside `_presence_probe` (it binds, never connects — the "outbound connect" half of L8), and zero Attributes named `bind` inside `_probe_peer` (it connects, never binds). The distinction is thereby asserted in both directions, not narrated.
- **Unchanged and re-run green:** `FETCH_CALLSITES`, `create_default_context`-exactly-once, insecure-TLS absence, write-verb, subprocess-gateway, file-write, snapshot-lock, arming, `_section_spans` (`[A-F]`), route-table sets (`FROZEN_POST_ROUTES` 9, `get_only` untouched — no new route).

### 6.2 Parse-level refusals as tests: the wildcard refusal (both `0.0.0.0` and `::`, bare and bracketed), the both-lists refusal, the wildcard-coverage refusal, and the literal-or-error trap (`999.1.1.1`, `1:2:3`) each asserted with exact message text in `test_binds.py`.

### 6.3 Seeded violations (seed → red → unseed at integration; red names in the commit message): **(s1)** an inline `socket.socket(family, socket.SOCK_STREAM)` presence check pasted into `BindWatch.cycle` → §6.1 probe guard red (the likeliest real erosion: "it's just a bind"); **(s2)** a `ThreadingHTTPServer(...)` constructed directly in `BindWatch.cycle` bypassing `_make_listener` → listener-construction guard red; **(s3)** a `connect(("192.0.2.1", 80))` call added inside `_presence_probe` → the no-connect structural leg red.

## 7. TEST SPEC

### 7.1 `tests/test_binds.py::TestBindOptionalParsing` (T1)

Table-driven over §3.1: repeats + comma mixes flatten in order; literal canonicalization (`[::1]` ≡ `::1`); names accepted (`ampere.vpn`, single-label `ampere`); wildcard refusals (4 spellings); duplicate literal after canonicalization; duplicate name casefolded; both-lists refusal; wildcard-coverage refusal (`--bind 0.0.0.0` default + optional `192.168.88.168` → error; optional `::1` beside v4-only wildcard → accepted); `999.1.1.1` and `1:2:3` → literal-or-error trap; hostname charset violations; 9 decls → cap; multiple errors all reported in one return; `validate_bind_retry` boundaries.

### 7.2 `TestPresence` + `TestBindWatchCycle` (T1; injected resolver/presence/factory/clock — no real address manipulation, no root)

Presence classification row-for-row via injected `sock_factory`: EADDRNOTAVAIL → `(False, None)`; success → `(True, None)`; EACCES → `(True, error-string)` (present-on-weird-errno, the L2 conservative rule); socket closed on every path. Cycle: appearance (factory succeeds → row bound, thread started, announce, `listener` event with prev_state absent); disappearance (presence returns absent → shutdown/close/join called on THAT server, row absent, event); EADDRNOTAVAIL on add → row absent, NO error event storm (flat cycles silent); EADDRINUSE on add → row error + retry next cycle; gaierror → name row absent with the text; resolution change (name resolves to a2 instead of a1 → a1 removed reason `resolution changed`, a2 added, ONE resolved-flip event when state stays bound); wildcard-family and mandatory-addr filtering of resolved names; `RESOLVE_ADDR_MAX` cap; contested-address first-decl-wins; **idempotence** (two cycles, unchanged world → factory/presence call counts prove zero mutations, zero events); **shutdown race gate** (event set between construct and register → `server_close` called, `servers` empty); **lock discipline** (injected factory and presence assert `lock.acquire(blocking=False)` succeeds mid-call — the Risk-#1 trap, mechanical); `rows_unlocked` never acquires.

### 7.3 `test_peers.py` candidate legs (T2)

Parse: comma lists per §4.1 (messages name the candidate); dup URL; cap-3 message; mixed http/https accepted; per-candidate D2 (`bee[2]` targeting `127.0.0.1:<managed>` → refusal naming the candidate; the same endpoint as `bee[1]`'s host on an unmanaged port → legal). Walk (injected `connect`): stop-at-first (candidate 2 never probed when 1 answers — call recorder); winner=1-dead-2-alive → success, `cf == 0`, `endpoint_index == 1` (the hysteresis-stays-peer-level row); full-fail error = `candidate 1: …; candidate 2: …` truncated 200; endpoint-change-without-transition → ONE event, `prev_state == state == 'up'`, `endpoint_changed_at` set; re-walk from top (round N winner 1, round N+1 candidate 0 answers → winner 0, one event); failed walk leaves endpoint fields untouched; tcp rows: `endpoint_in_use` constant, `endpoint_changed_at` null, no `endpoints` key. Fetch: injected opener records URLs → winner's URL only; walk-failed-but-up round → zero fetch calls, fed untouched (the skip rule). Ctor normalization: tuple-shaped `declared`/str-shaped `fleet` accepted and normalized (the compat seam itself is tested).

### 7.4 `test_server.py` integration (T1 lifecycle/guards; T2 candidates/surfaces)

- **Optional-bind integration (T1, real sockets, no root — recon 8):** roundhouse-shaped server set with `--bind 127.0.0.1` + optional `127.0.0.2` (binds for real: GET `/api/units` answers on both), optional `192.0.2.1` (row absent, process serving), optional occupied `127.0.0.3` (pre-bound scratch socket → row error EADDRINUSE, serving continues). Shutdown ordering: with an optional listener bound, run the §3.4 sequence → bind-watch thread joined first, then both doors refuse connects, no thread leaks.
- **Snapshot/SSE (T2):** `listeners` key shape frozen (exact key set per row incl. `resolved`); agrees with BindWatch rows same-call; `listener` SSE transition captured once per change, zero while flat; peer-row additions field-for-field across `/api/peers`/snapshot/SSE.
- **Real-socket candidate leg (T2):** fleet peer with candidates [dead-ephemeral-port, live listener] → up via 2, `endpoint_in_use` = candidate 2's URL; bind a listener on candidate 1's port → next round flips to 1, one event, state up throughout; close 1 → flips back; kill both → cf climbs, down after 2 full-walk failures.
- **Budget pin (T2):** `(PEER_MAX - FLEET_PEER_MAX) * PEER_TIMEOUT_SEC + FLEET_PEER_MAX * ((FLEET_CANDIDATE_MAX - 1) * PEER_TIMEOUT_SEC + 2 * FETCH_TIMEOUT_SEC) <= 60` asserted from the live constants (and `== 56.0`, so a silent constant drift is loud).
- **Guards (T1):** §6.1 legs green on the grown source; §6.2 message tests; §6.3 seeds run once at integration.

### 7.5 Statics (T2): `listener-strip` id present; `listening` title string; ` · via ` peer-cell fragment; no new innerHTML/localStorage; the frozen 44 px selector list byte-identical; existing static assertions green over the grown file.

### 7.6 `scripts/roam-drill.sh` (T2; container + live)

Root-gated: `[ "$(id -u)" = 0 ] || { echo 'roam-drill needs root for ip addr add/del'; exit 2; }`. Scratch address `SCRATCH=192.0.2.9` (TEST-NET-1 — non-loopback so absence is real; recon 8), managed exclusively via `ip addr add|del $SCRATCH/32 dev lo`, with `trap 'ip addr del 192.0.2.9/32 dev lo 2>/dev/null || true' EXIT`. Legs:
1. **Refusals:** `--bind-optional 0.0.0.0` → exit 1, wildcard message; `--bind 127.0.0.1 --bind-optional 127.0.0.1` → exit 1, both-lists message.
2. **Appearance/disappearance for real:** instance B `--bind 127.0.0.1 --bind-optional $SCRATCH --bind-retry 2 --port PB`; assert row absent + curl to $SCRATCH fails; `ip addr add` → within 2 cycles curl `http://$SCRATCH:PB/api/units` answers and the row is bound with `since` fresh; `ip addr del` → within 2 cycles the row is absent, curl fails, **loopback still serves** (the rest-keep-serving criterion); `ip addr add` again → bound again. SSE capture across the cycle: exactly the earned `listener` events, none while flat.
3. **Error-and-continue:** with $SCRATCH present, occupy `$SCRATCH:PB` with a scratch listener, restart B → row `error` EADDRINUSE, process up, loopback serving; free the port → bound within one cycle.
4. **Two-candidate roaming leg (the centerpiece; composes both features):** instance A `--fleet-peer bee=http://$SCRATCH:PB,http://127.0.0.1:PB --peer-interval 5`. Address absent → bee `up`, `endpoint_in_use = http://127.0.0.1:PB`, fed `fresh` (candidate 1 dead-for-real: unassigned TEST-NET connect fails by unreach or timeout — both are probe failures, both accepted). `ip addr add` → B binds within 2 s cycle, A's next from-the-top walk flips `endpoint_in_use` to `http://192.0.2.9:PB`; assert exactly ONE `peer` SSE event for the flip, `prev_state == 'up'` (zero flaps across the whole leg), `endpoint_changed_at` set, `/api/fleet` still fresh via the new endpoint. `ip addr del` → flips back, still zero up/down transitions.
5. **Live leg (operator-run; the away-desk half may remain open per checklist):** ampere `--bind 127.0.0.1 --bind-optional 192.168.88.168,ampere.vpn --port 8099`; boltzmann `--fleet-peer ampere=http://ampere.fritz.box:8099,http://[fd96:cafe:cafe::1000]:8099`; verify at home (192.168.88.168 bound, ampere.vpn absent, boltzmann up-via-candidate-1), then from the work desk (the mirror image).

## 8. WORK BREAKDOWN — 2 tasks, sequential T1 → T2

Why 2 and why this seam: T1 delivers the serving side whole (grammar, presence, watch, lifecycle, guards) — every moving part provable with injected seams; T2 delivers the federating side and every surface (candidates touch PeerWatch, whose row shape feeds routes/SSE/UI, so candidates and surfaces are one ownership domain). Both touch `roundhouse.py` and `test_server.py` → sequential.

**T1 — Optional binds + listener lifecycle + guards**
- **Writes:** Section F: `parse_bind_optional`, `validate_bind_retry`, `_make_listener`, `_presence_probe`, `BindWatch`, the four constants, banner text edit; `import errno`. Section C: the ctor `bind_watch=` kwarg ONLY (bind mechanism, per MVP7 precedent). Section D: flags, batched validation, `server_kwargs`, `_make_listener` in the mandatory loop, BindWatch construction + thread, §3.4 shutdown sequence. Tests: `test_binds.py` complete (§7.1–7.2); test_server.py §6.1 guard evolution + §7.4 optional-bind integration + shutdown ordering.
- **Must not touch:** `take_snapshot` body, `do_GET`/`do_POST`/handlers, PeerWatch/`_probe_peer`/`_fetch_peer`/`peer_watch_round`, `index.html`, `roundhouse_mcp.py`, `test_mcp.py`, docs.
- **Frozen interfaces handed to T2:** `BindWatch` ctor + `rows_unlocked()` + the L4 row shape; `listener` SSE payload; `_make_listener`/`_presence_probe` signatures; `server_kwargs` keys; the ctor kwarg name `bind_watch`.
- **Self-test:** full discover green, pre-existing tests unmodified; scratch-run `--bind 127.0.0.1 --bind-optional 127.0.0.2,192.0.2.1 --bind-retry 2` and eyeball one bound announce, one absent row, both doors curling.

**T2 — Multi-endpoint peers + surfaces + docs + drill**
- **Writes:** Section F: §4.1 candidate parsing, PeerWatch candidate/endpoint growth (§4.2–4.3 incl. ctor normalization), `_probe_peer`/`_fetch_peer` index params, the walk in `peer_watch_round`. Section D: per-candidate D2 wiring. Section C: `take_snapshot` `listeners` merge line. `index.html` §5.4; `roundhouse_mcp.py` + `test_mcp.py` version literals; test_peers.py §7.3; test_server.py §7.4 candidate/snapshot/SSE/pin legs + §7.5 statics; `docs/PEERS.md` §5.6 + README line; `scripts/roam-drill.sh` §7.6. Runs the §6.3 seeded-violation acceptance once at integration.
- **May touch T1 code:** nothing but bug fixes with a failing test first.
- **Self-test:** full discover green; drill container legs 1–4 green end-to-end as root.

**Shared-file ownership:** `roundhouse.py` — T1 owns BindWatch/binds/lifecycle + the ctor kwarg, T2 owns PeerWatch/candidates + the snapshot merge; `test_server.py` — T1 owns guards + bind/lifecycle integration, T2 owns candidate/surface/pin/statics classes; nobody edits the other's classes or any E–K-era test beyond §2's permitted list.

## 9. TEST PLAN — mapped 1:1 to MVP9.md's acceptance checklist

| criterion | proven by |
|---|---|
| `--bind-optional` parses (repeats, commas, names, IPv4/IPv6, wildcard refused), coexists; absent never fails startup, mandatory still does | §7.1 tables; §7.4 integration (absent row + serving); mandatory all-or-nothing tests unchanged and green; drill leg 1 |
| Address appears → listener within one cycle and serves; disappears → closed, rest keep serving; **proven with a real address** | drill leg 2 (`ip addr add/del`, curl both ways, loopback-still-serving assert); §7.2 cycle logic |
| Permission/in-use reported per-address, process continues; EADDRNOTAVAIL = absence | §7.2 classification rows; §7.4 occupied-port leg; drill leg 3 |
| `listeners` in snapshot + UI; wildcard refusal + loopback recommendation documented | §7.4 snapshot shape; §7.5 statics; PEERS.md §5.6 items 1/3 (reviewed at integration) |
| `--fleet-peer` candidate list; walk in order; first answerer = `endpoint_in_use` = fetch target | §7.3 walk + fetch-URL recorder; §7.4 real-socket leg; drill leg 4 |
| Peer up while any candidate answers; hysteresis unchanged; 1→2 move = one event, no flap | §7.3 cf/endpoint-event rows; §7.4 real-socket leg; drill leg 4 SSE capture |
| D2/TLS/no-redirect/limits per candidate; budget accounts for candidates, pinned | §7.3 per-candidate D2; K-era TLS guards green unchanged (per-call context, L7); §7.4 executable budget pin |
| Container drill: scratch-address add/remove/add + dead-first-candidate leg | drill legs 2–4 |
| Live: ampere loopback + whichever identity exists; boltzmann two candidates; desk leg may stay open | drill leg 5, operator-run |
| Stdlib only, no build step, no German, no throughput figures | stdlib guard (`errno` passes); review grep |

## 10. RISKS — top 3 mechanical-coder failure modes and the guards placed

1. **Listener lifecycle vs shutdown races — and the Risk-#1 lock family wearing a new coat.** The failure shapes: the bind watch registers a listener after the main thread's close pass (a door that survives "shutdown" past the stop timeout); the cycle holding `watcher_lock` across `getaddrinfo`/bind/`join` (a dead resolver then stalls every HTTP route for its full hang — the MVP5/MVP7 lesson with a slower fuse); `rows_unlocked` re-acquiring the non-reentrant lock inside `take_snapshot` (instant deadlock). **Guards:** §3.4 freezes join-BEFORE-close with the reason spelled out; the §3.3 phase-3 race gate covers the join-timeout path; the cycle is written out with one end-of-cycle lock hold; §7.2's lock-discipline test proves the lock free mid-resolve and mid-bind; the shutdown-ordering integration asserts thread-joined-then-doors-refused.
2. **Errno semantics quietly inverted.** EADDRINUSE classified as absence silently drops a merely-collided listener (the gemma-npu collision class, now invisible instead of loud); EADDRNOTAVAIL treated as an error kills the laptop at the wrong desk — inverting the milestone; a presence-probe surprise errno tearing down a serving door; `999.1.1.1` sliding through as a never-resolving hostname. **Guards:** the L2 classification is a frozen table tested row-for-row (§7.2); the present-on-weird-errno rule is stated normatively and tested; the literal-or-error trap has dedicated §7.1 rows; drill legs 2–3 assert the loud-continue behavior against real sockets.
3. **The candidate walk eroding peer-level semantics.** Three sub-shapes: per-candidate failures feeding `cf` (a dead sofa candidate flaps the peer down while the desk candidate answers — checklist row broken); a sticky winner that never re-walks (the returning preferred endpoint is ignored, violating operator ordering); the fetch using a stale or non-winner URL (probe won on candidate 2, fetch hits candidate 1 — the TLS/D2 per-candidate story silently bypassed, and a fed row built from an endpoint nobody verified this round). **Guards:** §4.2 writes the walk out with `cf` fed only by whole-walk failure; §7.3's call-recorder tests pin stop-at-first, from-the-top, and fetch-URL-equals-winner mechanically; drill leg 4's SSE capture asserts one event and zero flaps across a real endpoint migration; `_fetch_peer`'s `index` has no source but the walk result.

**Out of scope (do not build, per contract):** VPN detection/management; waking peers; mDNS/DNS-SD or discovery; candidate scoring/reordering/learning; per-candidate credentials; binding by interface name; IPv6 privacy-address churn handling; link-local zone-id binds (`fe80::…%iface` fails §3.1 as neither literal nor name — use a global address or a name); TCP-peer candidate lists (L5); a listeners route or MCP tool (L4); cross-host actuation (Milestone 10).

---

**Relay-worthy findings for the committer:** (1) `allow_reuse_address` makes an optional bind behind a mandatory wildcard *succeed silently* on Linux — the cross-list wildcard-coverage refusal (L1) is load-bearing, not pedantry. (2) All of 127.0.0.0/8 binds without configuration, so unit tests can prove a real optional bind (`127.0.0.2`) but never appearance — the root-container `ip addr` drill is the only honest proof, and the scratch address must be TEST-NET, not a loopback alias. (3) The candidate cap is 3, not 4, because 4 pins the worst round at 64 s > 60 s cadence; the 56 s arithmetic is an executable test, and ampere's three identities fit exactly. (4) `PeerWatch.declared`/`fleet` become candidate lists with ctor normalization of the old shapes — without that seam, ~50 green fixtures churn for nothing. (5) The presence probe forces the raw-socket guard's only-ever growth (`PROBE_CALLSITES` + `_presence_probe`); the connects-never-binds / binds-never-connects distinction is asserted structurally in both directions, so the two probe kinds cannot bleed into each other unnoticed.
