Files
peerspeak/docs/contacts-plan.md
T
molluskandClaude Opus 4.8 22f0eed94d feat(w7 p6): opt-in n0 DNS discovery for Discoverable presence
Wire the Discoverable presence posture to n0 DNS publish/lookup, the last
core piece of W7 (friends-first contacts). When a friend moves networks and
their saved address goes stale, they flip Discoverable to publish their
current address; everyone else resolves it by node id. Asymmetric: only the
mover publishes.

- src/discovery.rs (pure seam, +3 tests): lookup_plan(network_mode, want_publish)
  -> LookupPlan { resolver, publisher }. Relay-capable modes always resolve and
  publish only when Discoverable; DirectOnly (the explicit no-server posture)
  gets neither, overriding the toggle. DISCOVERY_TIMEBOX = 30 min.
- apply_discovery (core edge): clears + reinstalls the bound endpoint's
  address-lookup services at runtime (no endpoint rebuild). memory-lookup always;
  n0 PkarrResolver + DnsAddressLookup when resolver; PkarrPublisher when publisher.
  Toggling publish off drops the publisher (republish task ends; TTL-30s record
  expires). build_net_stack now binds uniformly with Minimal + per-mode relay and
  installs discovery via apply_discovery (drops the per-mode presets::N0 build).
- Toggle + time-box: SetPresenceMode re-applies discovery and arms/cancels a
  discovery_deadline; a select! branch fires at the deadline -> revert to Normal,
  stop publishing, and emit UiEvent::PresenceModeReverted so the GUI mirrors and
  persists it. Re-selecting Discoverable restarts the clock.

Decisions (user, 2026-06-16): 30-min auto-revert (not sticky); resolver always
on in relay-capable modes so a stationary friend in Normal can look up a mover.

266 lib tests green, clippy clean (--all-targets). Runtime smoke-tested: the new
Minimal+apply_discovery path binds and runs with no error/panic for both Normal
and Discoverable startup postures. Cross-network publish->lookup and the live
30-min revert still want a 2-machine field test (P7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:24:46 -04:00

20 KiB
Raw Blame History

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<FriendStore>; 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 — DONE (UI status/Join, room labels, add-from-room, recents)

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. Add-friend-from-room — DONE 2026-06-16 (fb17fd1): each participant card has a star — clickable ☆ adds that peer (pulling their live presence name + addr so they're reachable immediately, unlike a bare add-by-id), gold ★ once they're already a friend; hidden while friends are read-only. AddFriendFromRoom msg. ⚠️ star + click want a live 2-machine confirm (needs a peer in the room). Recents — DONE 2026-06-16: a purely-local, most-recent-first list of joined rooms (src/recents.rsRecent {name,ticket,joined_at}, push_recent de-dupes by topic_id via the new PeerSpeakTicket::topic_of, caps at RECENTS_MAX=12; relative_time for "5m ago"; 6 unit tests). Persisted in AppConfig.recents (#[serde(default)], back-compat). Recorded on RoomJoined (label via label_of), rendered as a "Recent rooms" block in connect_card (each entry = label/"Untitled room"

  • relative time → JoinRecent, plus a ✕ → RemoveRecent); only shown when non-empty. Rejoin is best-effort (works only while the room is still live + reachable through the stored ticket — reliability is P6/the member-ticket floor, not this list). Screenshot-verified (seeded config → 3 recents render with correct ages + fallback).

P6 — Opt-in discovery — DONE 2026-06-16

Wires the Discoverable presence state to n0 DNS publish (default off, 30-min time-boxed) plus an always-on lookup path. Decisions (user, 2026-06-16): 30-min auto-revert (not sticky); resolver always on in relay-capable modes (the stationary "looker" is usually in Normal, so lookups must work there).

  • Pure seam (src/discovery.rs, +3 tests): lookup_plan(network_mode, want_publish) → LookupPlan { resolver, publisher } — relay modes always resolve + publish only when Discoverable; DirectOnly gets neither (the explicit no-server posture overrides the toggle). DISCOVERY_TIMEBOX = 30 min.
  • iroh edge (apply_discovery in core/mod.rs): at runtime, on the bound endpoint, clear() + reinstall the address-lookup services — memory-lookup always, n0 PkarrResolver
    • DnsAddressLookup when resolver, PkarrPublisher when publisher. No endpoint rebuild — toggling publish off drops the publisher (its republish task ends; the TTL-30s record expires). build_net_stack now binds uniformly with Minimal + per-mode relay and calls apply_discovery (the old per-mode presets::N0 build is gone), seeded by the startup presence mode.
  • Toggle + time-box (core loop): SetPresenceMode re-applies discovery and arms/cancels a discovery_deadline; a tokio::select! branch fires at the deadline → revert to Normal + stop publishing + UiEvent::PresenceModeReverted so the GUI mirrors/persists it (status: "Discoverable timed out — back to Normal"). Re-selecting Discoverable restarts the clock.
  • Verified: 266 lib tests green, clippy clean (--all-targets); runtime smoke-tested — app binds + runs the new Minimal+apply_discovery path with no error/panic, both Normal and Discoverable startup postures (the PkarrPublisher build path). ⚠️ The actual cross- network publish→lookup (a friend whose saved addr went stale resolving via n0 DNS) and the live 30-min auto-revert need a 2-machine field test (P7).

P7 — Security review + 2-machine field test — SmallMedium

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: ~34 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.