Files
peerspeak/docs/contacts-plan.md
T
molluskandClaude Opus 4.8 b6984dda59 docs: redesign W7 around member-issued/multi-bootstrap tickets
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>
2026-06-15 01:00:11 -04:00

11 KiB
Raw Blame History

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)

  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.

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

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.

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>".

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 — SmallMedium (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.

Layer 0 first (the spine; unblocks "rooms outlive their creator" immediately), then Layer 1 (the one-click-share headline). Layers 25 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): SmallMedium, ~12 sessions. Full serverless persistent-room story (+ 2, 3, 4): +12 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::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.

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.