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>
192 lines
12 KiB
Markdown
192 lines
12 KiB
Markdown
# 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)
|
||
|
||
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) — 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 + a `CoreCommand` that 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 transient `Error` →
|
||
`status_message`), an `identity_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 + `CoreCommand` path).
|
||
- **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 — 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)
|
||
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.
|