Pivot the contacts plan away from the heavyweight control-plane design (always-on listener + persistent reachable identity + n0 DNS presence beacon) toward a serverless, no-beacon spine: member-issued, multi-bootstrap tickets. Any current member can mint a fresh ticket from their own live address + the room's existing topic_id (both already in hand; join() already takes extra_bootstrap: Vec), so rooms outlive their creator for ~no cost. Layered optional add-ons: one-click invite sharing (peerspeak:// deep links + QR + .peerspeak files, delegating delivery to Signal/email/OS), persistent identity (save-and-return), name-derived rooms, favorite-room bookmarks, and a privacy-bounded silent occupancy peek. The old heavyweight path is retained at the bottom of the doc as 'decided against', with the reasoning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
Durable rooms + frictionless invites (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.
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.
Goals (user-visible)
- Reuse a room without re-exchanging a fresh ticket every time.
- 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.
- Optionally bookmark favorite rooms and see how busy one is before joining.
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.
The reframe that makes this cheap (the spine)
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::joinalready acceptsextra_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 (A8known_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.
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.
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.
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).
Layers (each optional, stack as desired)
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.
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.desktopfile withMimeType=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. .peerspeakinvite file + the same MimeType association (double-click → open + prompt). Sendable over any transport, incl. the user'scroc.- "Invite via…" buttons:
xdg-open "mailto:?body=peerspeak://join/<ticket>".
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.
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.
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.
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)
- 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.)
- 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.
- Storage format: JSON (no new dep; matches
serde_jsoneverywhere) vs pixelpass's TOML. Default: JSON. - Ship Layer 5 (silent peek) at all, given the consent tradeoff? If yes, confirm the "retain only the count" contract.
- 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.
Cross-references
- Ticket type / multi-bootstrap join seam:
src/network/mod.rs:81,RoomState::joinextra_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.
Heavyweight path (decided against 2026-06-15 — 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.
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.