Record the persistent-identity foundation as landed (d157d78) and add the
user's requirement: surface a persistent UI warning (not just a log) when the
key can't be read/written, since running on an ephemeral fallback silently
breaks friend recognition next launch. To build with the regenerate UI slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Friends-first contacts + ephemeral rooms (W7, redesigned) — plan / scope contract
Status: SCOPED, not started. Model locked 2026-06-15 after an extended design session with the user. 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)
- Persistent identity = default foundation. Load/create a stable
SecretKeyat a key file instead ofgenerate()each launch (core/mod.rs:421). Node ID stable across launches. iroh doesn't force rolling IDs; today's behaviour is an unrevisited default. - Settings "Regenerate identity" control (confirm + warn) for a deliberate unlink / fresh start. Breaks others' saved reachability to you until re-exchange.
- Friends list keyed by node ID (+ a locally editable display name). Fully doable now that IDs are stable.
- 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.)
- Recents list = local-only saved entries (cosmetic room name + the session's topic + the member addresses seen). Purely local UI state.
- 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). - 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").
- 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.
- Saved addresses auto-heal silently. Every successful connection to a friend
carries their current
PeerState.addrover 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.) - 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) — Small, foundational
Foundation DONE (d157d78, 2026-06-15): 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 now loads the stable key instead of
generate() (falls back to ephemeral only if the file can't be read/written). The
key file is raw hex (a secret, not structured config — JSON/TOML only matters for
the friends store). 238 lib tests green, clippy clean, release builds.
Remaining in P1:
- Settings "Regenerate identity" control (confirm + warn) —
regenerate()exists; UI + aCoreCommandthat rebuilds the live endpoint with the new key still to wire. - Visual warning when the key can't be read/written (user, 2026-06-15). Today
the ephemeral fallback only logs — it must surface a persistent UI indicator,
because running unsaved is a real degraded state (your id won't survive the next
launch → friends stop recognising you). Plan: a dedicated
UiEvent(IdentityNotPersisted(reason), distinct from the transientError→status_message), anidentity_degraded: Option<String>flag in app state, and a standing badge + a fuller explainer in the identity Settings section ("Identity couldn't be saved to~/.config/peerspeak/identity.key— friends won't recognise you next launch; check disk/permissions"). Build alongside the regenerate UI slice (same identity Settings section +CoreCommandpath). - Spike: verify same-relay reachability by node ID without discovery on
iroh = 1.0.0-rc.0(a savedEndpointAddrshould 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 — Small (the ticket mechanism)
Let any current member mint a ticket from { their live addr, the session topic }
(both already in hand). Pre-exists: the type PeerSpeakTicket and RoomState::join
already takes extra_bootstrap: Vec<EndpointAddr> (A8). Optionally bundle several
present members for redundancy. Also tighten ticket encoding (drop JSON/base64 fat,
index the relay URL — lossless, ~halves the string).
P4 — Friends-only idle listener + presence — Medium (the new primitive)
A long-lived endpoint while the app is open (own ALPN, e.g. peerspeak/friends/0),
outside the room-session lifecycle, that:
- answers a ping only if
remote_id()∈ friends list; - replies with online + (if in a gathering) a fresh member-issued ticket + the cosmetic room name;
- is governed by the invisible/normal/discoverable state. On app open (and a slow rate-limited refresh), ping each friend; render their status in the friends list. If a friend reports a gathering, show a small room-name tag + Join entry; clicking bootstraps off their ticket.
P5 — Recents + UI — Medium
Local recents list (cosmetic room tags). Friends-list UI with status dots ("available" / "appears offline"), the discoverable shortcut toggle, and the join-friend's-room entries from P4. Invisible/discoverable controls in Settings.
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)
- Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled "HangOut."
- 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."
- Your friends list shows X · HangOut [Join]. Click → bootstrap off X's ticket → you're in. Gossip refreshes everyone's saved addresses.
- 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)
- 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.
- Ticket bundle size for multi-bootstrap (1 = today; more = robust, longer).
- 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::joinextra_bootstrap(A8). - Identity mint point:
src/core/mod.rs:421. - Presence carries
addr(auto-heal source):PeerState.addr, re-announce on NeighborUpsrc/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.