Both waves landed (W4 `867c7ab`, W1 `7cddaba`); doyle's concurrent docs commit `1b4fced` left untouched by selective adds. Confirming clean tree, then reporting to doyle.


Bash

git status --short && echo "tree-clean=$?" && printf '%s' "doyle — v0.17.0 W1 + W4 LANDED on the shared tree (both correctness blockers), each gated.

W4 @867c7ab  fix(registry): cold perch -> Suspended not Dormant  [REQ-PRESENCE-LIVENESS-TRUTH impl+unit+int]
  registryhost self-gossip else split (advertised_status helper): live-but-UNBOUND -> warm Dormant; cold/no-session -> Suspended (cold-but-node-up), never blanket Dormant, never self-gossiped Offline. Picker already maps Suspended->offline => false-ONLINE removed.
  unit (spt-daemon): advertised_status over alive/unbound-warm/cold-dead.
  int (spt picker): Suspended remote perch reads NOT-online through the real load_snapshots->resource_projection->gather pipeline; warm Dormant sibling stays online (home==remote).

W1 @7cddaba  feat(net): per-family bind gate  [REQ-NET-FAMILY-GATE doc+impl+unit]
  NetEndpoint::bind (All scope): probe each family once, drop the DEAD one (dual / v4-only / v6-only; never prefer-v4). Probe = bounded TCP connect to per-family anycast :443 on a blocking thread (tokio net feature off), concurrent, short timeout — catches dead PATH like the field Test-NetConnection discriminator. select_families pure: both-dead/both-healthy -> dual (never strand). SPT_DISABLE_IPV6/IPV4 force-off (mirrors SPT_NTP_SERVER). Single-family = existing clear_ip_transports+one bind_addr; Loopback/Port untouched. iroh single-family API VERIFIED via that mechanism.
  unit: select_families matrix + flag parser, no live sockets.

GATES (all green, local):
  spt-net lib 126/126 | spt-daemon lib 380/380 | spt bin 261/261 (incl 4 new tests)
  clippy --workspace --all-targets: clean
  traceable-reqs check: 311/311 complete, 0 findings, exit 0
  xtask check: OK (no CLI/docs drift — env knobs, not flags)

NOTES for the matrix: family probe makes a LIVE outbound TCP :443 on All-scope binds. CI/test binds are Loopback (hermetic) => no probe, no firewall prompt, dual-stack unchanged. The only WAN-touching path is a real production daemon bind. twohost still SKIP (no cross-host seam touched).

Tree clean; your docs commit 1b4fced left untouched (selective add). Standing by for matrix findings + W2/W3/W5 GO (join two-phase + diagnostics + display parity) when you scope them." | spt send doyle


Error: Exit code 1
tree-clean=0
NO_PERCH:doyle is not listening


`spt send` bounces NO_PERCH — use `$OWL send` (where live perches run). Retrying.


printf '%s' "doyle — v0.17.0 W1 + W4 LANDED on the shared tree (both correctness blockers), each gated.

W4 @867c7ab  fix(registry): cold perch -> Suspended not Dormant  [REQ-PRESENCE-LIVENESS-TRUTH impl+unit+int]
  registryhost self-gossip else split (advertised_status helper): live-but-UNBOUND -> warm Dormant; cold/no-session -> Suspended (cold-but-node-up), never blanket Dormant, never self-gossiped Offline. Picker already maps Suspended->offline => false-ONLINE removed.
  unit (spt-daemon): advertised_status over alive/unbound-warm/cold-dead.
  int (spt picker): Suspended remote perch reads NOT-online through the real load_snapshots->resource_projection->gather pipeline; warm Dormant sibling stays online (home==remote).

W1 @7cddaba  feat(net): per-family bind gate  [REQ-NET-FAMILY-GATE doc+impl+unit]
  NetEndpoint::bind (All scope): probe each family once, drop the DEAD one (dual / v4-only / v6-only; never prefer-v4). Probe = bounded TCP connect to per-family anycast :443 on a blocking thread (tokio net feature off), concurrent, short timeout — catches dead PATH like the field Test-NetConnection discriminator. select_families pure: both-dead/both-healthy -> dual (never strand). SPT_DISABLE_IPV6/IPV4 force-off (mirrors SPT_NTP_SERVER). Single-family = existing clear_ip_transports+one bind_addr; Loopback/Port untouched. iroh single-family API VERIFIED via that mechanism.
  unit: select_families matrix + flag parser, no live sockets.

GATES (all green, local):
  spt-net lib 126/126 | spt-daemon lib 380/380 | spt bin 261/261 (incl 4 new tests)
  clippy --workspace --all-targets: clean
  traceable-reqs check: 311/311 complete, 0 findings, exit 0
  xtask check: OK (no CLI/docs drift — env knobs, not flags)

NOTES for the matrix: family probe makes a LIVE outbound TCP :443 on All-scope binds. CI/test binds are Loopback (hermetic) => no probe, no firewall prompt, dual-stack unchanged. Only WAN-touching path is a real production daemon bind. twohost still SKIP (no cross-host seam touched).

Tree clean; your docs commit 1b4fced left untouched (selective add). Standing by for matrix findings + W2/W3/W5 GO when you scope them." | $OWL send doyle todlando 2>&1 | tail -3; echo "owl-rc=${PIPESTATUS[0]}"


➜ SENT:doyle
owl-rc=0


Report SENT to doyle. Recording v0.17.0 progress to memory for resume continuity.


Write

---
name: v0170-w1-w4-blockers
description: v0.17.0 robust-WAN-join + presence-truth milestone — W1+W4 correctness blockers LANDED; W2/W3/W5/W6 pending doyle scope
metadata:
  type: project
---

v0.17.0 = robust WAN subnet join (ADR-0030) + presence-liveness truth (no ADR, correctness bug). doyle BUILD GO 2026-06-28; operator wants full milestone→publish (counter 36). Design LOCKED: ADR-0030 + docs/design/{robust-wan-subnet-join,subnet-presence-display}.md + V0.17.0-ROBUST-JOIN-AND-PRESENCE-JIT.md. Two parallel tracks: join cluster (W1-W3) + presence/display (W4-W5).

**LANDED (shared branch v0.16.0-update-arc, each gated locally):**
- W4 @867c7ab — REQ-PRESENCE-LIVENESS-TRUTH (impl+unit+int). registryhost self-gossip `else` split into `advertised_status()` helper: live-but-UNBOUND→warm Dormant; cold/no-session→**Suspended** (cold-but-node-up), never blanket Dormant, never self-gossiped Offline. Picker ALREADY maps Suspended→offline (data.rs `_` arm) ⇒ fix is one semantic flip, removes the dead-endpoint-shows-green-remotely bug. int lives in spt picker (load_snapshots→resource_projection→gather), NOT twohost.
- W1 @7cddaba — REQ-NET-FAMILY-GATE (doc+impl+unit). NetEndpoint::bind All-scope only: probe each family once, drop DEAD one. Probe = bounded TCP connect per-family anycast :443 on **spawn_blocking** (tokio `net` feature is OFF in spt-net — confirmed Cargo.toml) + std::net. select_families PURE (both-dead/both-healthy→Dual never-strand). SPT_DISABLE_IPV6/IPV4 force-off. Single-family bind = existing `clear_ip_transports()`+one `bind_addr` (the BindScope mechanism = the verified iroh single-family API). Loopback/Port untouched ⇒ all hermetic tests + CI dual-stack unchanged, no probe, no firewall prompt.

GATES green: spt-net 126 / spt-daemon 380 / spt 261 lib+bin, +4 new tests; clippy --workspace clean; traceable 311/311 0-findings; xtask check OK. Build OOMs if 3 crate test-bins codegen concurrently — use `$env:CARGO_BUILD_JOBS=2` + per-crate nextest.

**PENDING (doyle gates W1+W4 matrix, then scopes):**
- W2 REQ-JOIN-TWO-PHASE (impl+unit+int) — PairMeetReq/MetMember/PairCodeSubmit two-phase stream, daemon held-address state machine + 5-min timeout, ceremony-only wrong-code retry, --code one-shot preserved. brain.rs:1009 / pairhost.rs / meet.rs / cli.rs:6236.
- W3 REQ-JOIN-DIAGNOSTICS (impl+unit) — live search progress + detailed-failure-before-prompt + brain.rs:1024 terminal-event propagation (the `_=>continue` swallow) + --verbose/SPT_LOG trace.
- W5 REQ-SUBNET-DISPLAY-PARITY (impl+unit+int, DEPENDS on W4) — gossip additive Instance fields (bound/controlled+driver/harness_only), remove data.rs:293 remote-reduction, EpDisplay rework (drop UnboundControlled, Unbound→red-filled, HarnessOnly→amber-hollow, add Suspended→gray-filled). Palette = subnet-presence-display.md §B.
- W6 — int activations at fold, public docs (gh-pages networking/overview + join troubleshooting note + status-square legend), release bump-in-PR counter 36, deployah publishes.

Build rules: tag evidence same commit; commit SELECTIVELY (git add <files>, never -A — doyle shares the tree, e.g. his docs commit 1b4fced); coordinate traceable-reqs.toml (use git add -p to split per-wave hunks). CI fires on the release PR not branch push. See [[v0160-published.md]], [[release-counter-from-published]], [[agent-roles]], [[no-machinewide-killon-shared-runner]].


File created successfully at: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\v0170-w1-w4-blockers.md (file state is current in your context — no need to Read it back)

---
name: v0170-w1-w4-blockers
description: v0.17.0 robust-WAN-join + presence-truth milestone — W1+W4 correctness blockers LANDED; W2/W3/W5/W6 pending doyle scope
metadata:
  node_type: memory
  type: project
  originSessionId: 8c8eef1f-6837-4fc5-a65f-0633fc292f63
---

v0.17.0 = robust WAN subnet join (ADR-0030) + presence-liveness truth (no ADR, correctness bug). doyle BUILD GO 2026-06-28; operator wants full milestone→publish (counter 36). Design LOCKED: ADR-0030 + docs/design/{robust-wan-subnet-join,subnet-presence-display}.md + V0.17.0-ROBUST-JOIN-AND-PRESENCE-JIT.md. Two parallel tracks: join cluster (W1-W3) + presence/display (W4-W5).

**LANDED (shared branch v0.16.0-update-arc, each gated locally):**
- W4 @867c7ab — REQ-PRESENCE-LIVENESS-TRUTH (impl+unit+int). registryhost self-gossip `else` split into `advertised_status()` helper: live-but-UNBOUND→warm Dormant; cold/no-session→**Suspended** (cold-but-node-up), never blanket Dormant, never self-gossiped Offline. Picker ALREADY maps Suspended→offline (data.rs `_` arm) ⇒ fix is one semantic flip, removes the dead-endpoint-shows-green-remotely bug. int lives in spt picker (load_snapshots→resource_projection→gather), NOT twohost.
- W1 @7cddaba — REQ-NET-FAMILY-GATE (doc+impl+unit). NetEndpoint::bind All-scope only: probe each family once, drop DEAD one. Probe = bounded TCP connect per-family anycast :443 on **spawn_blocking** (tokio `net` feature is OFF in spt-net — confirmed Cargo.toml) + std::net. select_families PURE (both-dead/both-healthy→Dual never-strand). SPT_DISABLE_IPV6/IPV4 force-off. Single-family bind = existing `clear_ip_transports()`+one `bind_addr` (the BindScope mechanism = the verified iroh single-family API). Loopback/Port untouched ⇒ all hermetic tests + CI dual-stack unchanged, no probe, no firewall prompt.

GATES green: spt-net 126 / spt-daemon 380 / spt 261 lib+bin, +4 new tests; clippy --workspace clean; traceable 311/311 0-findings; xtask check OK. Build OOMs if 3 crate test-bins codegen concurrently — use `$env:CARGO_BUILD_JOBS=2` + per-crate nextest.

**PENDING (doyle gates W1+W4 matrix, then scopes):**
- W2 REQ-JOIN-TWO-PHASE (impl+unit+int) — PairMeetReq/MetMember/PairCodeSubmit two-phase stream, daemon held-address state machine + 5-min timeout, ceremony-only wrong-code retry, --code one-shot preserved. brain.rs:1009 / pairhost.rs / meet.rs / cli.rs:6236.
- W3 REQ-JOIN-DIAGNOSTICS (impl+unit) — live search progress + detailed-failure-before-prompt + brain.rs:1024 terminal-event propagation (the `_=>continue` swallow) + --verbose/SPT_LOG trace.
- W5 REQ-SUBNET-DISPLAY-PARITY (impl+unit+int, DEPENDS on W4) — gossip additive Instance fields (bound/controlled+driver/harness_only), remove data.rs:293 remote-reduction, EpDisplay rework (drop UnboundControlled, Unbound→red-filled, HarnessOnly→amber-hollow, add Suspended→gray-filled). Palette = subnet-presence-display.md §B.
- W6 — int activations at fold, public docs (gh-pages networking/overview + join troubleshooting note + status-square legend), release bump-in-PR counter 36, deployah publishes.

Build rules: tag evidence same commit; commit SELECTIVELY (git add <files>, never -A — doyle shares the tree, e.g. his docs commit 1b4fced); coordinate traceable-reqs.toml (use git add -p to split per-wave hunks). CI fires on the release PR not branch push. See [[v0160-published.md]], [[release-counter-from-published]], [[agent-roles]], [[no-machinewide-killon-shared-runner]].


1	- [v0.14.0 endpoint-creation-flow](v0140-endpoint-creation-flow.md) — PUBLISHED counter 30. Multi-subnet home-at-creation + UNBOUND pre-bind attach. SPT_RELEASE_SEED in env (len64).
2	- [v0.14.1 builds (F-018+picker+netstream)](f018-adapter-add-nondestructive.md) — PUBLISHED counter 31. F-018 adapter-add non-destructive, picker home-subnet, netstream flake FIX. GOTCHA: `spt` NO lib target → `--bin spt`.
3	- [F-019 translation install_dir resolve](f019-translation-install-dir-resolve.md) — PUBLISHED v0.14.2 counter 32. CC-on-Win blocker: idle msgs typed-not-SUBMITTED, root=bare path not resolved vs install_dir. Fix=resolve_program_in_dir ×4 sites.
4	- [v0.17.0 robust-join DESIGNED (next build)](v0170-robust-join-design.md) — branch v0.17.0-robust-join off main @551f3dc. ADR-0030 (two-phase meet-before-code + per-family bind gate + diagnosable join) + REQ-PRESENCE-LIVENESS-TRUTH. JIT W1 family-gate + W4 presence-truth = urgent correctness; W2/W3 = UX. todlando builds next. Diags: [[broken-ipv6-poisons-iroh-discovery]] + [[presence-dead-shows-online]].
5	- [presence: dead endpoints show ONLINE remotely](presence-dead-shows-online.md) — registryhost.rs:404 gossips a not-alive perch as Dormant; picker/data.rs:268 maps remote Dormant→Online. Fix=dead→Offline (guard live-unbound) + picker Dormant-distinct. v0.17.0 W4.

- [v0.14.0 endpoint-creation-flow](v0140-endpoint-creation-flow.md) — PUBLISHED counter 30. Multi-subnet home-at-creation + UNBOUND pre-bind attach. SPT_RELEASE_SEED in env (len64).
- [v0.14.1 builds (F-018+picker+netstream)](f018-adapter-add-nondestructive.md) — PUBLISHED counter 31. F-018 adapter-add non-destructive, picker home-subnet, netstream flake FIX. GOTCHA: `spt` NO lib target → `--bin spt`.
- [F-019 translation install_dir resolve](f019-translation-install-dir-resolve.md) — PUBLISHED v0.14.2 counter 32. CC-on-Win blocker: idle msgs typed-not-SUBMITTED, root=bare path not resolved vs install_dir. Fix=resolve_program_in_dir ×4 sites.
- [v0.17.0 robust-join DESIGNED (next build)](v0170-robust-join-design.md) — branch v0.17.0-robust-join off main @551f3dc. ADR-0030 (two-phase meet-before-code + per-family bind gate + diagnosable join) + REQ-PRESENCE-LIVENESS-TRUTH. JIT W1 family-gate + W4 presence-truth = urgent correctness; W2/W3 = UX. todlando builds next. Diags: [[broken-ipv6-poisons-iroh-discovery]] + [[presence-dead-shows-online]].
- [presence: dead endpoints show ONLINE remotely](presence-dead-shows-online.md) — registryhost.rs:404 gossips a not-alive perch as Dormant; picker/data.rs:268 maps remote Dormant→Online. Fix=dead→Offline (guard live-unbound) + picker Dormant-distinct. v0.17.0 W4.


MEMORY.md already has the v0.17.0 design pointer — adding the build-progress pointer beside it.


Edit

- [v0.17.0 robust-join DESIGNED (next build)](v0170-robust-join-design.md) — branch v0.17.0-robust-join off main @551f3dc. ADR-0030 (two-phase meet-before-code + per-family bind gate + diagnosable join) + REQ-PRESENCE-LIVENESS-TRUTH. JIT W1 family-gate + W4 presence-truth = urgent correctness; W2/W3 = UX. todlando builds next. Diags: [[broken-ipv6-poisons-iroh-discovery]] + [[presence-dead-shows-online]].

- [v0.17.0 robust-join DESIGNED (next build)](v0170-robust-join-design.md) — branch v0.17.0-robust-join off main @551f3dc. ADR-0030 (two-phase meet-before-code + per-family bind gate + diagnosable join) + REQ-PRESENCE-LIVENESS-TRUTH. JIT W1 family-gate + W4 presence-truth = urgent correctness; W2/W3 = UX. todlando builds next. Diags: [[broken-ipv6-poisons-iroh-discovery]] + [[presence-dead-shows-online]].
- [v0.17.0 W1+W4 LANDED](v0170-w1-w4-blockers.md) — 2026-06-28: W4 @867c7ab (cold perch→Suspended not Dormant, REQ-PRESENCE-LIVENESS-TRUTH impl+unit+int) + W1 @7cddaba (per-family bind gate, REQ-NET-FAMILY-GATE doc+impl+unit). All gated (311/311 traceable, clippy/xtask clean). Built on SHARED branch v0.16.0-update-arc (NOT the design-memo's v0.17.0-robust-join), doyle's tree. W2/W3/W5/W6 pending doyle scope. Build OOM→CARGO_BUILD_JOBS=2 per-crate.


The file C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)

- [v0.17.0 robust-join DESIGNED (next build)](v0170-robust-join-design.md) — branch v0.17.0-robust-join off main @551f3dc. ADR-0030 (two-phase meet-before-code + per-family bind gate + diagnosable join) + REQ-PRESENCE-LIVENESS-TRUTH. JIT W1 family-gate + W4 presence-truth = urgent correctness; W2/W3 = UX. todlando builds next. Diags: [[broken-ipv6-poisons-iroh-discovery]] + [[presence-dead-shows-online]].
- [v0.17.0 W1+W4 LANDED](v0170-w1-w4-blockers.md) — 2026-06-28: W4 @867c7ab (cold perch→Suspended not Dormant, REQ-PRESENCE-LIVENESS-TRUTH impl+unit+int) + W1 @7cddaba (per-family bind gate, REQ-NET-FAMILY-GATE doc+impl+unit). All gated (311/311 traceable, clippy/xtask clean). Built on SHARED branch v0.16.0-update-arc (NOT the design-memo's v0.17.0-robust-join), doyle's tree. W2/W3/W5/W6 pending doyle scope. Build OOM→CARGO_BUILD_JOBS=2 per-crate.

- [v0.14.1 builds (F-018+picker+netstream)](f018-adapter-add-nondestructive.md) — PUBLISHED counter 31. F-018 adapter-add non-destructive, picker home-subnet, netstream flake FIX. GOTCHA: `spt` NO lib target → `--bin spt`.

- [F-019 translation install_dir resolve](f019-translation-install-dir-resolve.md) — PUBLISHED v0.14.2 counter 32. CC-on-Win blocker: idle msgs typed-not-SUBMITTED, root=bare path not resolved vs install_dir. Fix=resolve_program_in_dir ×4 sites.

+- [v0.17.0 W1+W4 LANDED](v0170-w1-w4-blockers.md) — 2026-06-28: W4 @867c7ab (cold perch→Suspended not Dormant, REQ-PRESENCE-LIVENESS-TRUTH impl+unit+int) + W1 @7cddaba (per-family bind gate, REQ-NET-FAMILY-GATE doc+impl+unit). All gated (311/311 traceable, clippy/xtask clean). Built on SHARED branch v0.16.0-update-arc (NOT the design-memo's v0.17.0-robust-join), doyle's tree. W2/W3/W5/W6 pending doyle scope. Build OOM→CARGO_BUILD_JOBS=2 per-crate.

- [presence: dead endpoints show ONLINE remotely](presence-dead-shows-online.md) — registryhost.rs:404 gossips a not-alive perch as Dormant; picker/data.rs:268 maps remote Dormant→Online. Fix=dead→Offline (guard live-unbound) + picker Dormant-distinct. v0.17.0 W4.

- [v0.16.0 PUBLISHED (update-arc + CLI/UX)](v0160-published.md) — ✅ counter 35 @9e5b33b (PR#38, 2026-06-25). 6 waves all doyle-gated: subst keys {adapter_dir}/{adapter_name} + lazy [strings] (ADR-0029 resolve-not-execute) / translation [command] / [update.post] composite / --reply-to REMOVED / empty-scope create / global --json / digest cursor / rc identity row. hashes linux e13d7c2d… / win f6cd39ec…. deployah caught a docs-token leak at the gate (clap /// REQ-tag → //, see [[clap-doc-comment-leaks-req-tag]]). perri GO sent for claude-spt v0.8.0 (min_spt_core→0.16.0). Build/ratify detail: [[v0160-w1-manifest-runtime]].

- [Win update-exe UAC-740](win-update-exe-uac740.md) — exe/fixture names containing "update"/"setup"/"install" trip Win installer-detection → os-error-740 elevation. Name spawned test helpers around it (W2 used "post_step").


v0.17.0 W1 + W4 done. Both correctness blockers landed + gated.

**W4 `867c7ab`** — cold perch → Suspended not Dormant (REQ-PRESENCE-LIVENESS-TRUTH, impl+unit+int). Dead endpoint no longer paints green ONLINE on remote picker. Fix = split registryhost self-gossip `else`: unbound-live → warm Dormant, cold → Suspended. Picker already maps Suspended→offline.

**W1 `7cddaba`** — per-family bind gate (REQ-NET-FAMILY-GATE, doc+impl+unit). Probe each IP family at bind, drop dead one. Kills the half-broken-IPv6 silent WAN-join failure. `SPT_DISABLE_IPV6/IPV4` override knobs. Hermetic scopes untouched.

**Gates green:** spt-net 126 / spt-daemon 380 / spt 261 + 4 new tests · clippy clean · traceable 311/311 0-findings · xtask OK.

Selective commits — doyle's docs commit `1b4fced` untouched. Report SENT doyle (`$OWL send`; plain `spt send` bounced NO_PERCH). Memory updated.

IDLE. Standing by:
1. doyle W1+W4 matrix gate → triage findings
2. W2/W3/W5 GO (join two-phase + diagnostics + display parity) when doyle scopes
3. perri real-CC validation

Not starting W2 unprompted — doyle set the report-back checkpoint here + owns scope/sequence.


<task-notification>
<task-id>bdfzr7zvf</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/10" id="f6b4e280" type="msg" from="doyle">todlando — W1 @7cddaba + W4 @867c7ab GATED GREEN by me (independent re-run): traceable 311 ok, xtask ok, clippy --workspace --all-targets clean, nextest spt-net+spt-daemon+spt 923/923 pass (4 leaky/1 twohost-skip). Both correctness blockers locked.

GO on the rest — three waves, second cluster + join UX:
<</EVENT-PART>
<EVENT-PART seq="2/10" id="f6b4e280">br>=== W2 — two-phase meet-before-code join (REQ-JOIN-TWO-PHASE) ===
Extend brain.pair_join (brain.rs:1009) into two-phase stream: PairMeetReq{subnet} → daemon meets (name, current-epoch) resolving seed-holder REAL stable pairing addr (pairhost.rs connect_seed_holder / meet.rs dial_via_rendezvous) → new MetMember event → CLI prompts</EVENT-PART>
<EVENT-PART seq="3/10" id="f6b4e280"> code (cli.rs cmd_subnet_join :6236) → PairCodeSubmit{code} → daemon dials held addr on SPT_PAIR_ALPN + SPAKE2 → PairJoined/PairFail. Daemon HOLDS resolved addr between phases; 5-MIN wait-for-code timeout. Wrong-code retry re-runs CEREMONY ONLY vs held addr (no re-search). --code path stays one-shot (no prompt). Security unchanged (meet </EVENT-PART>
<EVENT-PART seq="4/10" id="f6b4e280">pre-trust SPT_PAIR_MEET_ALPN; auth in SPAKE2). KEYSTONE: meet routes on PUBLIC TOTP-epoch (rendezvous_token=SHA256(domain‖name‖totp_step)), NOT secret code — code collected AFTER member found. ACTIVATE impl+unit+int (int = real two-phase join over live meet + wrong-code-retry-hits-ceremony-only + held-addr timeout).

=== W3 — dia</EVENT-PART>
<EVENT-PART seq="5/10" id="f6b4e280">gnosable join (REQ-JOIN-DIAGNOSTICS) ===
(a) live search progress (replace one-shot Searching… cli.rs:6268 with periodic elapsed/deadline). (b) detailed meet-failure (candidates+families+relay-vs-direct+last error) surfaced BEFORE any code prompt; thread last error up from connect_seed_holder (pairhost.rs:437) + dial_via_rendezvous (meet.</EVENT-PART>
<EVENT-PART seq="6/10" id="f6b4e280">rs:281) (today swallowed). (c) brain.rs:1024 _=&gt;continue MUST deliver NoSeedHolder/PairFail to CLI (why user saw nothing). (d) --verbose/SPT_LOG discovery trace (per-probe id, path mDNS/n0-DNS/relay, per-family timeouts). ACTIVATE impl+unit. xtask gen IF --verbose touches CLI surface.

=== W5 — subnet display parity + palette (REQ-S</EVENT-PART>
<EVENT-PART seq="7/10" id="f6b4e280">UBNET-DISPLAY-PARITY) — W4 done, unblocked ===
Spec: docs/design/subnet-presence-display.md §B. Gossip ADDITIVE per-Instance fields (registry::Instance, forward-compat): bound/unbound, controlled + DRIVER node, harness_only. (Offline never gossiped.) REMOVE remote-reduction (picker/data.rs:293-294) — derive full EpDisplay for subnet ro</EVENT-PART>
<EVENT-PART seq="8/10" id="f6b4e280">ws from gossiped fields, SAME path as local. EpDisplay rework (picker/model.rs): DROP UnboundControlled; Unbound→red-filled ■; HarnessOnly→amber-hollow ▢ (was amber-filled); ADD Suspended→gray-filled ■. Picker maps Active|Dorm
...(truncated)</event>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


doyle GATED W1+W4 green (923/923) and GO'd W2/W3/W5. Acking + grounding in the join + display subsystems before building.


Bash

printf '%s' "doyle — ACK W1+W4 gate (923/923, locked). GO received for W2/W3/W5. Starting W2 (two-phase meet-before-code keystone) → W3 (diagnostics on same meet path) → W5 (display parity, independent). Grounding in brain.pair_join + pairhost/meet + cli cmd_subnet_join + the wire types now. Will gate each wave (nextest spt-net+spt-daemon+spt, clippy --workspace, traceable, xtask gen if --verbose hits CLI) + report per wave. Building on shared v0.16.0-update-arc tree, selective commits." | $OWL send doyle todlando 2>&1 | tail -1


➜ SENT:doyle


1	# Subnet presence + endpoint-state display — design
2
3	> Source: `/grill-with-docs` 2026-06-27/28 (operator + doyle), off the SCELTOUIN incident — a remote
4	> node painted another node's DEAD endpoints as ONLINE. The grill unified the picker's status-square
5	> semantics across local and remote, and corrected the gossiped-status model against `resting.rs` +
6	> CONTEXT.md ("instance state" glossary). No ADR (a correctness + display fix; reversible). Lands as
7	> two `REQ-*`: `REQ-PRESENCE-LIVENESS-TRUTH` (gossip-status correctness) + `REQ-SUBNET-DISPLAY-PARITY`
8	> (unified palette + the gossip/render parity it needs).
9
10	## The instance-state model (canonical: CONTEXT.md "instance state")
11
12	active = the bare-`id` **routing target** (actively driven) · dormant = **warm** non-target sibling
13	(lost attention / driver detached; multi-instance routing differentiator) · suspended = **cold**,
14	resumable-on-wake, **node up** · offline = **node down**, never self-gossiped (remote-inferred via
15	epoch-lease eviction). `resting.rs` implements active/dormant/suspended; offline is registry-only.
16
17	## A. Gossip-status truth → `REQ-PRESENCE-LIVENESS-TRUTH`
18
19	`registryhost.rs:397-405` derives the per-endpoint gossiped `Status`. The `else` (not-bound-alive)
20	branch currently advertises `Dormant` — wrong: Dormant requires warm/running. Fix:
21	- **live-but-unbound** (a live broker session; `is_perch_alive` is bound-gated, so the `else` catches
22	  these — reuse the picker's `is_perch_unbound` signal) → `Active`/`Dormant` (still warm).
23	- **else** (cold, no live session — but the node is UP, since *this very daemon* is gossiping) →
24	  **`Suspended`**. **Never `Dormant`** (not warm) and **never `Offline`** (the node is up; a live node
25	  never self-gossips Offline — `Offline` is what a remote viewer infers when a node stops gossiping).
26
27	This alone removes the false-ONLINE. `dormant` keeps gossiping (routing/MRA needs it) but **renders
28	as its online flavor** (no distinct glyph — the dormant→suspended auto-suspend timer disambiguates
29	recency by the time it would matter).
30
31	## B. Unified display palette + gossip parity → `REQ-SUBNET-DISPLAY-PARITY`
32
33	**Local and remote rows render identically** (the subnet view shows everything the local view does).
34	Today `data.rs:293-294` reduces remote rows to plain green/gray because bound/unbound, controlled, and
35	harness-only aren't propagated. Fix: **gossip those per-`Instance` fields** and derive the full
36	`EpDisplay` for subnet rows the same as local.
37
38	**Fill = actionable:** *filled* = you can act now (rc-control if online; **wake** if suspended on a live
39	node) · *hollow* = you cannot (harness-only has no control seat; offline node is gone).
40
41	| Color | Fill | Glyph | State | Change from today |
42	|---|---|---|---|---|
43	| green | filled | ■ | online · bound · free (broker-controllable) | unchanged |
44	| blue | filled | ■ | online · **controlled** (`driven_by`; desc pane shows `controlled by <node>`) | + driver node |
45	| red | filled | ■ | online · **unbound** (controlled-or-not — rc-attachable, not msg-bound; controlled-ness shown via available options, not glyph) | **was** green-hollow; **absorbs** UnboundControlled |
46	| amber | hollow | ▢ | online · **harness-only** (no broker seat → can't rc) | **was** amber-*filled* |
47	| gray | filled | ■ | **suspended** (cold, node up — wakeable) | **NEW combo** |
48	| gray | hollow | ▢ | **offline** (node down) | now **remote-only** (a local node is always up) |
49
50	`EpDisplay` rework: **drop** `UnboundControlled`; `Unbound` → red-filled; `HarnessOnly` →
51	amber-hollow; **add** `Suspended` (gray-filled). Picker mapping: `Active|Dormant` → online flavor
52	(green/blue/red/amber by bound/controlled/harness), `Suspended` → gray-filled, `Offline` → gray-hollow.
53
54	**Gossip additions (per `Instance`, additive/forward-compat):** `bound`/unbound, `controlled` +
55	**driver node**, `harness_only`. (`Offline` is never gossiped — node-down is remote-inferred.)
56
57	**Residual tension (resolved):** under "hollow = cannot control," a *suspended* endpoint also can't be
58	controlled, yet it is gray-**filled** — reconciled by reading filled as "actionable = control **or
59	wake**" (suspended is wakeable on a live node; offline is not). `red` doubles as an "incomplete /
60	needs-attention" signal for the live-but-unbound state — intended.
61
62	## Cross-cutting
63
64	- CONTEXT.md "instance state" glossary already records the four-state model (done this session). The
65	  display palette is impl detail (lives here, not the glossary).
66	- Public docs (ride the build): gh-pages `terminal`/picker status-square legend + the new palette.
67	- Folds into the v0.17.0 milestone JIT (`V0.17.0-...`) as the presence/display waves.
68

# Subnet presence + endpoint-state display — design

> Source: `/grill-with-docs` 2026-06-27/28 (operator + doyle), off the SCELTOUIN incident — a remote
> node painted another node's DEAD endpoints as ONLINE. The grill unified the picker's status-square
> semantics across local and remote, and corrected the gossiped-status model against `resting.rs` +
> CONTEXT.md ("instance state" glossary). No ADR (a correctness + display fix; reversible). Lands as
> two `REQ-*`: `REQ-PRESENCE-LIVENESS-TRUTH` (gossip-status correctness) + `REQ-SUBNET-DISPLAY-PARITY`
> (unified palette + the gossip/render parity it needs).

## The instance-state model (canonical: CONTEXT.md "instance state")

active = the bare-`id` **routing target** (actively driven) · dormant = **warm** non-target sibling
(lost attention / driver detached; multi-instance routing differentiator) · suspended = **cold**,
resumable-on-wake, **node up** · offline = **node down**, never self-gossiped (remote-inferred via
epoch-lease eviction). `resting.rs` implements active/dormant/suspended; offline is registry-only.

## A. Gossip-status truth → `REQ-PRESENCE-LIVENESS-TRUTH`

`registryhost.rs:397-405` derives the per-endpoint gossiped `Status`. The `else` (not-bound-alive)
branch currently advertises `Dormant` — wrong: Dormant requires warm/running. Fix:
- **live-but-unbound** (a live broker session; `is_perch_alive` is bound-gated, so the `else` catches
  these — reuse the picker's `is_perch_unbound` signal) → `Active`/`Dormant` (still warm).
- **else** (cold, no live session — but the node is UP, since *this very daemon* is gossiping) →
  **`Suspended`**. **Never `Dormant`** (not warm) and **never `Offline`** (the node is up; a live node
  never self-gossips Offline — `Offline` is what a remote viewer infers when a node stops gossiping).

This alone removes the false-ONLINE. `dormant` keeps gossiping (routing/MRA needs it) but **renders
as its online flavor** (no distinct glyph — the dormant→suspended auto-suspend timer disambiguates
recency by the time it would matter).

## B. Unified display palette + gossip parity → `REQ-SUBNET-DISPLAY-PARITY`

**Local and remote rows render identically** (the subnet view shows everything the local view does).
Today `data.rs:293-294` reduces remote rows to plain green/gray because bound/unbound, controlled, and
harness-only aren't propagated. Fix: **gossip those per-`Instance` fields** and derive the full
`EpDisplay` for subnet rows the same as local.

**Fill = actionable:** *filled* = you can act now (rc-control if online; **wake** if suspended on a live
node) · *hollow* = you cannot (harness-only has no control seat; offline node is gone).

| Color | Fill | Glyph | State | Change from today |
|---|---|---|---|---|
| green | filled | ■ | online · bound · free (broker-controllable) | unchanged |
| blue | filled | ■ | online · **controlled** (`driven_by`; desc pane shows `controlled by <node>`) | + driver node |
| red | filled | ■ | online · **unbound** (controlled-or-not — rc-attachable, not msg-bound; controlled-ness shown via available options, not glyph) | **was** green-hollow; **absorbs** UnboundControlled |
| amber | hollow | ▢ | online · **harness-only** (no broker seat → can't rc) | **was** amber-*filled* |
| gray | filled | ■ | **suspended** (cold, node up — wakeable) | **NEW combo** |
| gray | hollow | ▢ | **offline** (node down) | now **remote-only** (a local node is always up) |

`EpDisplay` rework: **drop** `UnboundControlled`; `Unbound` → red-filled; `HarnessOnly` →
amber-hollow; **add** `Suspended` (gray-filled). Picker mapping: `Active|Dormant` → online flavor
(green/blue/red/amber by bound/controlled/harness), `Suspended` → gray-filled, `Offline` → gray-hollow.

**Gossip additions (per `Instance`, additive/forward-compat):** `bound`/unbound, `controlled` +
**driver node**, `harness_only`. (`Offline` is never gossiped — node-down is remote-inferred.)

**Residual tension (resolved):** under "hollow = cannot control," a *suspended* endpoint also can't be
controlled, yet it is gray-**filled** — reconciled by reading filled as "actionable = control **or
wake**" (suspended is wakeable on a live node; offline is not). `red` doubles as an "incomplete /
needs-attention" signal for the live-but-unbound state — intended.

## Cross-cutting

- CONTEXT.md "instance state" glossary already records the four-state model (done this session). The
  display palette is impl detail (lives here, not the glossary).
- Public docs (ride the build): gh-pages `terminal`/picker status-square legend + the new palette.
- Folds into the v0.17.0 milestone JIT (`V0.17.0-...`) as the presence/display waves.


Full W5 spec captured. Now grounding in the W2/W3 join path. Locating the exact sites + current wire types.


crates\spt-daemon\src\brain.rs:42:    NetStreamSubscribeReq, NetStreamsReply, OutputEvent, PairJoinReply, PairJoinReq, ResizeReq,
crates\spt-daemon\src\brain.rs:148:    PairJoined(PairJoinReply),
crates\spt-daemon\src\brain.rs:733:                    return Ok(BrokerEvent::PairJoined(ev));
crates\spt-daemon\src\brain.rs:1009:    pub fn pair_join(
crates\spt-daemon\src\brain.rs:1017:            serde_json::to_value(PairJoinReq {
crates\spt-daemon\src\brain.rs:1022:            .expect("PairJoinReq serializes"),
crates\spt-daemon\src\brain.rs:1026:                BrokerEvent::PairJoined(r) => return Ok(r),
crates\spt-daemon\src\broker.rs:58:    NetStreamsReply, PairJoinReply, PairJoinReq, ResizeReq, SessionInfo, SessionsReply, SpawnReq,
crates\spt-daemon\src\broker.rs:2203:        let req: PairJoinReq = serde_json::from_value(env.payload)
crates\spt-daemon\src\broker.rs:2228:                    JoinFail::NoSeedHolder(_) => ("no-seed-holder", None),
crates\spt-daemon\src\msg.rs:496:pub struct PairJoinReq {
crates\spt-daemon\src\nethost.rs:826:    pub fn pair_join(
crates\spt\src\cli.rs:6226:fn cmd_subnet_join(name: Option<String>, code: Option<String>) -> i32 {
crates\spt-net\src\net\pairing\wire.rs:93:pub enum PairWireError {
crates\spt-net\src\net\pairing\ntp.rs:6://! with `ClockSkew` / `NoSeedHolder`. The fix is an **in-process offset**
crates\spt-net\src\net\pairing\meet.rs:253:pub async fn dial_via_rendezvous(
crates\spt-net\src\net\pairing\spake.rs:37:pub enum PairError {
crates\spt-daemon\src\pairhost.rs:260:    NoSeedHolder(String),
crates\spt-daemon\src\pairhost.rs:277:            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
crates\spt-daemon\src\pairhost.rs:415:/// tokens. ~2.5 windows of trying before the NoSeedHolder verdict (M7
crates\spt-daemon\src\pairhost.rs:418:async fn connect_seed_holder(
crates\spt-daemon\src\pairhost.rs:427:            .map_err(|e| JoinFail::NoSeedHolder(e.to_string())),
crates\spt-daemon\src\pairhost.rs:435:                        return Err(JoinFail::NoSeedHolder(e.to_string()));


Reading the daemon-side join driver (heart of W2's state machine).


240	            // ±1 acceptance anyway); the window just ended keeps serving
241	            // one more period as `prev_tasks` (overlap, above).
242	            let now = now_secs();
243	            let next = (time_step(now) + 1) * PERIOD_SECS;
244	            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
245	            for t in prev_tasks.drain(..) {
246	                t.abort();
247	            }
248	            prev_tasks = window_tasks;
249	        }
250	    });
251	}
252
253	/// Why a join attempt failed — classified for the CLI's guided UX
254	/// (M7 plan decision 10: wrong code re-prompts, rate limits surface
255	/// honestly, a missing seed-holder gets actionable hints).
256	#[derive(Debug, Clone, PartialEq, Eq)]
257	pub enum JoinFail {
258	    /// No meet listener answered for any window token — no seed-holder
259	    /// reachable under that subnet name right now.
260	    NoSeedHolder(String),
261	    /// The ceremony ran and the confirmation failed — in practice: wrong code.
262	    WrongCode,
263	    /// The responder shed or refused us (busy / backoff `retry_after_secs`).
264	    RateLimited(Option<u64>),
265	    /// The responder does not hold a subnet by that name.
266	    UnknownSubnet(String),
267	    /// The dialer-announced TOTP step was outside the responder's window
268	    /// (clock skew beyond ±1 step).
269	    ClockSkew,
270	    /// Wire/store plumbing failure (stream died, seed write failed…).
271	    Other(String),
272	}
273
274	impl std::fmt::Display for JoinFail {
275	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276	        match self {
277	            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
278	            JoinFail::WrongCode => write!(f, "pairing confirmation failed (wrong code?)"),
279	            JoinFail::RateLimited(Some(s)) => write!(f, "rate-limited — retry in {s}s"),
280	            JoinFail::RateLimited(None) => write!(f, "responder busy with another ceremony"),
281	            JoinFail::UnknownSubnet(n) => write!(f, "responder holds no subnet '{n}'"),
282	            JoinFail::ClockSkew => write!(f, "TOTP step rejected — check this machine's clock"),
283	            JoinFail::Other(e) => write!(f, "{e}"),
284	        }
285	    }
286	}
287
288	/// The joiner leg (M7 plan decision 10): find a seed-holder via the LAN+relay
289	/// rendezvous (or dial `responder_addr` directly — the static-`ip:port` rig
290	/// seam, mirroring [`meet_at`](spt_net::net::pairing::meet::meet_at)'s
291	/// explicit-address form), run the unchanged initiator ceremony against its
292	/// real endpoint, and persist the results (peer trust-pinned; the
293	/// transferred seed makes this node a full member / future seed-holder).
294	/// One attempt — the CLI owns the re-prompt loop.
295	// [impl->REQ-SUBNET-2]
296	pub async fn join_ceremony(
297	    endpoint: &NetEndpoint,
298	    subnet_name: &str,
299	    code: &str,
300	    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
301	) -> Result<String, JoinFail> {
302	    let mut subnets = SubnetStore::load();
303	    // Roster (REQ-MESH-2): run_initiator merges the seed-holder's transferred
304	    // roster into this store; we add our own self-entry + the seed-holder's
305	    // address after a successful ceremony. Membership is roster-only (Mesh-D6).
306	    let mut roster = RosterStore::load();
307	    // The joiner's self-intro (M8 decision 13, REQ-SUBNET-7): label +
308	    // hashed machine id — what lets the seed-holder recognize this MACHINE
309	    // re-pairing under a fresh key and evict the superseded identity.
310	    // Best-effort: a label-less or id-less box just sends none (no
311	    // overwrite; manual prune covers it).
312	    // [impl->REQ-SUBNET-7]
313	    let intro = match (
314	        spt_store::hostlabel::os_hostname(),
315	        crate::machineid::machine_id_hash(),
316	    ) {
317	        (Some(label), Some(machine_id)) => {
318	            Some(spt_net::net::pairing::wire::NodeIntro { label, machine_id })
319	        }
320	        _ => None,
321	    };
322	    let conn = connect_seed_holder(endpoint, subnet_name, responder_addr.clone()).await?;
323	    let attempt = tokio::time::timeout(
324	        CEREMONY_TIMEOUT,
325	        run_initiator(
326	            &conn,
327	            endpoint.node_id(),
328	            subnet_name,
329	            code,
330	            &mut subnets,
331	            &mut roster,
332	            now_secs(),
333	            0,
334	            intro.as_ref(),
335	        ),
336	    )
337	    .await
338	    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?;
339	    match attempt {
340	        Ok(()) => {
341	            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
342	            // persist the seed-holder's observed address from the live
343	            // ceremony connection — the post-join first sync dials it
344	            // directly instead of re-finding the peer from scratch.
345	            // [impl->REQ-CONV-1]
346	            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
347	            // [impl->REQ-MESH-2]
348	            stamp_roster_responder(&mut roster, subnet_name, &conn);
349	        }
350	        // The LATE-CODE retry (field report 2026-06-06): the joiner holds no
351	        // seed, so a code read just before a rotation and entered just after
352	        // binds to the wrong step and reads as "wrong code" — before
353	        // bouncing that back at the user, retry the SAME code announced one
354	        // step back (the responder's ±1 window exists for exactly this
355	        // tolerance). The responder runs one ceremony per connection, so the
356	        // retry redials a FRESH one, after the limiter's first-failure
357	        // backoff (1s) has lapsed. Any retry failure surfaces the FIRST
358	        // failure's class — the retry was a guess on the user's behalf, not
359	        // a second user attempt.
360	        Err(PairWireError::Pairing(first)) => {
361	            let first_fail = classify_wire_error(PairWireError::Pairing(first));
362	            drop(conn);
363	            tokio::time::sleep(Duration::from_millis(1_200)).await;
364	            let Ok(retry_conn) = connect_seed_holder(endpoint, subnet_name, responder_addr).await
365	            else {
366	                return Err(first_fail);
367	            };
368	            match tokio::time::timeout(
369	                CEREMONY_TIMEOUT,
370	                run_initiator(
371	                    &retry_conn,
372	                    endpoint.node_id(),
373	                    subnet_name,
374	                    code,
375	                    &mut subnets,
376	                    &mut roster,
377	                    now_secs(),
378	                    1,
379	                    intro.as_ref(),
380	                ),
381	            )
382	            .await
383	            {
384	                Ok(Ok(())) => {
385	                    eprintln!("(code had just rotated — accepted against the previous step)");
386	                    // The retry ran on a FRESH connection — seed from it.
387	                    // [impl->REQ-CONV-1]
388	                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
389	                    // [impl->REQ-MESH-2]
390	                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
391	                }
392	                _ => return Err(first_fail),
393	            }
394	        }
395	        Err(e) => return Err(classify_wire_error(e)),
396	    }
397	    subnets
398	        .save()
399	        .map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
400	    // Author our own roster self-entry for the now-joined subnet, then persist
401	    // the adopted roster (REQ-MESH-2). `subnets` now holds the joined subnet, so
402	    // refresh covers it.
403	    // [impl->REQ-MESH-2]
404	    refresh_self_roster(&mut roster, &subnets, &endpoint.node_id().to_hex());
405	    roster
406	        .save()
407	        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
408	    Ok(format!("joined subnet '{subnet_name}'"))
409	}
410
411	/// Reach a seed-holder: dial `responder_addr` directly when given (the
412	/// static-`ip:port` rig seam), else sweep the rendezvous until the search
413	/// deadline — one sweep races the ±1 window's tokens (bounded probes), and a
414	/// sweep that straddles a rotation simply misses; the next one derives fresh
415	/// tokens. ~2.5 windows of trying before the NoSeedHolder verdict (M7
416	/// acceptance fix).
417	// [impl->REQ-SUBNET-2]
418	async fn connect_seed_holder(
419	    endpoint: &NetEndpoint,
420	    subnet_name: &str,
421	    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
422	) -> Result<Connection, JoinFail> {
423	    match responder_addr {
424	        Some(addr) => endpoint
425	            .connect_with_alpn(addr, spt_net::net::pairing::wire::SPT_PAIR_ALPN)
426	            .await
427	            .map_err(|e| JoinFail::NoSeedHolder(e.to_string())),
428	        None => {
429	            const SEARCH_DEADLINE: Duration = Duration::from_secs(75);
430	            let start = std::time::Instant::now();
431	            loop {
432	                match dial_via_rendezvous(endpoint, subnet_name, time_step(now_secs())).await {
433	                    Ok(conn) => return Ok(conn),
434	                    Err(e) if start.elapsed() >= SEARCH_DEADLINE => {
435	                        return Err(JoinFail::NoSeedHolder(e.to_string()));
436	                    }
437	                    Err(_) => tokio::time::sleep(Duration::from_millis(500)).await,
438	                }
439	            }
440	        }
441	    }
442	}
443
444	/// Map the wire error onto the CLI-facing failure classes.
445	fn classify_wire_error(e: PairWireError) -> JoinFail {
446	    match e {
447	        PairWireError::Pairing(_) => JoinFail::WrongCode,
448	        PairWireError::Refused(RateLimited::Busy) => JoinFail::RateLimited(None),
449	        PairWireError::Refused(RateLimited::Backoff { retry_after_secs }) => {
450	            JoinFail::RateLimited(Some(retry_after_secs))
451	        }
452	        PairWireError::UnknownSubnet(n) => JoinFail::UnknownSubnet(n),
453	        PairWireError::StepRejected => JoinFail::ClockSkew,
454	        PairWireError::Io(e) | PairWireError::Protocol(e) => JoinFail::Other(e),
455	    }
456	}
457
458	/// The handshake-proven remote identity of a ceremony connection, as the
459	/// lowercase-hex stable name the stores key on.

            // ±1 acceptance anyway); the window just ended keeps serving
            // one more period as `prev_tasks` (overlap, above).
            let now = now_secs();
            let next = (time_step(now) + 1) * PERIOD_SECS;
            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
            for t in prev_tasks.drain(..) {
                t.abort();
            }
            prev_tasks = window_tasks;
        }
    });
}

/// Why a join attempt failed — classified for the CLI's guided UX
/// (M7 plan decision 10: wrong code re-prompts, rate limits surface
/// honestly, a missing seed-holder gets actionable hints).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JoinFail {
    /// No meet listener answered for any window token — no seed-holder
    /// reachable under that subnet name right now.
    NoSeedHolder(String),
    /// The ceremony ran and the confirmation failed — in practice: wrong code.
    WrongCode,
    /// The responder shed or refused us (busy / backoff `retry_after_secs`).
    RateLimited(Option<u64>),
    /// The responder does not hold a subnet by that name.
    UnknownSubnet(String),
    /// The dialer-announced TOTP step was outside the responder's window
    /// (clock skew beyond ±1 step).
    ClockSkew,
    /// Wire/store plumbing failure (stream died, seed write failed…).
    Other(String),
}

impl std::fmt::Display for JoinFail {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
            JoinFail::WrongCode => write!(f, "pairing confirmation failed (wrong code?)"),
            JoinFail::RateLimited(Some(s)) => write!(f, "rate-limited — retry in {s}s"),
            JoinFail::RateLimited(None) => write!(f, "responder busy with another ceremony"),
            JoinFail::UnknownSubnet(n) => write!(f, "responder holds no subnet '{n}'"),
            JoinFail::ClockSkew => write!(f, "TOTP step rejected — check this machine's clock"),
            JoinFail::Other(e) => write!(f, "{e}"),
        }
    }
}

/// The joiner leg (M7 plan decision 10): find a seed-holder via the LAN+relay
/// rendezvous (or dial `responder_addr` directly — the static-`ip:port` rig
/// seam, mirroring [`meet_at`](spt_net::net::pairing::meet::meet_at)'s
/// explicit-address form), run the unchanged initiator ceremony against its
/// real endpoint, and persist the results (peer trust-pinned; the
/// transferred seed makes this node a full member / future seed-holder).
/// One attempt — the CLI owns the re-prompt loop.
// [impl->REQ-SUBNET-2]
pub async fn join_ceremony(
    endpoint: &NetEndpoint,
    subnet_name: &str,
    code: &str,
    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
) -> Result<String, JoinFail> {
    let mut subnets = SubnetStore::load();
    // Roster (REQ-MESH-2): run_initiator merges the seed-holder's transferred
    // roster into this store; we add our own self-entry + the seed-holder's
    // address after a successful ceremony. Membership is roster-only (Mesh-D6).
    let mut roster = RosterStore::load();
    // The joiner's self-intro (M8 decision 13, REQ-SUBNET-7): label +
    // hashed machine id — what lets the seed-holder recognize this MACHINE
    // re-pairing under a fresh key and evict the superseded identity.
    // Best-effort: a label-less or id-less box just sends none (no
    // overwrite; manual prune covers it).
    // [impl->REQ-SUBNET-7]
    let intro = match (
        spt_store::hostlabel::os_hostname(),
        crate::machineid::machine_id_hash(),
    ) {
        (Some(label), Some(machine_id)) => {
            Some(spt_net::net::pairing::wire::NodeIntro { label, machine_id })
        }
        _ => None,
    };
    let conn = connect_seed_holder(endpoint, subnet_name, responder_addr.clone()).await?;
    let attempt = tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_initiator(
            &conn,
            endpoint.node_id(),
            subnet_name,
            code,
            &mut subnets,
            &mut roster,
            now_secs(),
            0,
            intro.as_ref(),
        ),
    )
    .await
    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?;
    match attempt {
        Ok(()) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it
            // directly instead of re-finding the peer from scratch.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
            // [impl->REQ-MESH-2]
            stamp_roster_responder(&mut roster, subnet_name, &conn);
        }
        // The LATE-CODE retry (field report 2026-06-06): the joiner holds no
        // seed, so a code read just before a rotation and entered just after
        // binds to the wrong step and reads as "wrong code" — before
        // bouncing that back at the user, retry the SAME code announced one
        // step back (the responder's ±1 window exists for exactly this
        // tolerance). The responder runs one ceremony per connection, so the
        // retry redials a FRESH one, after the limiter's first-failure
        // backoff (1s) has lapsed. Any retry failure surfaces the FIRST
        // failure's class — the retry was a guess on the user's behalf, not
        // a second user attempt.
        Err(PairWireError::Pairing(first)) => {
            let first_fail = classify_wire_error(PairWireError::Pairing(first));
            drop(conn);
            tokio::time::sleep(Duration::from_millis(1_200)).await;
            let Ok(retry_conn) = connect_seed_holder(endpoint, subnet_name, responder_addr).await
            else {
                return Err(first_fail);
            };
            match tokio::time::timeout(
                CEREMONY_TIMEOUT,
                run_initiator(
                    &retry_conn,
                    endpoint.node_id(),
                    subnet_name,
                    code,
                    &mut subnets,
                    &mut roster,
                    now_secs(),
                    1,
                    intro.as_ref(),
                ),
            )
            .await
            {
                Ok(Ok(())) => {
                    eprintln!("(code had just rotated — accepted against the previous step)");
                    // The retry ran on a FRESH connection — seed from it.
                    // [impl->REQ-CONV-1]
                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
                    // [impl->REQ-MESH-2]
                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
                }
                _ => return Err(first_fail),
            }
        }
        Err(e) => return Err(classify_wire_error(e)),
    }
    subnets
        .save()
        .map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
    // Author our own roster self-entry for the now-joined subnet, then persist
    // the adopted roster (REQ-MESH-2). `subnets` now holds the joined subnet, so
    // refresh covers it.
    // [impl->REQ-MESH-2]
    refresh_self_roster(&mut roster, &subnets, &endpoint.node_id().to_hex());
    roster
        .save()
        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
    Ok(format!("joined subnet '{subnet_name}'"))
}

/// Reach a seed-holder: dial `responder_addr` directly when given (the
/// static-`ip:port` rig seam), else sweep the rendezvous until the search
/// deadline — one sweep races the ±1 window's tokens (bounded probes), and a
/// sweep that straddles a rotation simply misses; the next one derives fresh
/// tokens. ~2.5 windows of trying before the NoSeedHolder verdict (M7
/// acceptance fix).
// [impl->REQ-SUBNET-2]
async fn connect_seed_holder(
    endpoint: &NetEndpoint,
    subnet_name: &str,
    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
) -> Result<Connection, JoinFail> {
    match responder_addr {
        Some(addr) => endpoint
            .connect_with_alpn(addr, spt_net::net::pairing::wire::SPT_PAIR_ALPN)
            .await
            .map_err(|e| JoinFail::NoSeedHolder(e.to_string())),
        None => {
            const SEARCH_DEADLINE: Duration = Duration::from_secs(75);
            let start = std::time::Instant::now();
            loop {
                match dial_via_rendezvous(endpoint, subnet_name, time_step(now_secs())).await {
                    Ok(conn) => return Ok(conn),
                    Err(e) if start.elapsed() >= SEARCH_DEADLINE => {
                        return Err(JoinFail::NoSeedHolder(e.to_string()));
                    }
                    Err(_) => tokio::time::sleep(Duration::from_millis(500)).await,
                }
            }
        }
    }
}

/// Map the wire error onto the CLI-facing failure classes.
fn classify_wire_error(e: PairWireError) -> JoinFail {
    match e {
        PairWireError::Pairing(_) => JoinFail::WrongCode,
        PairWireError::Refused(RateLimited::Busy) => JoinFail::RateLimited(None),
        PairWireError::Refused(RateLimited::Backoff { retry_after_secs }) => {
            JoinFail::RateLimited(Some(retry_after_secs))
        }
        PairWireError::UnknownSubnet(n) => JoinFail::UnknownSubnet(n),
        PairWireError::StepRejected => JoinFail::ClockSkew,
        PairWireError::Io(e) | PairWireError::Protocol(e) => JoinFail::Other(e),
    }
}

/// The handshake-proven remote identity of a ceremony connection, as the
/// lowercase-hex stable name the stores key on.


The existing `connect_seed_holder` already has a direct-addr path — phase 2 can reuse it with the held addr (no re-search). Reading the IPC protocol layer: brain.pair_join, broker handler, msg types, CLI.


1000	        }
1001	    }
1002
1003	    /// Run the joiner leg of the subnet pairing ceremony on the broker (M7
1004	    /// D3, REQ-SUBNET-2): the broker owns the node-identity endpoint the
1005	    /// ceremony binds and pins. Blocks for the ceremony's bounded life; the
1006	    /// reply classifies failures for the CLI's guided UX. Not journaled —
1007	    /// the code must never persist, and a re-join is store-idempotent.
1008	    // [impl->REQ-SUBNET-2]
1009	    pub fn pair_join(
1010	        &mut self,
1011	        subnet: &str,
1012	        code: &str,
1013	        responder_addr: Option<serde_json::Value>,
1014	    ) -> io::Result<PairJoinReply> {
1015	        self.send(
1016	            KIND_PAIR_JOIN,
1017	            serde_json::to_value(PairJoinReq {
1018	                subnet: subnet.to_string(),
1019	                code: code.to_string(),
1020	                responder_addr,
1021	            })
1022	            .expect("PairJoinReq serializes"),
1023	        )?;
1024	        loop {
1025	            match self.read_event()? {
1026	                BrokerEvent::PairJoined(r) => return Ok(r),
1027	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
1028	                _ => continue,
1029	            }
1030	        }
1031	    }
1032
1033	    /// Dial a peer over the broker-owned endpoint (D4a). `addr` is the peer's
1034	    /// serialized `EndpointAddr` (as another node's `net-status` reply carries).
1035	    /// With `op_id` the dial is exactly-once: a replay of the same durable id
1036	    /// after a brain crash is deduped at the broker and answered with the
1037	    /// already-open connection ([`NetDialed::applied_now`] distinguishes).
1038	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
1039	    pub fn net_dial(
1040	        &mut self,
1041	        addr: serde_json::Value,
1042	        op_id: Option<u64>,
1043	    ) -> io::Result<NetDialed> {
1044	        self.send(

        }
    }

    /// Run the joiner leg of the subnet pairing ceremony on the broker (M7
    /// D3, REQ-SUBNET-2): the broker owns the node-identity endpoint the
    /// ceremony binds and pins. Blocks for the ceremony's bounded life; the
    /// reply classifies failures for the CLI's guided UX. Not journaled —
    /// the code must never persist, and a re-join is store-idempotent.
    // [impl->REQ-SUBNET-2]
    pub fn pair_join(
        &mut self,
        subnet: &str,
        code: &str,
        responder_addr: Option<serde_json::Value>,
    ) -> io::Result<PairJoinReply> {
        self.send(
            KIND_PAIR_JOIN,
            serde_json::to_value(PairJoinReq {
                subnet: subnet.to_string(),
                code: code.to_string(),
                responder_addr,
            })
            .expect("PairJoinReq serializes"),
        )?;
        loop {
            match self.read_event()? {
                BrokerEvent::PairJoined(r) => return Ok(r),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => continue,
            }
        }
    }

    /// Dial a peer over the broker-owned endpoint (D4a). `addr` is the peer's
    /// serialized `EndpointAddr` (as another node's `net-status` reply carries).
    /// With `op_id` the dial is exactly-once: a replay of the same durable id
    /// after a brain crash is deduped at the broker and answered with the
    /// already-open connection ([`NetDialed::applied_now`] distinguishes).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn net_dial(
        &mut self,
        addr: serde_json::Value,
        op_id: Option<u64>,
    ) -> io::Result<NetDialed> {
        self.send(


488	    /// the same id after a brain crash is deduped and answered with the
489	    /// already-open connection instead of dialing twice. `None` dials directly.
490	    #[serde(default, skip_serializing_if = "Option::is_none")]
491	    pub op_id: Option<u64>,
492	}
493
494	/// `pair-join` payload — the two user-entered ceremony inputs (M7 D3).
495	#[derive(Debug, Clone, Serialize, Deserialize)]
496	pub struct PairJoinReq {
497	    /// The subnet to join (names the rendezvous + the seed).
498	    pub subnet: String,
499	    /// The current 6-digit TOTP code (the SPAKE2 password — consumed by the
500	    /// ceremony, never logged or journaled).
501	    pub code: String,
502	    /// Skip the rendezvous and dial THIS responder address directly (a
503	    /// serde-serialized `EndpointAddr`) — the static-`ip:port` rig seam,
504	    /// mirroring `pairing::meet::meet_at`'s explicit-address form. The
505	    /// guided CLI never sets it.
506	    #[serde(default, skip_serializing_if = "Option::is_none")]
507	    pub responder_addr: Option<serde_json::Value>,
508	}
509
510	/// `pair-joined` reply — the ceremony's verdict, classified for the CLI's
511	/// guided UX (wrong code re-prompts; rate limits surface honestly; a missing
512	/// seed-holder gets actionable hints).
513	#[derive(Debug, Clone, Serialize, Deserialize)]
514	pub struct PairJoinReply {
515	    /// Whether the ceremony confirmed and the seed landed.
516	    pub ok: bool,
517	    /// On failure: the [`crate::pairhost::JoinFail`] class tag —
518	    /// `no-seed-holder` / `wrong-code` / `rate-limited` / `unknown-subnet` /
519	    /// `clock-skew` / `other`.
520	    #[serde(default, skip_serializing_if = "Option::is_none")]
521	    pub class: Option<String>,
522	    /// Rate-limit retry window (seconds), when the class is `rate-limited`
523	    /// with a known backoff.
524	    #[serde(default, skip_serializing_if = "Option::is_none")]
525	    pub retry_after_secs: Option<u64>,
526	    /// Operator-facing detail (the rendered outcome on success, the rendered
527	    /// failure otherwise). Never carries secret material.
528	    #[serde(default)]
529	    pub detail: String,
530	}
531
532	/// `net-dialed` event — a dial durably landed (fresh or deduped).
533	#[derive(Debug, Clone, Serialize, Deserialize)]
534	pub struct NetDialed {
535	    /// The broker-minted id addressing this connection in later net commands.
536	    pub conn_id: u64,
537	    /// The remote node's stable name (lowercase-hex pubkey), proven by the QUIC
538	    /// handshake (the EndpointId *is* the key).
539	    pub remote_id_hex: String,
540	    /// Echoes the request's durable op id, when one was carried.
541	    #[serde(default, skip_serializing_if = "Option::is_none")]
542	    pub op_id: Option<u64>,
543	    /// Whether the dial ran on this delivery (`true`) or was deduped (`false`).
544	    /// Both mean "durably landed" — mirrors [`AppliedEvent::applied_now`].
545	    #[serde(default)]
546	    pub applied_now: bool,
547	    /// The peer's address as OBSERVED on the live connection (remote id +

    /// the same id after a brain crash is deduped and answered with the
    /// already-open connection instead of dialing twice. `None` dials directly.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
}

/// `pair-join` payload — the two user-entered ceremony inputs (M7 D3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairJoinReq {
    /// The subnet to join (names the rendezvous + the seed).
    pub subnet: String,
    /// The current 6-digit TOTP code (the SPAKE2 password — consumed by the
    /// ceremony, never logged or journaled).
    pub code: String,
    /// Skip the rendezvous and dial THIS responder address directly (a
    /// serde-serialized `EndpointAddr`) — the static-`ip:port` rig seam,
    /// mirroring `pairing::meet::meet_at`'s explicit-address form. The
    /// guided CLI never sets it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub responder_addr: Option<serde_json::Value>,
}

/// `pair-joined` reply — the ceremony's verdict, classified for the CLI's
/// guided UX (wrong code re-prompts; rate limits surface honestly; a missing
/// seed-holder gets actionable hints).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairJoinReply {
    /// Whether the ceremony confirmed and the seed landed.
    pub ok: bool,
    /// On failure: the [`crate::pairhost::JoinFail`] class tag —
    /// `no-seed-holder` / `wrong-code` / `rate-limited` / `unknown-subnet` /
    /// `clock-skew` / `other`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    /// Rate-limit retry window (seconds), when the class is `rate-limited`
    /// with a known backoff.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_after_secs: Option<u64>,
    /// Operator-facing detail (the rendered outcome on success, the rendered
    /// failure otherwise). Never carries secret material.
    #[serde(default)]
    pub detail: String,
}

/// `net-dialed` event — a dial durably landed (fresh or deduped).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetDialed {
    /// The broker-minted id addressing this connection in later net commands.
    pub conn_id: u64,
    /// The remote node's stable name (lowercase-hex pubkey), proven by the QUIC
    /// handshake (the EndpointId *is* the key).
    pub remote_id_hex: String,
    /// Echoes the request's durable op id, when one was carried.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
    /// Whether the dial ran on this delivery (`true`) or was deduped (`false`).
    /// Both mean "durably landed" — mirrors [`AppliedEvent::applied_now`].
    #[serde(default)]
    pub applied_now: bool,
    /// The peer's address as OBSERVED on the live connection (remote id +


Reading the broker-side join handler + the CLI flow.


2195	    /// REAL node pubkey, so it cannot run anywhere else. Not journaled: a
2196	    /// repeated join is idempotent at the stores (re-pair keeps the existing
2197	    /// trust row + seed), and the TOTP code in the request must never land
2198	    /// in a durable journal. Blocks this client's channel for the ceremony's
2199	    /// (bounded) life; the reply is always a `pair-joined` frame, with
2200	    /// failures classified for the CLI's guided UX.
2201	    // [impl->REQ-SUBNET-2]
2202	    fn dispatch_pair_join(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
2203	        let req: PairJoinReq = serde_json::from_value(env.payload)
2204	            .map_err(|e| format!("bad pair-join payload: {e}"))?;
2205	        let host = self
2206	            .net
2207	            .get()
2208	            .ok_or("net disabled: this broker was bound without a network host")?;
2209	        // The optional explicit responder address (the rig seam) decodes
2210	        // strictly — a malformed address is a usage error, not a dial flop.
2211	        let responder_addr = match req.responder_addr {
2212	            Some(v) => Some(
2213	                serde_json::from_value::<EndpointAddr>(v)
2214	                    .map_err(|e| format!("bad pair-join responder_addr: {e}"))?,
2215	            ),
2216	            None => None,
2217	        };
2218	        let reply = match host.pair_join(&req.subnet, &req.code, responder_addr) {
2219	            Ok(outcome) => PairJoinReply {
2220	                ok: true,
2221	                class: None,
2222	                retry_after_secs: None,
2223	                detail: outcome,
2224	            },
2225	            Err(fail) => {
2226	                use crate::pairhost::JoinFail;
2227	                let (class, retry) = match &fail {
2228	                    JoinFail::NoSeedHolder(_) => ("no-seed-holder", None),
2229	                    JoinFail::WrongCode => ("wrong-code", None),
2230	                    JoinFail::RateLimited(retry) => ("rate-limited", *retry),
2231	                    JoinFail::UnknownSubnet(_) => ("unknown-subnet", None),
2232	                    JoinFail::ClockSkew => ("clock-skew", None),
2233	                    JoinFail::Other(_) => ("other", None),
2234	                };
2235	                PairJoinReply {
2236	                    ok: false,
2237	                    class: Some(class.to_string()),
2238	                    retry_after_secs: retry,
2239	                    detail: fail.to_string(),
2240	                }
2241	            }
2242	        };
2243	        let frame = Envelope::new(
2244	            KIND_PAIR_JOINED,
2245	            serde_json::to_value(reply).expect("PairJoinReply serializes"),
2246	        );
2247	        send_frame(send, &frame);
2248	        Ok(())
2249	    }
2250
2251	    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
2252	    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
2253	    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
2254	    /// replay is answered with the stream the first apply opened.
2255	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
2256	    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
2257	        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
2258	            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
2259	        let host = self.net.get().ok_or("net disabled")?;
2260	        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
2261	            Envelope::new(
2262	                KIND_NET_STREAM_OPENED,
2263	                serde_json::to_value(NetStreamOpened {
2264	                    stream_id,
2265	                    conn_id,
2266	                    op_id,
2267	                    applied_now,
2268	                })
2269	                .expect("NetStreamOpened serializes"),
2270	            )
2271	        };
2272	        match req.op_id {
2273	            None => {
2274	                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
2275	                send_frame(send, &build(stream_id, req.conn_id, None, true));
2276	                Ok(())
2277	            }
2278	            Some(op) => {
2279	                let outcome = self
2280	                    .journal
2281	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
2282	                        let stream_id = host.open_stream(req.conn_id)?;
2283	                        host.record_stream_op(op, stream_id);
2284	                        Ok(())

    /// REAL node pubkey, so it cannot run anywhere else. Not journaled: a
    /// repeated join is idempotent at the stores (re-pair keeps the existing
    /// trust row + seed), and the TOTP code in the request must never land
    /// in a durable journal. Blocks this client's channel for the ceremony's
    /// (bounded) life; the reply is always a `pair-joined` frame, with
    /// failures classified for the CLI's guided UX.
    // [impl->REQ-SUBNET-2]
    fn dispatch_pair_join(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: PairJoinReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad pair-join payload: {e}"))?;
        let host = self
            .net
            .get()
            .ok_or("net disabled: this broker was bound without a network host")?;
        // The optional explicit responder address (the rig seam) decodes
        // strictly — a malformed address is a usage error, not a dial flop.
        let responder_addr = match req.responder_addr {
            Some(v) => Some(
                serde_json::from_value::<EndpointAddr>(v)
                    .map_err(|e| format!("bad pair-join responder_addr: {e}"))?,
            ),
            None => None,
        };
        let reply = match host.pair_join(&req.subnet, &req.code, responder_addr) {
            Ok(outcome) => PairJoinReply {
                ok: true,
                class: None,
                retry_after_secs: None,
                detail: outcome,
            },
            Err(fail) => {
                use crate::pairhost::JoinFail;
                let (class, retry) = match &fail {
                    JoinFail::NoSeedHolder(_) => ("no-seed-holder", None),
                    JoinFail::WrongCode => ("wrong-code", None),
                    JoinFail::RateLimited(retry) => ("rate-limited", *retry),
                    JoinFail::UnknownSubnet(_) => ("unknown-subnet", None),
                    JoinFail::ClockSkew => ("clock-skew", None),
                    JoinFail::Other(_) => ("other", None),
                };
                PairJoinReply {
                    ok: false,
                    class: Some(class.to_string()),
                    retry_after_secs: retry,
                    detail: fail.to_string(),
                }
            }
        };
        let frame = Envelope::new(
            KIND_PAIR_JOINED,
            serde_json::to_value(reply).expect("PairJoinReply serializes"),
        );
        send_frame(send, &frame);
        Ok(())
    }

    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
    /// replay is answered with the stream the first apply opened.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
        let host = self.net.get().ok_or("net disabled")?;
        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
            Envelope::new(
                KIND_NET_STREAM_OPENED,
                serde_json::to_value(NetStreamOpened {
                    stream_id,
                    conn_id,
                    op_id,
                    applied_now,
                })
                .expect("NetStreamOpened serializes"),
            )
        };
        match req.op_id {
            None => {
                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
                send_frame(send, &build(stream_id, req.conn_id, None, true));
                Ok(())
            }
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
                        let stream_id = host.open_stream(req.conn_id)?;
                        host.record_stream_op(op, stream_id);
                        Ok(())


6226	fn cmd_subnet_join(name: Option<String>, code: Option<String>) -> i32 {
6227	    // The gate runs FIRST — before any prompt or network syllable
6228	    // (REQ-SUBNET-4: refused = nothing typed, nothing dialed, nothing saved).
6229	    if let Some(msg) = join_elevation_refusal(elevation::current()) {
6230	        if let Some(code) = try_auto_elevate() {
6231	            return code;
6232	        }
6233	        eprintln!("{}", with_elevation_hint(msg));
6234	        return EXIT_NOT_ELEVATED;
6235	    }
6236	    let Some(name) = name.or_else(|| prompt_line("Subnet name: ")) else {
6237	        eprintln!(
6238	            "USAGE: a subnet name is required — `spt subnet join <name>` (or run interactively)"
6239	        );
6240	        return 2;
6241	    };
6242	    if SubnetStore::load().find(&name).is_some() {
6243	        eprintln!("ALREADY_MEMBER:{name} — this machine already holds the subnet");
6244	        return 0;
6245	    }
6246	    let interactive = code.is_none();
6247	    let Some(mut code) = code.or_else(|| prompt_line("6-digit code: ")) else {
6248	        eprintln!(
6249	            "USAGE: the current 6-digit code is required — `--code <CODE>` (or run interactively)"
6250	        );
6251	        return 2;
6252	    };
6253
6254	    if let Err(code) = ensure_daemon_announced() {
6255	        return code;
6256	    }
6257	    let mut brain =
6258	        match spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms()) {
6259	            Ok(b) => b,
6260	            Err(e) => {
6261	                eprintln!("BROKER_CONNECT_FAIL: {e}");
6262	                return 1;
6263	            }
6264	        };
6265
6266	    const MAX_ATTEMPTS: usize = 3;
6267	    for attempt in 1..=MAX_ATTEMPTS {
6268	        eprintln!("Searching for a \"{name}\" member (LAN + relay)…");
6269	        let reply = match brain.pair_join(&name, &code, None) {
6270	            Ok(r) => r,
6271	            Err(e) => {
6272	                eprintln!("JOIN_FAIL:{name}: {e}");
6273	                return 1;
6274	            }
6275	        };
6276	        if reply.ok {
6277	            println!("JOINED SUBNET: {name}");
6278	            // First-join home adoption (REQ-INST-15): fires on join exactly
6279	            // as on create — the daemon saved the seed; local-only Self
6280	            // perches adopt the fresh sole subnet now rather than waiting
6281	            // one advertisement cadence.
6282	            let store = SubnetStore::load();
6283	            let mut vis = spt_store::visibility::VisibilityStore::load();
6284	            let adopted = spt_store::home::adopt_for_unset(&perch::owlery_dir(), &store, &mut vis);
6285	            if !adopted.is_empty() {
6286	                let _ = vis.save();
6287	            }
6288	            announce_sync_home(&adopted);
6289	            // Re-provision material for the joiner (ADR-0005: re-provision the QR
6290	            // from any trusted node). Now a member AND elevated (the gate passed
6291	            // first), surface this subnet's code + `otpauth://` URI + terminal QR
6292	            // so this machine can add the subnet to its OWN authenticator app —
6293	            // the same elevated Show output as `show-code`, reusing the
6294	            // unit-tested decision core. [impl->REQ-PAIR-3] [impl->REQ-SUBNET-1]
6295	            let mut qstore = SubnetStore::load();
6296	            let show = decide_show_code(
6297	                resolve(&mut qstore, Some(name.clone()), None),
6298	                now_unix(),
6299	                elevation::current(),
6300	            );
6301	            if let Some(out) = show.out {
6302	                print!("{out}");
6303	                let _ = std::io::stdout().flush();
6304	            }
6305	            // Translate the trust verdict (the wire carries the machine tag;
6306	            // "(Pinned)" means nothing to a person — grill 2026-06-06):
6307	            match reply.detail.as_str() {
6308	                "Pinned" => eprintln!(
6309	                    "This machine and the member that answered now recognize and \
6310	                     trust each other."
6311	                ),
6312	                "AlreadyTrusted" => {
6313	                    eprintln!("The machines already trusted each other — membership refreshed.")
6314	                }
6315	                _ => {}
6316	            }
6317	            eprintln!(
6318	                "Machines in \"{name}\" will see each other shortly (the first sync \
6319	                 can take a minute) — check with `spt subnet status --nodes` on \
6320	                 either machine."
6321	            );
6322	            // Hold a fresh elevated console open (Windows UAC re-launch) so the
6323	            // joiner reads the QR before the window closes; no-op otherwise.
6324	            pause_elevated_console_if_fresh();
6325	            return 0;
6326	        }
6327	        match reply.class.as_deref() {
6328	            // Wrong code: re-prompt against a FRESH window (codes rotate
6329	            // every 30s) — bounded so a typo can't become a guessing loop.
6330	            Some("wrong-code") if interactive && attempt < MAX_ATTEMPTS => {
6331	                match prompt_line("Wrong code (codes rotate every 30s) — enter the CURRENT code: ")
6332	                {
6333	                    Some(next) => code = next,
6334	                    None => {
6335	                        eprintln!("JOIN_FAIL:{name}: wrong code");

fn cmd_subnet_join(name: Option<String>, code: Option<String>) -> i32 {
    // The gate runs FIRST — before any prompt or network syllable
    // (REQ-SUBNET-4: refused = nothing typed, nothing dialed, nothing saved).
    if let Some(msg) = join_elevation_refusal(elevation::current()) {
        if let Some(code) = try_auto_elevate() {
            return code;
        }
        eprintln!("{}", with_elevation_hint(msg));
        return EXIT_NOT_ELEVATED;
    }
    let Some(name) = name.or_else(|| prompt_line("Subnet name: ")) else {
        eprintln!(
            "USAGE: a subnet name is required — `spt subnet join <name>` (or run interactively)"
        );
        return 2;
    };
    if SubnetStore::load().find(&name).is_some() {
        eprintln!("ALREADY_MEMBER:{name} — this machine already holds the subnet");
        return 0;
    }
    let interactive = code.is_none();
    let Some(mut code) = code.or_else(|| prompt_line("6-digit code: ")) else {
        eprintln!(
            "USAGE: the current 6-digit code is required — `--code <CODE>` (or run interactively)"
        );
        return 2;
    };

    if let Err(code) = ensure_daemon_announced() {
        return code;
    }
    let mut brain =
        match spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms()) {
            Ok(b) => b,
            Err(e) => {
                eprintln!("BROKER_CONNECT_FAIL: {e}");
                return 1;
            }
        };

    const MAX_ATTEMPTS: usize = 3;
    for attempt in 1..=MAX_ATTEMPTS {
        eprintln!("Searching for a \"{name}\" member (LAN + relay)…");
        let reply = match brain.pair_join(&name, &code, None) {
            Ok(r) => r,
            Err(e) => {
                eprintln!("JOIN_FAIL:{name}: {e}");
                return 1;
            }
        };
        if reply.ok {
            println!("JOINED SUBNET: {name}");
            // First-join home adoption (REQ-INST-15): fires on join exactly
            // as on create — the daemon saved the seed; local-only Self
            // perches adopt the fresh sole subnet now rather than waiting
            // one advertisement cadence.
            let store = SubnetStore::load();
            let mut vis = spt_store::visibility::VisibilityStore::load();
            let adopted = spt_store::home::adopt_for_unset(&perch::owlery_dir(), &store, &mut vis);
            if !adopted.is_empty() {
                let _ = vis.save();
            }
            announce_sync_home(&adopted);
            // Re-provision material for the joiner (ADR-0005: re-provision the QR
            // from any trusted node). Now a member AND elevated (the gate passed
            // first), surface this subnet's code + `otpauth://` URI + terminal QR
            // so this machine can add the subnet to its OWN authenticator app —
            // the same elevated Show output as `show-code`, reusing the
            // unit-tested decision core. [impl->REQ-PAIR-3] [impl->REQ-SUBNET-1]
            let mut qstore = SubnetStore::load();
            let show = decide_show_code(
                resolve(&mut qstore, Some(name.clone()), None),
                now_unix(),
                elevation::current(),
            );
            if let Some(out) = show.out {
                print!("{out}");
                let _ = std::io::stdout().flush();
            }
            // Translate the trust verdict (the wire carries the machine tag;
            // "(Pinned)" means nothing to a person — grill 2026-06-06):
            match reply.detail.as_str() {
                "Pinned" => eprintln!(
                    "This machine and the member that answered now recognize and \
                     trust each other."
                ),
                "AlreadyTrusted" => {
                    eprintln!("The machines already trusted each other — membership refreshed.")
                }
                _ => {}
            }
            eprintln!(
                "Machines in \"{name}\" will see each other shortly (the first sync \
                 can take a minute) — check with `spt subnet status --nodes` on \
                 either machine."
            );
            // Hold a fresh elevated console open (Windows UAC re-launch) so the
            // joiner reads the QR before the window closes; no-op otherwise.
            pause_elevated_console_if_fresh();
            return 0;
        }
        match reply.class.as_deref() {
            // Wrong code: re-prompt against a FRESH window (codes rotate
            // every 30s) — bounded so a typo can't become a guessing loop.
            Some("wrong-code") if interactive && attempt < MAX_ATTEMPTS => {
                match prompt_line("Wrong code (codes rotate every 30s) — enter the CURRENT code: ")
                {
                    Some(next) => code = next,
                    None => {
                        eprintln!("JOIN_FAIL:{name}: wrong code");


Solid grounding on the join flow. The W2 design hinges on the broker's dispatch model — whether a handler can block awaiting a follow-up frame, or I need broker-held session state. Checking the serve/dispatch structure + how existing in-mem state (conn table) is held.


Grep

fn serve|fn dispatch\b|fn dispatch_frame|fn handle_client|match kind|KIND_PAIR_JOIN|KIND_PAIR_MEET|=> self\.dispatch|fn serve_client|spawn.*client|read_frame


49:use crate::codec::{read_frame, write_frame};
65:    KIND_PAIR_JOIN, KIND_PAIR_JOINED, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY, KIND_SPAWN,
1380:    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
1392:    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
1487:            let env = match read_frame(&mut recv) {
1527:                KIND_NET_STATUS => self.dispatch_net_status(&send),
1538:                KIND_PAIR_JOIN => {
1608:                KIND_BRAIN_RESTART => self.dispatch_brain_restart(&send),
2244:            KIND_PAIR_JOINED,
2609:    /// client read then hits EOF, and `read_frame` returns Err → `None`.
2610:    fn try_read_frame(client: Stream, wait: Duration) -> Option<Envelope> {
2614:            let r = read_frame(&mut client).ok();
2651:            let env = read_frame(&mut client).expect("an evicted close writes a final frame");
2679:                try_read_frame(client, Duration::from_secs(2)).is_none(),
3124:    /// `seq`s in arrival order. A blocking `read_frame` per frame — the writer
3130:            let env = read_frame(client).expect("output frame on the wire");
3216:        // blocking EOF probe (a blocking `read_frame` here can hang on Windows named


1475	    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
1476	        let (mut recv, send) = conn.split();
1477	        let send: SharedSend = Arc::new(Mutex::new(send));
1478
1479	        // Version handshake: this connection must be a brain (2.3).
1480	        recv_hello(&mut recv, Role::Brain)?;
1481
1482	        let mut my_subs: Vec<u64> = Vec::new();
1483	        let mut my_stream_subs: Vec<u64> = Vec::new();
1484	        let mut my_presence_sub = false;
1485
1486	        loop {
1487	            let env = match read_frame(&mut recv) {
1488	                Ok(e) => e,
1489	                // Any read failure (EOF included) ends the connection.
1490	                Err(_) => break,
1491	            };
1492	            match env.kind.as_str() {
1493	                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
1494	                    Ok(id) => my_subs.push(id),
1495	                    Err(msg) => send_error(&send, &msg),
1496	                },
1497	                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
1498	                    Ok(id) => my_subs.push(id),
1499	                    Err(msg) => send_error(&send, &msg),
1500	                },
1501	                KIND_UNSUBSCRIBE => {
1502	                    if let Err(msg) = self.dispatch_unsubscribe(env, &send) {
1503	                        send_error(&send, &msg);
1504	                    }
1505	                }
1506	                KIND_INPUT => {
1507	                    if let Err(msg) = self.dispatch_input(env, &send) {
1508	                        send_error(&send, &msg);
1509	                    }
1510	                }
1511	                KIND_RESIZE => {
1512	                    if let Err(msg) = self.dispatch_resize(env, &send) {
1513	                        send_error(&send, &msg);
1514	                    }
1515	                }
1516	                KIND_KILL => {
1517	                    if let Err(msg) = self.dispatch_kill(env) {
1518	                        send_error(&send, &msg);
1519	                    }
1520	                }
1521	                // [impl->REQ-ADAPTER-LIVE-UPDATE]
1522	                KIND_ADAPTER_APPLY => {
1523	                    if let Err(msg) = self.dispatch_adapter_apply(env, &send) {
1524	                        send_error(&send, &msg);
1525	                    }
1526	                }
1527	                KIND_NET_STATUS => self.dispatch_net_status(&send),
1528	                KIND_NET_DIAL => {
1529	                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
1530	                        send_error(&send, &msg);
1531	                    }
1532	                }
1533	                KIND_NET_DIAL_LOOPBACK => {
1534	                    if let Err(msg) = self.dispatch_net_dial_loopback(&send) {
1535	                        send_error(&send, &msg);
1536	                    }
1537	                }
1538	                KIND_PAIR_JOIN => {
1539	                    if let Err(msg) = self.dispatch_pair_join(env, &send) {
1540	                        send_error(&send, &msg);
1541	                    }
1542	                }
1543	                KIND_NET_STREAM_OPEN => {
1544	                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
1545	                        send_error(&send, &msg);
1546	                    }
1547	                }
1548	                KIND_NET_STREAM_SEND => {
1549	                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
1550	                        send_error(&send, &msg);
1551	                    }
1552	                }
1553	                KIND_NET_STREAMS => {
1554	                    if let Err(msg) = self.dispatch_net_streams(&send) {
1555	                        send_error(&send, &msg);
1556	                    }
1557	                }
1558	                KIND_SESSIONS => {
1559	                    // Clone the (id, endpoint, log) out from under the sessions
1560	                    // lock, then read each log's cursor under the log lock alone —
1561	                    // never both locks at once (the dispatch_subscribe discipline).
1562	                    let rows: Vec<(u64, String, Arc<Mutex<OutputLog>>)> = {
1563	                        let sessions = self.sessions.lock().unwrap();
1564	                        sessions

    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
        let (mut recv, send) = conn.split();
        let send: SharedSend = Arc::new(Mutex::new(send));

        // Version handshake: this connection must be a brain (2.3).
        recv_hello(&mut recv, Role::Brain)?;

        let mut my_subs: Vec<u64> = Vec::new();
        let mut my_stream_subs: Vec<u64> = Vec::new();
        let mut my_presence_sub = false;

        loop {
            let env = match read_frame(&mut recv) {
                Ok(e) => e,
                // Any read failure (EOF included) ends the connection.
                Err(_) => break,
            };
            match env.kind.as_str() {
                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_UNSUBSCRIBE => {
                    if let Err(msg) = self.dispatch_unsubscribe(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_INPUT => {
                    if let Err(msg) = self.dispatch_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_RESIZE => {
                    if let Err(msg) = self.dispatch_resize(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_KILL => {
                    if let Err(msg) = self.dispatch_kill(env) {
                        send_error(&send, &msg);
                    }
                }
                // [impl->REQ-ADAPTER-LIVE-UPDATE]
                KIND_ADAPTER_APPLY => {
                    if let Err(msg) = self.dispatch_adapter_apply(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STATUS => self.dispatch_net_status(&send),
                KIND_NET_DIAL => {
                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_DIAL_LOOPBACK => {
                    if let Err(msg) = self.dispatch_net_dial_loopback(&send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_PAIR_JOIN => {
                    if let Err(msg) = self.dispatch_pair_join(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_OPEN => {
                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SEND => {
                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAMS => {
                    if let Err(msg) = self.dispatch_net_streams(&send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_SESSIONS => {
                    // Clone the (id, endpoint, log) out from under the sessions
                    // lock, then read each log's cursor under the log lock alone —
                    // never both locks at once (the dispatch_subscribe discipline).
                    let rows: Vec<(u64, String, Arc<Mutex<OutputLog>>)> = {
                        let sessions = self.sessions.lock().unwrap();
                        sessions


Broker uses a per-connection sequential dispatch loop (handler runs to completion per frame) — so W2 needs broker-held session state (option b), mirroring the existing conn/stream tables, not inline frame-blocking. Verifying the meet ALPN model + nethost.pair_join before locking the design.


crates\spt-net\src\net\pairing\wire.rs:5://! stream on a dedicated pre-trust **pairing ALPN** ([`SPT_PAIR_ALPN`]), gated by the
crates\spt-net\src\net\pairing\wire.rs:62:pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";
crates\spt-net\src\net\pairing\wire.rs:951:            vec![SPT_PAIR_ALPN.to_vec()],
crates\spt-net\src\net\pairing\wire.rs:960:            vec![SPT_PAIR_ALPN.to_vec()],
crates\spt-net\src\net\pairing\wire.rs:1005:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1080:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1150:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1210:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1265:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1329:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1392:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\meet.rs:28://! dedicated pre-trust ALPN, [`SPT_PAIR_MEET_ALPN`]), and the joiner re-dials
crates\spt-net\src\net\pairing\meet.rs:29://! the real endpoint over [`SPT_PAIR_ALPN`](super::wire::SPT_PAIR_ALPN) where
crates\spt-net\src\net\pairing\meet.rs:54:use super::wire::SPT_PAIR_ALPN;
crates\spt-net\src\net\pairing\meet.rs:60:pub const SPT_PAIR_MEET_ALPN: &[u8] = b"spt-core/pair-meet/0";
crates\spt-net\src\net\pairing\meet.rs:113:/// the token-derived identity, speaking only [`SPT_PAIR_MEET_ALPN`]. Lives for
crates\spt-net\src\net\pairing\meet.rs:137:        vec![SPT_PAIR_MEET_ALPN.to_vec()],
crates\spt-net\src\net\pairing\meet.rs:201:        .connect_with_alpn(meet_addr, SPT_PAIR_MEET_ALPN)
crates\spt-net\src\net\pairing\meet.rs:277:                    .connect_with_alpn(real_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\meet.rs:357:        let real = hermetic(&resp_id, vec![SPT_PAIR_ALPN.to_vec()]).await;
crates\spt-net\src\net\pairing\meet.rs:387:        let joiner = hermetic(&Identity::generate(), vec![SPT_PAIR_ALPN.to_vec()]).await;
crates\spt-net\src\net\pairing\meet.rs:395:            .connect_with_alpn(fetched, SPT_PAIR_ALPN)