---
name: v0170-w2-design
description: "v0.17.0 W2 two-phase meet-before-code join — crisp impl plan (grounded), for build/resume"
metadata: 
  node_type: memory
  type: project
  originSessionId: 8c8eef1f-6837-4fc5-a65f-0633fc292f63
---

REQ-JOIN-TWO-PHASE (impl+unit+int). **SHIPPED @a07322c (v0.17.0 W2), all gates green** (spt-net 126 / spt-daemon 382 / spt 261, clippy/traceable/xtask clean). doyle GO'd 2026-06-28 after W1+W4 gate. Build on shared branch v0.16.0-update-arc, selective commits. Plan below was followed as-is. NEXT: W3 (diagnosable join) then W5 (display parity) — see [[v0170-w1-w4-blockers]].

**KEY FINDING (grounded):** `meet.rs::dial_via_rendezvous` ALREADY meets-then-redials: it races `meet_at` over the ±1 rendezvous window (SPT_PAIR_MEET_ALPN), gets the responder's REAL `EndpointAddr`, then re-dials it over SPT_PAIR_ALPN and returns the ceremony conn. And `pairhost.rs::connect_seed_holder` ALREADY has a direct-addr path: `Some(addr) → connect_with_alpn(addr, SPT_PAIR_ALPN)` (no rendezvous). So two-phase = resolve+HOLD the real_addr in phase 1, then phase 2 reuses `pair_join(subnet, code, Some(held_addr))` (existing join_ceremony, direct dial, no re-search). The internal late-code retry (pairhost.rs:360) already redials Some(addr) = held addr.

**Broker dispatch model:** per-connection sequential loop (broker.rs:1486 handle_conn) — each frame dispatched to completion. So hold state in a broker map (mirror conn/stream tables), NOT inline frame-blocking.

**Impl steps:**
1. spt-net meet.rs: extract `pub async fn meet_via_rendezvous(dialer, subnet, step) -> Result<EndpointAddr, MeetError>` (the JoinSet race, returns real_addr, NO final SPT_PAIR_ALPN dial). `dial_via_rendezvous` = `meet_via_rendezvous` + connect SPT_PAIR_ALPN (external behavior unchanged → --code path + existing tests intact).
2. spt-daemon pairhost.rs: `meet_seed_holder(endpoint, subnet) -> Result<EndpointAddr, JoinFail>` — mirror connect_seed_holder None-branch SEARCH_DEADLINE(75s) loop but call meet_via_rendezvous, return addr.
3. spt-daemon nethost.rs: `pair_meet(subnet) -> Result<EndpointAddr, JoinFail>` (wraps meet_seed_holder on the broker-owned endpoint). pair_join already exists (nethost.rs:826).
4. spt-daemon broker.rs: add `pair_holds: Mutex<HashMap<u64, PairHold>>` {subnet, addr: EndpointAddr, deadline: Instant=now+5min}. KIND_PAIR_MEET→dispatch_pair_meet: meet→mint session_id (reuse counter)→store hold→reply KIND_MET_MEMBER(MetMember). KIND_PAIR_CODE_SUBMIT→dispatch: lookup+deadline-check (expired/missing→fail "meet expired, re-run join")→host.pair_join(subnet,code,Some(addr))→reply PairJoinReply; KEEP hold on wrong-code (retry ceremony-only), DROP on success/other-terminal/expiry. Sweep stale holds on each new meet.
5. spt-daemon msg.rs: PairMeetReq{subnet}; MetMember{session_id:u64, member_hex:String, detail?}; PairCodeSubmit{session_id:u64, code}. KIND_PAIR_MEET/KIND_MET_MEMBER/KIND_PAIR_CODE_SUBMIT consts. BrokerEvent::MetMember + read in brain.read_event.
6. spt-daemon brain.rs: `pair_meet(subnet)->io::Result<MetMember>`; `pair_submit_code(session_id, code)->io::Result<PairJoinReply>`. Keep pair_join (--code one-shot).
7. spt cli.rs cmd_subnet_join (:6226): interactive path → pair_meet (live "searching" progress is W3) → on MetMember prompt code → pair_submit_code → wrong-code re-prompts + re-submits SAME session (ceremony-only, no re-search) → success = existing post-join block. `--code` path unchanged (pair_join one-shot, relies on W1 fast discovery).

**Security unchanged:** meet pre-trust (SPT_PAIR_MEET_ALPN, meet.rs:60), auth in SPAKE2 on SPT_PAIR_ALPN. Meet routes on PUBLIC (name, totp-epoch); code collected AFTER member found.

**int (spt-daemon tests, likely pairjoin.rs):** real two-phase join over a live meet + wrong-code-retry-hits-ceremony-only (no re-search) + held-addr 5-min timeout. SPT_NTP_SERVER=off for hermetic clock. Then W3 (diagnostics) + W5 (display) remain — see [[v0170-w1-w4-blockers]].
