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>
This commit is contained in:
2026-06-15 01:00:11 -04:00
co-authored by Claude Opus 4.8
parent 0af2af2dd9
commit b6984dda59
+162 -111
View File
@@ -1,138 +1,189 @@
# Contacts list + room-invite notifications (W7) — plan / scope contract
# Durable rooms + frictionless invites (W7, redesigned) — plan / scope contract
**Status:** SCOPED, not started (assessment 2026-06-14 by the senior). This is the
scope contract; update it as phases land. Source: wishlist item **W7** in
**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`.
## Goal
> **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]].
Save known peers and make joining their rooms frictionless:
- A **contacts list** — save known peers (by stable id + name) so you don't
re-exchange tickets every time.
- A **notification drawer** where incoming **room invites** appear out-of-room.
- Each invite carries the room **ticket in a button**; clicking it **auto-fills
the join-room text box** (one-click join — never copy/paste a ticket).
## Goals (user-visible)
## The decisive architectural finding (read this first)
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 wishlist says "port pixelpass's friends-list/notification system." The
*protocol* code ports nearly verbatim — **both projects are on the exact same
`iroh = "1.0.0-rc.0"`** — but the *integration* is genuinely new architecture for
PeerSpeak, because the two apps have opposite networking lifecycles:
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.
- **pixelpass** runs a **persistent identity** + an **always-on control-plane
endpoint** (bound at GUI start, online the whole time the app is open),
separate from any video session. The friends system rides that.
- **PeerSpeak** has **no endpoint at all when not in a call**: `core/mod.rs:500`
(the `Join` handler) builds the endpoint and tears it down on `Leave`, and
`core/mod.rs:421` mints a **fresh random `SecretKey::generate()` every launch**,
so a peer's `EndpointId` changes each run and nothing is listening while idle.
## The reframe that makes this cheap (the spine)
W7 fundamentally needs to reach a contact who is **not in a room** — which
PeerSpeak currently cannot do at all. **That gap, not the friends list, is the
real work.** Porting the proven protocol/store is ~60% of the effort (low risk);
the new 40% is the always-on control plane (Phase 1) and the all-new drawer/
contacts UI (Phase 3).
**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.
## Reusable prior art (pixelpass — `~/git/butter/pixelpass/`)
**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.
Field-verified + merged (`04bc0a8` + audit `6d0bf99`/`cfc4800`). Same stack, same
iroh version → API-compatible.
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.
| pixelpass file | lines | reuse for PeerSpeak |
| --- | --- | --- |
| `src/common/identity.rs` | ~140 | **near-verbatim** — persistent ed25519 key, `load_or_create()`, atomic 0600 write. |
| `src/common/control.rs` | ~260 | **protocol reusable as-is** — one-message-per-connection JSON over a bi-stream, **authenticated sender via `conn.remote_id()`** (not spoofable), one-byte ACK = delivery+parse signal, `serve()` accept loop → `mpsc::Receiver<Inbound>`. `ControlMsg` variants `Hello`/`FriendRequest`/`FriendAccept`/`FriendDecline`/`ShareCode`. |
| `src/common/friends.rs` | ~330 | **near-verbatim**`FriendStore`, mutual-consent `FriendState` (`PendingOutgoing`/`PendingIncoming`/`Accepted`), atomic write, keyed by stable `EndpointId`. |
| `src/common/alpn.rs` | — | `CONTROL_ALPN = b"pixelpass/ctrl/0"` pattern → mint `b"peerspeak/ctrl/0"`. |
| `src/gui/presence.rs` | ~299 | **reference, not copy** — service orchestration + online/presence indicators; PeerSpeak's iced UI differs, so adapt. |
**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).
## Locked design decisions
## Layers (each optional, stack as desired)
*(none yet — see "Open decisions" below; the user must answer the four before build)*
### 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.
## Adaptations from pixelpass's model
### 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>"`.
- pixelpass's `ControlMsg::ShareCode { name, ticket }` (push a video share-code to a
friend) maps directly onto PeerSpeak's need: a **room invite** carrying a
`PeerSpeakTicket` (`src/network/mod.rs:81`). Likely rename to `RoomInvite`.
- Store contacts/identity as **JSON** (PeerSpeak already uses `serde_json`
everywhere; pixelpass uses `toml`) to avoid adding the `toml` dependency — see
Open decision #3.
### 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.
## Phases
### 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.
### Phase 0 — Persistent identity · Small (~1 session)
Port `identity.rs``~/.config/peerspeak/identity.key` (0600). Swap
`core/mod.rs:421` `SecretKey::generate()` for `load_or_create()`. Unit-testable
(hex round-trip tests come with it).
- **Side effect:** the gossip-signing key (security S2) becomes stable across
launches → minor cross-room linkability. Note it (Open decision #2).
### 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.
### Phase 1 — Always-on control plane · Large (the crux, ~12 sessions)
Stand up a **second, long-lived endpoint** on `peerspeak/ctrl/0`, online whenever
the app runs, owned by the core loop **outside** the `Join`/`Leave` session
lifecycle (today *all* networking lives inside `ActiveSession`).
- Port `control.rs` (protocol as-is). Wire its inbound `mpsc::Receiver<Inbound>`
into the core→UI event flow (new `UiEvent` variants + `CoreCommand`s for
send-request / accept / decline / invite).
- **Discovery:** the control endpoint likely needs **`presets::N0` (n0 DNS
discovery)** to be reachable by bare id while idle, even if the call posture
stays `RelayNoDiscovery`. This is Open decision #1.
- This is net-new long-lived networking; the bulk of W7's risk lives here.
### 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.
### Phase 2 — Contacts store + friend handshake · Medium (~1 session)
Port `friends.rs` (as JSON). Wire `FriendRequest`/`Accept`/`Decline` through the
control plane and core. Persist `~/.config/peerspeak/contacts.json`. Pure store =
unit-testable. Implement the `RoomInvite` send/receive path (carry a
`PeerSpeakTicket`).
## Recommended build order
### Phase 3 — UI: contacts list + notification drawer + one-click join · Medium (~1 session)
All-new iced UI (PeerSpeak has **no drawer/notification panel today** — the only
"drawer" is the chat layout):
- **Contacts view** — add by id, see online/pending status, accept/decline.
- **Notification drawer** — top-right popup, **reuse the layout-switcher popup
pattern** (`SelectRoomLayout` flow) in the always-visible top-right cluster;
incoming invites listed with a one-click-join button.
- **One-click join** — the button routes the carried ticket into the join-room
text-input state (validate, then pre-fill; do **not** auto-join).
- Online/presence indicators driven by `ControlMsg::Hello` refreshes.
- Cross-ref **W4 avatars**: show a contact's avatar (ties into `PeerState.avatar`).
**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.
### Phase 4 — Security review + 2-machine field test · SmallMedium
New untrusted surface:
- **Unsolicited control messages from arbitrary ids** = friend-request spam / DoS
vector → need a cap / rate-limit (Open decision #4).
- **Invite-carried tickets** are untrusted even from a contact → validate
defensively before pre-fill; never auto-join.
- Sender identity is **authenticated** (`remote_id()`) — inherited from
pixelpass's design, good.
- `cargo audit` (no new deps expected if we store as JSON, not TOML).
- 2-machine field test on dopedart: add-contact both ways, request/accept,
send a room invite out-of-room, one-click join.
## Open decisions (user)
## Open decisions (the user must answer before build)
1. **Discovery vs privacy (the big one).** Reaching an idle contact by stable id
needs **n0 DNS discovery**, which conflicts with the privacy-minded
`RelayNoDiscovery` default ([[user-security-preferences]] / telemetry stance).
Proposed: run **only the control plane** on n0 discovery; keep the user's
chosen posture for the call itself. Accept?
2. **Stable identity → stable gossip-signing key** across rooms (minor
linkability). Acceptable?
3. **Dependency:** store contacts/identity as **JSON** (no new dep) rather than
pixelpass's TOML. Confirm (default: JSON).
4. **Spam control:** cap / rate-limit unsolicited friend requests from the start?
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
**MediumHigh, ~4 focused sessions.** ~60% proven low-risk port (same iroh
version); ~40% new design — the always-on control endpoint (Phase 1) and the
all-new drawer/contacts UI (Phase 3).
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
- Wishlist W7 (the request, with pixelpass-port pointer): `wishlist-handoff.md`.
- Notification system precedent (chimes): `src/notify.rs` + W6 (per-sound toggles,
`7e75ae3`).
- Top-right popup pattern to reuse: the layout switcher (`SelectRoomLayout`).
- Ticket type to carry in invites: `PeerSpeakTicket` (`src/network/mod.rs:81`).
- 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.