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

190 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
### Layer 1 — One-click invite sharing (deep links + QR + file) — **SmallMedium**
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.
## Recommended build order
**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.