# M12 Wave 1.5 — broker loopback-conn + local `spt rc` attach

> JIT plan (todlando, 2026-06-14). doyle-scoped after W1 PASS. Goal: LOCAL `spt rc <id>`
> attach rides the BYTE-IDENTICAL `serve_attach` pump as cross-node — because self-dial is
> impossible (iroh: "Connecting to ourself is not supported", pinned by
> `loopback_self_dial_is_refused_local_uses_fallback_transport`). doyle ruling = **B1**: an
> in-process broker loopback connection, NOT B2 local-direct (which would reintroduce a
> lesser-tested local branch). Sensitive broker-layer work (KNOWN-HAZARDS 6.7 zone).

## The invariant (doyle, binding)
ONE `serve_attach` pump. Transport is swappable UNDER the stream. Cross-node = QUIC conn;
local = broker loopback conn. Both surface as broker net-stream events (`NetStreamData` /
`net_stream_subscribe` / `net_stream_send`) so `serve_attach` (target) + the `rc::pump`
(operator) are unchanged. The W1.5 int test MUST assert both transports hit the same pump.

## What already exists (W1)
- Operator pump: `spt/src/rc.rs` `run_attach` + `pump` (transport-invariant — reads
  `NetStreamData`, writes via `send_attach_input`). Currently codes the now-REFUSED self-dial
  (`brain.net_dial(net_status().addr)`) — **this is the one line W1.5 swaps** to a loopback-dial.
- Target serve: `spt-daemon/src/attach.rs serve_attach`, auto-dispatched at
  `dispatch.rs:335-360 StreamFamily::Attach` (resolves session_id→endpoint via `brain.sessions()`).
- Cross-node conn: `brain.net_dial(addr,op) -> NetDialed{conn_id}` (brain.rs:800) →
  `NetHost::dial` (nethost.rs:615) → iroh connect → registers in `NetShared::conns`.

## B1 design (the loopback conn)
A broker-internal connection with NO iroh handshake whose two bidi stream ends are wired
in-process, so a stream opened by the operator side delivers its bytes as `NetStreamData` to
a subscriber on the "remote" side (which the dispatch loop serves via `serve_attach`), and
vice-versa. Local `spt rc`: loopback-dial → `conn_id` → `request_attach(conn_id, session_id)`
→ subscribe → pump — identical to cross-node from `request_attach` up.

### Investigation TODOs (read before coding — NOT yet done)
1. **`spt-daemon/src/broker.rs`**: the conn table + stream table + how `dispatch_*` routes an
   inbound stream's first frame to `StreamFamily::Attach`→`serve_attach`. How a QUIC stream's
   bytes become `NetStreamData` events (the accept/serve plumbing). Find the seam to inject a
   local pair.
2. **`spt-daemon/src/nethost.rs`** (`NetHost::dial` ~615, `NetShared::conns`): how conns get a
   `conn_id` + how streams open on a conn. Add a `dial_loopback()` / a `Conn::Loopback` variant
   that needs no iroh endpoint.
3. **Stream open + cross-wire**: `net_open_stream(conn_id)` on a loopback conn must mint a
   stream whose sends loop back to a peer stream id the dispatch loop picks up as inbound.
   Likely an in-process channel pair inside the broker, surfaced through the SAME
   `NetStreamData`/`net_stream_send`/`net_stream_subscribe` IPC the QUIC path uses.
4. **Brain-side**: a `brain.net_dial_loopback(op) -> conn_id` (or reuse `net_dial` with a
   sentinel local addr the broker recognizes). Decide: new frame KIND vs sentinel addr.
5. **Origin for the gate**: `serve_attach`'s `origin_node` is the handshake-proven id. A
   loopback conn has no handshake — use THIS node's own id as origin (access_check is
   default-open for self; confirm). Don't fabricate a foreign origin.

## Tasks
- **T1.5.1** Broker loopback conn: `Conn::Loopback` + in-process stream cross-wiring; inbound
  loopback streams dispatch to `serve_attach` exactly like QUIC. (broker.rs + nethost.rs)
- **T1.5.2** `brain.net_dial_loopback` (or sentinel) → `conn_id`; minimal Brain API.
- **T1.5.3** Swap `rc.rs` conn-acquisition: local `run_attach` loopback-dials instead of
  `net_dial(own_addr)`. (One seam; pump unchanged.)
- **T1.5.4** Int test `local_attach_via_loopback_conn_rides_the_same_pump`: spawn a session,
  loopback-dial, request_attach, drive input, render echo, detach→session outlives. PLUS the
  **one-pump assertion**: both the cross-node int and this local int exercise the same
  `serve_attach`+pump path (assert by construction / shared helper, per doyle).
- **T1.5.5** Activate REQ-RC-1 local int evidence (int already active from W1 cross-node face;
  add the `[int->REQ-RC-1]` tag on the local test). `traceable-reqs check` EXIT=0.

## Gate (doyle)
Local `spt rc` attaches a broker-held PTY via the loopback conn over the byte-identical pump;
both transports proven to hit one pump; full suite + clippy -D warnings + traceable EXIT=0.
Then **REQ-HOST-RUN-2** (project cwd via additive `SpawnReq.cwd`) before the M12 gate.

## Crystallized design (2026-06-14, post-investigation — doyle GO, all 3 Qs answered)
doyle answers: (1) origin = THIS node's own id, broker-minted ONLY (never wire-derivable); access
`same-node short-circuit` already Allows (access.rs:89, documented access.rs:26-27) — NO access
change. (2) explicit new frame KIND `net-dial-loopback` (NOT sentinel addr). (3) one-pump =
by-construction via a SHARED serve_attach+pump test helper both cross-node + local int call.

**Transport seam = `tokio::io::duplex(CAP)`** — one full-duplex pair wires an entire operator↔peer
stream. Split each end (`tokio::io::split`); feed the SAME `StreamLog`/subscribe/send_stream/read-pump
machinery — only the LEAF send/recv halves become enums. That IS the one-pump invariant by construction.

- **nethost.rs**: `ConnEntry.conn` → `enum ConnKind { Quic(Connection), Loopback }`
  (`conn_remote_addr_json` Loopback→Null). `StreamEntry.send` → `enum SendHalf { Quic(SendStream),
  Loopback(WriteHalf<DuplexStream>) }` (async `write_all` + `finish`= quinn `finish()` / loopback
  `shutdown()`). Read pump takes `enum RecvHalf { Quic(RecvStream), Loopback(ReadHalf<DuplexStream>) }`
  — Quic `read_chunk`, Loopback `AsyncReadExt::read`. `register_stream` accepts SendHalf+RecvHalf.
  - `dial_loopback(&self)->(u64,String)`: SINGLETON (`loopback_conn: Mutex<Option<u64>>`), mints/reuses
    a `ConnKind::Loopback` row, `remote_id_hex = self.local_id.to_hex()`, NO closed-watcher/accept-loop.
  - `open_stream` branches on ConnKind::Loopback → `duplex(CAP)`→split→register TWO rows: operator
    (initiated_locally=true) + peer (initiated_locally=false, own hex). Returns operator stream_id.
    Peer row → dispatch loop → serve_attach with own-node origin → access Allow by construction.
- **msg.rs**: `KIND_NET_DIAL_LOOPBACK = "net-dial-loopback"`; reuse `NetDialed` reply; req payload Null
  (non-journaled — singleton is idempotent by reuse, no op needed).
- **brain.rs**: `net_dial_loopback(&mut self)->io::Result<NetDialed>`.
- **broker.rs**: dispatch arm `KIND_NET_DIAL_LOOPBACK`→`dispatch_net_dial_loopback`: `host.dial_loopback()`
  →`net_dialed_envelope(conn_id, remote, None, true, Null)`.
- **rc.rs**: swap lines 192-195 (`net_status().addr`+`net_dial`) → `net_dial_loopback()`.
- **tests/attach.rs**: extract shared helper driving request_attach→subscribe→wait_for_stream→serve_attach
  thread→input→render_until→detach→assert(Detached + session outlives). Cross-node + new local
  `local_attach_via_loopback_conn_rides_the_same_pump` BOTH call it. Tag `[int->REQ-RC-1]`.

## Notes
- rc.rs today codes the refused self-dial → LOCAL `spt rc` errors at runtime until T1.5.3
  (expected; local is W1.5). Cross-node rc works now.
- Commit only when the operator asks (not on default branch unprompted).
