--- id: w4-router-collision name: "Wave 4: ListenRouter token-collision pre-check (wave-3 gate booked item 4)" status: completed depends_on: [w3-tunnel-listen] scope: single risk: low impact: isolated level: implementation --- ## Description Close the wave-3 gate's booked finding item 4 (`review-wave-3` §Booked findings): a **defensive `ListenRouter` token-collision pre-check**. `register` silently *replaces* on token collision today; safety rests on the fabric's ADR-008 mint discipline (collision "can't happen" upstream). The gate's disposition: a fail-closed decline-the-listen-on-collision is a cheap wave-4 addition, following the same posture the gate landed inline for duplicate `tunnel/forwarded` op registration (`install_forwarded_surface` now refuses with `AdapterError::ListenRegistration` instead of trusting the silent-replace). - The pre-check: a `register` on an already-held token **refuses** (the listen declines fail-closed) instead of silently replacing the held route, so a collided mint can never steal a live adapter's arrival routes. - The error surface: reuse/extend the `AdapterError::ListenRegistration` class (decide + record, with a distinct variant only if the gate's variant can't carry the distinction honestly). - Wire row(s): the collision constructible (two registers on one token, with the test forcing what the fabric's discipline prevents) → the second refuses, the first route still serves (the steal is impossible, row-pinned); the existing arrival/cancel/teardown rows stay green. ## Acceptance Criteria - [x] `ListenRouter::register` refuses on token collision (fail-closed, no silent replace); the error surface decided + recorded - [x] Wire row(s): the second register refuses, the first route still serves; existing rows green - [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2 (`-D warnings`), fmt, `cargo doc --no-deps` pass ## References - tasks/implementation/review-wave-3.md §Booked findings item 4 (and §Minor issues, the `install_forwarded_surface` pre-check this mirrors) - src/tunnel_map.rs (the `ListenRouter`), src/error.rs (the `AdapterError` class), docs/architecture/decisions/ (the fabric's ADR-008 mint discipline, the upstream guarantee the pre-check defends in depth against) ## Notes ### Implementation (2026-10-29) - **`ListenRouter::register` (src/tunnel_map.rs)** now returns `Result<(), String>`: a register on an already-held token returns `Err` and **never replaces** the held route, so a collided mint can never steal a live adapter's arrival routes. The refusal detail carries the token + the steal posture. - **Error surface (decided + recorded):** the refusal surfaces as the request-time **decline disposition** (`RequestDenied`, reason `token-collision`), not a distinct `AdapterError` variant. The deciding fact: a global-request hook's `Err` is the taxonomy's only route into the request surface, and russh 0.65 propagates it with `?` out of the packet loop, which kills the whole session, while the vanilla contract for a refused listen is `RequestDenied`. `AdapterError::ListenRegistration` honestly carries the construction-time class it always did (the doc in src/error.rs records the wire-time sibling); the reuse is the shared fail-closed registration posture, no new variant needed. - **`listen_request_dispatch` (src/handler.rs)**: the register moved **before** the `&mut u32` out-param write (a refused listen never writes the bound port); on refusal the path releases the listener cap, emits `decline_global_request(row, "token-collision")` (the uniform vocabulary, closed, with no dynamic fields), and tears the minted fabric listener down immediately in a spawned task (`TunnelListener::close`, the cancel path's mechanism; a refused registration never left a half state). - **Wire row** (`tests/tunnel_mapping.rs` `a_collided_mint_is_refused_and_never_steals_the_live_route`): the fabric double gained a mint-strategy construct (`wire_fabric_with_listen_mint` + `MintStrategy::WithCollisions`, where the first N mints return a fixed token, then the ADR-008 counter continues; the test forces exactly what the fabric's discipline prevents). The row pins, against a 2-count listener ceiling (the ceiling that admits the collided mint past the cap, since the cap row's shape would decline at the cap before the register, and it makes the release observable): the first listen registers normally, the colliding mint's listen answers `RequestDenied`, its bound endpoint never serves again (teardown observable, per the cancel row's bounded-retry shape), the listener count was released (a fresh `port = 0` mint is admitted and registers, with its success reply proves the shape), and the first route **still serves** end to end (an arrival opens the echoed `forwarded-tcpip` and the bytes ride); the steal is impossible, row-pinned. ## Summary **Completed.** The `ListenRouter` token-collision pre-check landed fail-closed (register refuses, never replaces) with the error-surface decision recorded: the wire-time collision is the decline disposition (`token-collision` / `RequestDenied`), not the taxonomy, since an `Err` would kill the whole russh session, while `AdapterError:: ListenRegistration` keeps its construction-time class. The refusal path is the full fail-closed shape (cap release, uniform decline event, and immediate fabric teardown, so no half state). Row-pinned through a mint-strategy construct on the fabric double: second register refuses, first route still serves end to end, refused endpoint never serves, count released. Full verification set green: cargo test (default + git/session/tunnel/sftp + `--all-features`), clippy ×2 `-D warnings`, fmt, `cargo doc --no-deps`; existing arrival/cancel/ teardown rows unchanged and green.