# 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`** (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) — **Small–Medium** Make a ticket trivially shareable through existing channels: - **`peerspeak://join/` 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/"`. ### 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) 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.** ## 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.