# 🤖 Threads & DMs — multiple conversations per instance

Status: **in progress** (branch `threads-and-dms`, testing on `test`). This builds
on `docs/invite-redesign.md` (users, device tokens, server-blind key delivery).

Today one instance = one group chat with a single group key. This adds **multiple
threads** — DMs, groups, and a personal "self" thread — within one instance,
without weakening the E2EE invariant: **the server still only ever stores
ciphertext and key-blobs it cannot open.**

## Model

- **Instance** = a trust boundary: a roster of people + an admin + a subdomain.
  A new trust circle (e.g. a kid's friends) is a *separate instance*, not a
  thread here. Cross-instance contact is intentionally impossible. The instance
  name is the app/Home-Screen name.
- **Thread** = one conversation. The same primitive at every size:
  - **1 member** → "self" (notes), header shows "Notes".
  - **2 members** → a DM, header shows **the other person's name**.
  - **N members** → a group, header shows the group's (encrypted) name.
  DM-vs-group is a UI distinction, not a different mechanism.
- **Roster** = instance-level directory of `{user_id, label, public_key}`. Open
  (high trust): every member can see it and start threads with anyone — no
  introductions. The member list lives here, **not** inside an "everyone" group
  (we don't auto-create one).

## Crypto

- **User keypair** (ECDH P-256), one per person. Public key lives in the roster;
  private key lives in the browser session and is shippable (pkcs8) so it can
  ride an add-device link. Chosen over device keypairs for simpler multi-device
  onboarding; lchat already ships key material via the invite fragment, so user
  keys add no new risk class. (Trade-off: lost device → rotate the user key +
  affected thread keys, rather than dropping one device.)
- **Thread key** — a per-thread AES-GCM-256 key. Messages, attachments, and the
  thread's *name* are encrypted with it, exactly as the single group key works
  today.
- **Wrapping** — a thread key is delivered to a member by wrapping it to their
  user public key (ECIES: ephemeral ECDH → HKDF-SHA-256 → AES-GCM). The server
  stores one wrapped blob per (thread, member) and **cannot unwrap any of them**.
  `crypto.js`: `generateUserKeypair`, `wrapThreadKey`, `unwrapThreadKey`.
- **No instance-wide key.** Each thread has its own key; nobody (not even the
  admin) holds a key that reads everything. The **admin token is purely roster
  management** — it is not, and never was, a decryption key. The admin reads only
  threads they're a member of.

### What the server sees (accepted metadata posture)

Routing requires the server to know the **social graph**: thread membership, who
sent each message, message timing/size, and roster display names. It never sees
message content, attachment bytes, or thread/group names. Hiding the graph too
(sealed-sender / private membership) is explicitly out of scope.

## Data model (additive migrations)

- `users(id, label, created_at, public_key)` — `public_key` added; registered on
  first join. (Roster = this table.)
- `threads(id, kind, created_at, name_blob)` — `kind` ∈ self|dm|group;
  `name_blob` is the client-encrypted group name (null for dm/self).
- `thread_members(thread_id, user_id, created_at)` — membership (the graph).
- `thread_keys(thread_id, user_id, wrapped)` — the thread key wrapped to each
  member's user public key. One row per member; server-blind.
- `messages` gains `thread_id`; reactions key off `(thread_id, message_id, …)`.
- `dm_pairs(user_a, user_b, thread_id)` — canonical dedupe so there's exactly one
  DM per pair (`user_a < user_b`).

## Key flows

- **Register pubkey** — on first load with a session, the browser generates the
  user keypair (if absent), stores the private key in the session, and `PUT`s the
  public key to the roster.
- **Start a DM** — pick someone from the roster; if no `dm_pairs` row exists,
  generate a thread key, wrap it to *both* user pubkeys, create the thread +
  members + dm_pair. Otherwise open the existing one.
- **Create a group** — name it, pick members; generate a thread key, wrap it to
  each member's pubkey, store name_blob. Creator is owner.
- **Add a member** (existing instance member) — owner/any member fetches the
  invitee's roster pubkey, wraps the current thread key to it, adds the row.
- **Send / receive** — WS carries `{thread_id, blob}`; the server fans out only
  to that thread's members and gates reads by membership. Client decrypts with
  the thread key it unwrapped (and caches).
- **Onboarding (new person → instance)** — admin-only. The invite, as today,
  delivers the **user keypair** (generated by the inviter or the joiner) and
  **seeds a DM with the inviter** (its thread key wrapped into the invite). The
  joiner lands with one working conversation and a roster.
- **Add a device** (same person) — the existing device bundles the **user private
  key** into the add-device link (under `HKDF(T)`); the new device unwraps it and
  can then open every thread the user belongs to (their wrapped blobs are on the
  server). No per-thread re-wrap needed — the win of user keypairs.

### Deferred (designed, not in v1)

- **Remove a member / rekey** — admin removes from the instance, or owner from a
  group: rotate the thread key, re-wrap to the remaining members, so the removed
  party can't read *future* messages (past messages they already saw). Needs a UI
  + a rotation endpoint.
- **Group join links** (instance-members-only, device-token-gated), mute,
  restricted discovery modes, production migration of the 5 live instances.

## UI

- **Desktop**: master-detail — thread list (left), open thread (right). The list
  shows DMs by counterpart name, groups by name, newest activity first.
- **Mobile**: single pane that navigates list → thread → back (and a "people"
  view for starting a new chat from the roster).
- **Thread header**: DM = the other person's name; group = its name; self =
  "Notes". The instance name stays the app/title-bar name.
- A **"new chat"** action opens the roster picker (DM = one person, group =
  several + a name).

## Capabilities (default — safe-for-kids)

| Action | Who |
|---|---|
| Create group / start DM / add **existing** instance members | any member |
| Rename a group / remove a group member | the group's owner |
| Add a **new** person to the instance (roster) | admin only |
| Remove a person from the instance (+ rekey) | admin only |

The admin guards the trust boundary; members move freely inside it. Restricted
discovery (e.g. kids can only DM parents) is a later opt-in; default is open.

## Migration (live instances — later)

The existing single chat becomes one **group thread** keyed with the current
group key: create a `threads` row, attach all current members, wrap the existing
group key to each member's pubkey (their browser does this on first load, since
it already holds the group key from its session). Legacy `#k=` links and device
tokens keep working. History is preserved in that thread. Built and run at merge
time, after the model is proven on a fresh `test`.
