docs: scope contract for contacts + room-invite notifications (W7)

Bank the W7 investigation as a scope contract. Key finding: pixelpass's
friends/control/identity code ports near-verbatim (same iroh 1.0.0-rc.0),
but PeerSpeak's endpoint is room-scoped (built in Join, torn down on Leave)
with a fresh identity each launch — so the real work is a new always-on
control-plane endpoint + persistent identity, not the friends list. Phased
plan (0 identity / 1 control plane / 2 store+handshake / 3 drawer UI /
4 security+field-test), ~4 sessions, with 4 open decisions that block build
(discovery-vs-privacy being the big one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:27:56 -04:00
co-authored by Claude Opus 4.8
parent 7e75ae31af
commit 0af2af2dd9
+138
View File
@@ -0,0 +1,138 @@
# Contacts list + room-invite notifications (W7) — 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
`~/Documents/handoff-docs/Gemini/peerspeak/wishlist-handoff.md`.
## Goal
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).
## The decisive architectural finding (read this first)
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:
- **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.
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).
## Reusable prior art (pixelpass — `~/git/butter/pixelpass/`)
Field-verified + merged (`04bc0a8` + audit `6d0bf99`/`cfc4800`). Same stack, same
iroh version → API-compatible.
| 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. |
## Locked design decisions
*(none yet — see "Open decisions" below; the user must answer the four before build)*
## Adaptations from pixelpass's model
- 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.
## Phases
### 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).
### 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.
### 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`).
### 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`).
### 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 (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?
## 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).
## 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`).