docs: lock W7 friends-first model (friends = anchor, rooms = cosmetic)
Rewrite the plan around the model converged in the 2026-06-15 design session. Core shift: the friends list (stable node IDs) is the durable anchor; rooms become ephemeral cosmetic labels, not addressable places. Locked: persistent identity default-on + Settings regenerate; friends-only idle listener (answers pings only from friends via remote_id, from a saved address, no presence beacon); presence axis invisible/normal/discoverable with discovery opt-in default-off and asymmetric (only the friend who moves networks publishes); silent gossip-driven address auto-heal; and a universal floor of hand-shared member-issued tickets that always connect. Supersedes the heavyweight control-plane cut and the room-centric durable-room cut; both retained at the bottom with reasons. ~3-4 sessions, build order P1 identity -> P7 security/field-test. iroh does not force rolling IDs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+146
-164
@@ -1,189 +1,171 @@
|
||||
# Durable rooms + frictionless invites (W7, redesigned) — plan / scope contract
|
||||
# Friends-first contacts + ephemeral rooms (W7, redesigned) — plan / scope contract
|
||||
|
||||
**Status:** SCOPED, not started. **Redesigned 2026-06-15** (brainstorm with the
|
||||
user) away from the original "port pixelpass's friends/control-plane" approach.
|
||||
This is the scope contract; update it as phases land. Source: wishlist **W7** in
|
||||
`~/Documents/handoff-docs/Gemini/peerspeak/wishlist-handoff.md`.
|
||||
**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 the original W7 design.** The first cut (an always-on
|
||||
> control-plane endpoint + persistent reachable identity + n0 DNS discovery) is
|
||||
> recorded at the bottom under "Heavyweight path (decided against)" with the
|
||||
> reasons. The redesign below delivers the same *user-visible* goals — save/reuse
|
||||
> rooms, share an invite in one click — **without** an always-on listener, a
|
||||
> presence beacon, or any phone-home, per [[user-security-preferences]] /
|
||||
> [[user-telemetry-preference]].
|
||||
> **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.
|
||||
|
||||
## Goals (user-visible)
|
||||
## The model in one paragraph
|
||||
|
||||
1. **Reuse a room** without re-exchanging a fresh ticket every time.
|
||||
2. **Share an invite in one click** through tools people already trust (Signal,
|
||||
Telegram, email, SMS) — peerspeak makes the invite trivially shareable and
|
||||
clickable; it does NOT try to be a messenger.
|
||||
3. **Optionally** bookmark favorite rooms and see how busy one is before joining.
|
||||
**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.
|
||||
|
||||
Explicitly **out of scope** (dropped from the old W7, with intent): in-app
|
||||
"who's online right now" presence, a bespoke in-app invite inbox/drawer, and any
|
||||
mechanism that requires peerspeak to be reachable while idle. Those are exactly
|
||||
the parts that cost the security/privacy budget, and the user's position is that
|
||||
Signal/OS notifications already do them better.
|
||||
## Locked design decisions (user, 2026-06-15)
|
||||
|
||||
## The reframe that makes this cheap (the spine)
|
||||
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."
|
||||
|
||||
**Established facts (verified in-code this session):**
|
||||
- A ticket is `PeerSpeakTicket { host_addr: EndpointAddr, topic_id: [u8;32] }`
|
||||
(`src/network/mod.rs:81`). It carries **one** bootstrap address (the creator's)
|
||||
plus the room's gossip topic id.
|
||||
- The room's durable identity is the **`topic_id`** — every member is subscribed
|
||||
to it. The host address is just *one door* into that topic.
|
||||
- `RoomState::join` already accepts **`extra_bootstrap: Vec<EndpointAddr>`** (added
|
||||
by the A8 fix) — multiple dial targets are already supported.
|
||||
- Identity (`SecretKey`) is minted **once per process** (`src/core/mod.rs:421`),
|
||||
so a node id is stable for a running session but **regenerated on every fresh
|
||||
launch**. Retained peers (A8 `known_peers`) live **in memory only**.
|
||||
- Consequence (the dead-end this redesign fixes): a ticket's lifespan equals **the
|
||||
creator's address staying live**, NOT room occupancy. Host goes offline → the
|
||||
ticket can't bootstrap anyone, even if the room is full, because it only ever
|
||||
carried the host's address.
|
||||
## Core primitives to build
|
||||
|
||||
**The spine — member-issued, multi-bootstrap tickets.** Today only the founder
|
||||
mints a ticket. Instead, let **any current member** mint a fresh, fully valid
|
||||
ticket on demand, stamped with **their own live address + the room's existing
|
||||
`topic_id`** (both already in hand inside a live session). Every member is a door.
|
||||
### P1 — Persistent identity (+ regenerate) — Small, foundational
|
||||
Port pixelpass's `identity.rs` (~140 lines, near-verbatim) → stable key file (JSON,
|
||||
not TOML). Swap `core/mod.rs:421`. Settings regenerate control (confirm + warn).
|
||||
**Spike first:** 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). This underpins the whole presence story.
|
||||
|
||||
This alone makes **rooms outlive their creator**: a room is reachable as long as
|
||||
*anyone* inside can hand out a ticket. Extended to carry *several* present members
|
||||
as bootstrap candidates (the `Vec` is already there), the room loses its single
|
||||
point of failure — a newcomer dials all listed members and connects to whoever's
|
||||
up.
|
||||
### 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.
|
||||
|
||||
**Cost: almost nothing.** The ticket type and the multi-bootstrap join path
|
||||
already exist; a live member already holds the `topic_id` and `endpoint.addr()`.
|
||||
"Export this room as an invite" is a UI button + a `CoreCommand` that reads
|
||||
existing session state and builds a ticket. No new networking, no new identity, no
|
||||
discovery. It stays inside today's trust model (a ticket already grants a full
|
||||
join; members can already forward the founder's ticket — this just hands out a
|
||||
*fresher* pointer).
|
||||
### 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).
|
||||
|
||||
## Layers (each optional, stack as desired)
|
||||
### 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.
|
||||
|
||||
### Layer 0 — Member-issued / multi-bootstrap tickets — **the spine, Small**
|
||||
As above. Delivers "rooms outlive their creator" and redundant bootstrap with no
|
||||
identity/discovery changes.
|
||||
### 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.
|
||||
|
||||
### Layer 1 — One-click invite sharing (deep links + QR + file) — **Small–Medium**
|
||||
Make a ticket trivially shareable through existing channels:
|
||||
- **`peerspeak://join/<ticket>` URI scheme.** Register via a `.desktop` file with
|
||||
`MimeType=x-scheme-handler/peerspeak;` + `xdg-mime default` (Plasma + bare X11
|
||||
both honor it). Paste the link into Signal/email; the recipient clicks → peerspeak
|
||||
opens with a **"Join room? [preview] [Join] [Cancel]"** prompt. **Never
|
||||
auto-join** (the one real new surface; same defensive ticket validation we
|
||||
already do).
|
||||
- **QR code** of the same URI (pure-Rust `qrcode`, no network — vet the crate) for
|
||||
in-person / screen-to-phone handoff.
|
||||
- **`.peerspeak` invite file** + the same MimeType association (double-click →
|
||||
open + prompt). Sendable over any transport, incl. the user's `croc`.
|
||||
- **"Invite via…"** buttons: `xdg-open "mailto:?body=peerspeak://join/<ticket>"`.
|
||||
### 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.
|
||||
|
||||
### Layer 2 — Persistent identity — **Small** (enables save-and-return)
|
||||
Port pixelpass's `identity.rs` (~140 lines, near-verbatim) → a stable key file
|
||||
(JSON, not TOML — see decisions); swap `core/mod.rs:421` `generate()` for
|
||||
`load_or_create()`. **Only needed to make a *saved* member-address survive that
|
||||
member's restart** (i.e. rejoin a favorite room tomorrow). Layer 0 works without
|
||||
it. Side effect: the gossip-signing key (S2) becomes stable across launches →
|
||||
minor cross-room linkability — note it.
|
||||
### 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.
|
||||
|
||||
### Layer 3 — Name-derived rooms — **Small–Medium** (memorable identity)
|
||||
`hash(room_name [+ passphrase]) → topic_id`, so the room's *identity* is a
|
||||
memorable shared word instead of a 32-byte blob, host-independent. Note the
|
||||
entropy reality (established this session): a name can carry the **topic** (it
|
||||
*derives* it) but **cannot contain the host key/address** — that's ~32 bytes of
|
||||
irreducible random data. So a named room still needs Layer 0 for the *address*
|
||||
half (who to bootstrap from). Name = identity; member-ticket = reachability.
|
||||
Security: a guessable name = joinable by anyone → treat the name as a secret or
|
||||
require a passphrase in the hash.
|
||||
## 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.
|
||||
|
||||
### Layer 4 — Favorite rooms (bookmarks) — **Small**
|
||||
Persist a list of saved rooms = `{ friendly_name, topic_id, last-known member
|
||||
addresses }`. On "rejoin," dial the saved member addresses as `extra_bootstrap`;
|
||||
if any one is online, gossip pulls you back into the rest. Refresh the saved
|
||||
addresses every time you're in the room. Reliable only in proportion to Layer 2
|
||||
(stable member ids) + how many addresses you retain. Dials **only on explicit
|
||||
rejoin** — no always-on endpoint, no beacon.
|
||||
## 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.
|
||||
|
||||
### Layer 5 — Silent occupancy peek — **Small** (optional, flagged)
|
||||
"How busy is this room?" without fully joining. Mechanism (verified in-code): a
|
||||
background joiner enters the gossip mesh but **never broadcasts its own
|
||||
`Announce`**, so it doesn't appear in anyone's roster (roster is Announce-driven,
|
||||
`gossip.rs:315`); existing members re-announce on `NeighborUp` (`gossip.rs:344`),
|
||||
so the peeker collects the roster passively, reduces it to `peers.len()`, and
|
||||
leaves.
|
||||
- **Privacy contract (must hold):** receives identities transiently (unavoidable —
|
||||
the count *is* the roster), but **retains only the integer count**; never
|
||||
displays/logs/persists a name or address. One-liner in code: "receives
|
||||
identities transiently, retains only the count."
|
||||
- **Honest caveats:** "invisible" is UI-level, not wire-level — members' gossip
|
||||
logs a `NeighborUp` (acceptable per user). Must linger briefly or it undercounts.
|
||||
- **Consent note:** shipping this means anyone with a ticket can count a room
|
||||
without occupants' knowledge — a deliberate break from today's symmetric model
|
||||
(to see in, you announce yourself). Inside the app's trust boundary (a ticket
|
||||
already grants a full join), but name it as a conscious choice.
|
||||
|
||||
## Recommended build order
|
||||
|
||||
**Layer 0 first** (the spine; unblocks "rooms outlive their creator" immediately),
|
||||
then **Layer 1** (the one-click-share headline). Layers 2–5 are independent
|
||||
add-ons to pick up by appetite. The serverless persistent-room story = **0 + 2 + 3
|
||||
+ 4**. The "polished invite" UX = always keep a fresh, multi-bootstrap "Invite to
|
||||
this room" ticket ready to copy while in a session.
|
||||
|
||||
## Open decisions (user)
|
||||
|
||||
1. **Persistent identity (Layer 2):** adopt it? It's the gate for save-and-return
|
||||
rooms, and it makes the gossip-signing key stable across launches (minor
|
||||
linkability). (Layer 0/1 don't need it.)
|
||||
2. **Multi-bootstrap ticket size:** how many present members to bundle as bootstrap
|
||||
(1 = today's size; more = more robust but a longer ticket). Also: tighten the
|
||||
ticket encoding (drop JSON/base64 fat, index the relay URL) — lossless, roughly
|
||||
halves the string; worth doing alongside.
|
||||
3. **Storage format:** JSON (no new dep; matches `serde_json` everywhere) vs
|
||||
pixelpass's TOML. Default: JSON.
|
||||
4. **Ship Layer 5 (silent peek)** at all, given the consent tradeoff? If yes,
|
||||
confirm the "retain only the count" contract.
|
||||
5. **Named rooms (Layer 3):** require a passphrase in the hash, or treat the bare
|
||||
name as the secret?
|
||||
|
||||
## Effort summary
|
||||
|
||||
Spine + headline (Layers 0 + 1): **Small–Medium, ~1–2 sessions.** Full serverless
|
||||
persistent-room story (+ 2, 3, 4): **+1–2 sessions.** Dramatically lighter than the
|
||||
heavyweight path, and it adds **no always-on surface, no discovery beacon, no new
|
||||
network-reachable listener.**
|
||||
## 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 join seam: `src/network/mod.rs:81`, `RoomState::join`
|
||||
- Ticket type / multi-bootstrap seam: `src/network/mod.rs:81`, `RoomState::join`
|
||||
`extra_bootstrap` (A8).
|
||||
- Identity mint point to make persistent: `src/core/mod.rs:421`.
|
||||
- Roster = Announce-driven; re-announce on NeighborUp: `src/network/gossip.rs:315,344`.
|
||||
- Notification precedent: `src/notify.rs` + W6 (`7e75ae3`).
|
||||
- Reusable pixelpass code (Layer 2 identity port): `~/git/butter/pixelpass/src/common/identity.rs`.
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## Heavyweight path (decided against 2026-06-15 — kept for reference)
|
||||
## Earlier directions (superseded — kept for reference)
|
||||
|
||||
The original W7 cut: a **persistent identity** + an **always-on control-plane
|
||||
endpoint** (`peerspeak/ctrl/0`, online whenever the app runs, separate from any
|
||||
session) + **n0 DNS discovery** to dial idle contacts by stable id, porting
|
||||
pixelpass's `identity.rs`/`control.rs`/`friends.rs` (same `iroh = 1.0.0-rc.0`, so
|
||||
the protocol ports near-verbatim). It would have added an in-app contacts list,
|
||||
friend-request handshake, a notification drawer, and out-of-room invite delivery.
|
||||
**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.
|
||||
|
||||
**Why dropped:** it requires three things the user specifically wants to avoid —
|
||||
a persistent reachable identity (cross-room linkable), an always-on listening
|
||||
endpoint (standing spam/DoS surface), and an n0 DNS **presence beacon**
|
||||
(phone-home). The user's key observation: out-of-room **delivery + presence is
|
||||
already solved, better, by Signal/Telegram/OS notifications** (which also work
|
||||
when peerspeak is closed — the bespoke drawer can't). So peerspeak should make the
|
||||
invite trivially shareable and **delegate identity/presence/delivery** to those
|
||||
tools, which is what the redesign above does. The pixelpass `identity.rs` port
|
||||
survives as the optional Layer 2 only.
|
||||
**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.
|
||||
|
||||
Reference in New Issue
Block a user