# Friends-first contacts + ephemeral rooms (W7, redesigned) — plan / scope contract **Status (2026-06-15):** IN PROGRESS. Model locked after an extended design session; **P1 (identity) + P2 (friends store) + P3 (member tickets) DONE & 2-machine field-verified; P4 (idle listener) pure core + wire transport DONE, live integration NOT wired; P5 UI partial.** See the per-phase "Core primitives" section below for exact status and `handoff.md` for the live summary. This is the scope contract; update it as phases land. Source: wishlist **W7**. > **This doc supersedes two earlier drafts:** (1) the heavyweight "port pixelpass's > always-on control plane + reachable identity + n0 discovery" cut, and (2) the > room-centric "durable named rooms" cut. Both are folded into the bottom section > "Earlier directions (superseded)" with the reasons. The model below is the one to > build. ## The model in one paragraph **The friends list (stable node IDs) is the durable anchor; rooms are ephemeral cosmetic labels.** You don't join a persistent room — you reach your *friends* and label the gathering ("HangOut"). Identity is persistent so a friend's node ID never changes. Presence is best-effort and private-by-default: your peerspeak, while open, listens for pings *only from friends* and answers from your saved address — no presence beacon. Reliability across network changes is opt-in per person (voluntary discovery), and there's a universal floor that always works regardless: a hand-shared member-issued ticket. Out-of-band coordination ("want to get on peerspeak?") stays on Signal/Telegram — peerspeak is not a messenger. ## Locked design decisions (user, 2026-06-15) 1. **Persistent identity = default foundation.** Load/create a stable `SecretKey` at a key file instead of `generate()` each launch (`core/mod.rs:421`). Node ID stable across launches. iroh doesn't force rolling IDs; today's behaviour is an unrevisited default. 2. **Settings "Regenerate identity" control** (confirm + warn) for a deliberate unlink / fresh start. Breaks others' saved reachability to you until re-exchange. 3. **Friends list keyed by node ID** (+ a locally editable display name). Fully doable now that IDs are stable. 4. **Rooms are ephemeral cosmetic labels**, not addressable durable entities. A room is a gathering with a name tag shared between clients for display; the underlying gossip topic is per-session, not a saved/named identity. (Drops name-derived topics, favorite-room persistence, empty-room reachability — all the room-centric complexity.) 5. **Recents list** = local-only saved entries (cosmetic room name + the session's topic + the member addresses seen). Purely local UI state. 6. **Presence via a friends-only idle listener.** While the app is open, peerspeak answers pings **only** from node IDs on your friends list (authenticated via `remote_id()`); everyone else is silently dropped. **No DNS presence beacon by default.** This is the one new always-on-while-open primitive, deliberately accepted because it's whitelisted + beacon-free (it avoids the stranger-spam and phone-home costs that sank the heavyweight design). 7. **Presence control axis: invisible ↔ normal ↔ discoverable.** - *invisible* — don't answer pings at all. - *normal* (default) — answer friends-only, from saved address; no beacon. - *discoverable* — opt-in publish to n0 DNS so friends can find you after a network change. **Default OFF.** Toggle in Settings **and** a shortcut in the friends list. Ideally time-boxed ("available for 30 min"). 8. **Discovery is asymmetric — only the mover publishes.** To find a friend who moved networks, *that friend* enables discovery (publishes); everyone else just looks them up (a DNS query, no publishing). So the privacy cost is localized to the one person who needs the reliability; stationary friends change nothing and keep no standing presence record. 9. **Saved addresses auto-heal silently.** Every successful connection to a friend carries their current `PeerState.addr` over gossip; peerspeak compares and re-saves it with no prompt. So for friends you actually talk to, saved addresses stay current on their own. (Opportunistic: only updates while connected — a move made while you're apart isn't seen until the next shared session, a ticket, or discovery.) 10. **Universal floor: hand-shared member-issued tickets always work.** Whatever auto-detection / discovery do, a friend can always mint a fresh ticket from their current address and drop it in Signal; you bootstrap off it from any network, any privacy setting, no beacon. Nobody is ever stuck — worst case is "paste one link." ## Core primitives to build ### P1 — Persistent identity (+ regenerate) — ✅ DONE 2026-06-15 - **Foundation (`d157d78`):** `src/identity.rs` (`load_or_create` / `regenerate` / `save` over a `0600` hex key file at `~/.config/peerspeak/identity.key`, atomic write, malformed = hard error, path-injectable fs seam + 8 unit tests incl. create/persist/regenerate/perms). Core loop loads the stable key instead of `generate()`, falling back to ephemeral only if the file can't be read/written. Key file is raw hex (a secret, not structured config). - **Regenerate + degraded warning (`a99c789`):** `UiEvent::IdentityStatus { node_id, persisted, error }` (startup + after regenerate); `CoreCommand::RegenerateIdentity` (mints+persists a fresh key, swaps the core's live key for the next join); Settings "Identity" section showing the permanent ID, a Regenerate button behind a destructive-action confirm modal, and a standing red "Identity not saved" banner when `persisted == false`. - **Verified:** 238 lib tests green, clippy clean, release builds. **Screenshot- verified** — Identity section, confirm modal, and the degraded banner (`chmod 000` the key → "Permission denied (os error 13)" warning; Regenerate clears it). - **Deferred (small, optional):** an always-visible degraded badge *outside* Settings (today the warning lives in the Identity section only). The relay-locatability spike (a saved `EndpointAddr` resolving by node ID without discovery while on the same relay) still wants confirming during 2-machine testing — it underpins the P4 presence story. - **Spike:** verify same-relay reachability by node ID without discovery on `iroh = 1.0.0-rc.0` (a saved `EndpointAddr` should resolve while the peer stays on the same relay). Underpins the whole presence story; do during 2-machine testing. ### P2 — Friends list — Small Persist friends = `{ node_id, display_name, last_known_addr }` (JSON store, atomic write — pattern from pixelpass `friends.rs`). Add-from-room (you met them in a gathering), local rename. No handshake required for the basic list (it's a local address book), though a lightweight mutual-add is optional polish. ### P3 — Member-issued / multi-bootstrap tickets — ✅ core DONE 2026-06-15 (`c90dcf7`) - **Member-issued ticket (DONE, non-breaking):** the ticket the UI shows/copies is now stamped with the local member's own live address + the room's topic (pure seam `PeerSpeakTicket::restamp`, +3 tests), so every member hands out a working door pointing at themselves → rooms outlive their creator. Only the *display* copy is re-stamped; the join/bootstrap `ticket_str` + A8 retain logic are untouched. 248 lib tests green. ⚠️ **2-machine field test pending** (creator leaves → a joiner's ticket still admits a newcomer). - **Deferred (both BREAKING wire changes — want daylight + 2-machine):** multi-bootstrap ticket (bundle several present members as `extra_bootstrap`, needs a ticket struct change) and tighter ticket encoding (drop JSON/base64 fat, index the relay URL; lossless ~halving). ### P4 — Friends-only idle listener + presence — pure core DONE 2026-06-15 (`a897f5c`) - **Pure protocol + policy DONE** (`src/presence.rs`, +8 tests): `ControlMsg` {Ping, Pong{room: Option}} (tagged JSON, unknown tags rejected); `should_answer(from, friends, mode)` — the friends-only + not-invisible auth gate (the anti-stranger-surface whitelist; `from` = authenticated `remote_id`); `PresenceMode` {Invisible, Normal(default), Discoverable} persisted in `AppConfig`; `interpret_pong` — defensive (sanitizes peer room name, only surfaces a joinable room if the ticket parses, never auto-joins). - **Transport DONE 2026-06-15 (`501f76a`):** `src/presence_net.rs` — bind/`probe`/ `serve` the ping→pong over ALPN `peerspeak/friends/0`; request/response per conn; `serve` authenticates `remote_id` and asks an injected `Handler` (wraps `should_answer` + builds the pong) what to reply, `None` = reveal nothing to a stranger. **Loopback integration test PASSED over real iroh endpoints** (friend gets Pong+room; stranger gets unusable reply); run with `cargo test -- --ignored presence_net`. - **Endpoint-lifecycle fork — DECIDED 2026-06-15 via a throwaway spike: option (b), a SINGLE persistent endpoint.** The spike bound two endpoints sharing one `SecretKey` (one node id), each accepting a different ALPN, and probed both from a third endpoint. Result: **every inbound connection landed on ONE endpoint** (the first-bound), and connections for the other ALPN failed at the QUIC handshake with *"error 120: peer doesn't support any known protocol"* (the connection physically reached the wrong instance, which doesn't speak that ALPN). So **one node id = exactly one reachable endpoint instance** — option (a) (a second always-on friends endpoint sharing our id) is impossible, not merely risky. This is a hard handshake-layer collision, confirmed on one machine (no cross-network leg needed; which instance "wins" is just bind-order). **⇒ build (b):** one persistent endpoint bound once at startup, hosting friends-control + gossip + audio via a single persistent `Router`; a room "join" becomes subscribe-a-gossip-topic + spawn-audio-tasks (not rebuild-everything); `NetworkMode` changes require a full endpoint rebuild (acceptable — already "applies on next join"). - **B1 (persistent network stack) — DONE + 2-machine field-verified 2026-06-15 (`fdab4e0`, merged).** A persistent `NetStack` (endpoint + gossip + `Router`) is built once at startup and reused across calls; a new persistent `AudioRouter` (`src/network/iroh_impl.rs`) delegates inbound audio links to the active session's `Shared` (`bind` on join, `clear` on leave), so the single router/endpoint outlive any room session. Join no longer rebuilds the endpoint; network-mode/identity changes rebuild the stack when idle, else defer to the next Leave/Join. - **B2 (live listener + friends ownership) — DONE + 2-machine field-verified 2026-06-15 (`1ed64cb`, merged).** Friends ownership moved into core (shared `Mutex`; malformed load → read-only, never overwrites = A16 fixed; commands + `FriendsUpdated`; GUI is a read-only mirror). Live listener = `FriendsProtocol` `ProtocolHandler` on the persistent Router for `FRIENDS_ALPN` (NOT `serve` — the router owns `accept()`; shared `exchange()` body), reply policy via `should_answer` reporting our restamped member ticket. Outbound scheduler folded into the core loop (`tokio::select!`, 60s, 3s startup delay), **fully dark while Invisible**, detached per-tick; `note_seen` auto-heal on connect; P5 UI shows online/offline/in-room+Join. Bootstrap caveat: a friend with no saved addr shows offline until one ticket-based call seeds `last_addr`. ### P5 — Recents + UI — UI status/Join + room labels DONE; recents + add-from-room PENDING Friends-list UI with status (online / offline / in-room + Join) + Invisible/Normal/ Discoverable controls — **DONE (B2)**; the Friends panel + presence picker moved to the **home screen** 2026-06-16 (`173585f`/`ddc78f1`/`be42941`). **Cosmetic room labels — DONE 2026-06-16 (`4227ecc`):** an optional "Room name" field on Create mints a ticket carrying the label (`PeerSpeakTicket.name`, `#[serde(default)]` → backward/ forward compatible; `restamp` preserves it, `label_of` reads it); every member sets `current_room.name` from the ticket → presence reports "in " + the room header shows it. Sanitized via `sanitize_name` on mint + display. ⚠️ pure seam unit-tested + home field screenshot-verified; in-room header + friend-side "in " presence need a live/2-machine confirm. **STILL pending:** local **recents** list (cosmetic room tags — now that labels exist they'd be meaningful) + an **add-friend-from-room** button. ### P6 — Opt-in discovery — Small Wire the *discoverable* state to n0 DNS publish (default off, time-boxed). Lookup path for finding a discoverable friend whose saved address went stale. ### P7 — Security review + 2-machine field test — Small–Medium Surface: the friends-only listener (confirm non-friends are truly dropped pre-any state change; rate-limit pings), tickets from friends (validate defensively, no auto-join), the discovery publish (only when toggled, ideally auto-expiring). `cargo audit` (JSON store → no new deps expected). Field test on dopedart. ## The connect flow (the user's scenario, end to end) 1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled "HangOut." 2. You open peerspeak → it pings your friends → X answers (you have X's address; X is on a reachable relay) and reports "in HangOut, here's a ticket." 3. Your friends list shows **X · HangOut [Join]**. Click → bootstrap off X's ticket → you're in. Gossip refreshes everyone's saved addresses. 4. If X had moved to an unreachable network: X either flips on *discoverable*, or drops a fresh ticket in Signal — the manual floor. Once you connect once, X's new address auto-saves for next time. ## Build order & effort **P1 (identity + spike) first** — everything depends on it. Then **P2 (friends) → P3 (tickets) → P4 (listener/presence)** is the spine that makes the friends list live. **P5 (recents/UI)** alongside P4. **P6 (discovery)** and **P7 (security/field test)** last. Rough total: **~3–4 focused sessions.** No always-on stranger-facing surface, no default beacon. ## Open decisions (small, remaining) 1. **Mutual friend-add handshake** (accept/decline) vs a purely local add-by-id? The local list works alone; a handshake is polish + prevents one-sided "friends." Lean: start local, add handshake later if wanted. 2. **Ticket bundle size** for multi-bootstrap (1 = today; more = robust, longer). 3. **Discovery time-box default** (e.g. 30 min) vs sticky-until-off. ## Cross-references - Ticket type / multi-bootstrap seam: `src/network/mod.rs:81`, `RoomState::join` `extra_bootstrap` (A8). - Identity mint point: `src/core/mod.rs:421`. - Presence carries `addr` (auto-heal source): `PeerState.addr`, re-announce on NeighborUp `src/network/gossip.rs:315,344`. - Reusable pixelpass code: `~/git/butter/pixelpass/src/common/{identity,friends}.rs` (port the store + identity; the always-on control plane is NOT ported). --- ## Earlier directions (superseded — kept for reference) **Heavyweight control-plane cut (decided against 2026-06-15).** An always-on control-plane endpoint reachable by *strangers* + a presence beacon (n0 DNS) + porting pixelpass's full friends/control stack. Dropped: it needs a persistent *publicly* reachable identity, a stranger-facing listener (spam/DoS surface), and a default phone-home beacon — all of which the friends-only listener + opt-in discovery avoid. The user's key point: out-of-room *delivery/presence* is already done better by Signal/OS notifications, so delegate it. **Room-centric cut (folded into the friends-first model 2026-06-15).** Durable named rooms (`sha256(name) → topic`), favorite-room persistence, empty-room reachability. Dropped because it fought the serverless reality (an empty room has no one to bootstrap from) and the user reframed rooms as **ephemeral cosmetic labels** — the durable thing is the friends list, not the room. The member-issued ticket and the cosmetic name-tag survive from this cut; the persistence machinery does not. --- ## 2-machine field test — 2026-06-15 (desktop ↔ dopedart, build `c5df7a3`) — ALL PASSED ✅ - **P1 persistent identity:** node ID stable across app restarts on both machines. - **Call regression:** two-way audio works with persistent identity (no break). - **P3 member-issued ticket ("rooms outlive their creator"):** both machines in a room → dopedart copies ITS ticket → desktop leaves → desktop joins via dopedart's ticket → reconnects. Confirms a joiner's ticket is a working self-door. **Clears the P3 field-test that was pending.** - **Friends add (storage):** adding a friend by node id persists on both ends (live status still pending P4). - dopedart resynced to `c5df7a3` (sha `7541773d…`); old binary at `~/peerspeak.bak.20260615`.