aboutsummaryrefslogtreecommitdiffstats
path: root/docs/meshbay-draft-v6.md
blob: a5fb8d377515592c7c360cbd4ffb4c70b90ddc31 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# MeshBay — Architecture Draft v6

> Status: **current specification.** Supersedes `meshbay-draft-v5.md`.
> **Sections not restated here are unchanged from v5**, which remains the reference for
> everything v6 does not touch — the handshake (§4), node authority (§5), the hub's role
> (§6), cryptography (§7) and the testing posture (§10) are all still v5's.
>
> v6 exists because a design discussion on 2026-08-17 settled the desktop client and, in
> doing so, changed four things v5 states: what a group's content *is*, how a person's
> devices are admitted, how authorship is established, and which shell the native client
> uses. It also records one rule v5 assumed without writing down.
>
> **Amended 2026-08-28** with three changes that post-date that discussion: the hub gains a
> runtime instance-policy store (public groups can be switched off hub-wide), group names
> become unique per owner account (`name@owner`), and chat gains link previews. See §1
> rows 11–13 and §§2.8–2.10. Wire versions at that point: MNP 0.12, MHP 0.1; packages 0.8.0.
>
> The v5 convention is carried forward and is not negotiable: **a claim in this document
> must name the adversary it holds against.** A property that holds against a passive hub
> and not an active one is written that way.

---

## 0. Reading order

| Read | For |
|---|---|
| **this document** | what is true now, and what changed from v5 |
| `meshbay-draft-v5.md` | everything v6 does not restate — still authoritative there |
| `second-review.md` | the findings (C1–C6, H1–H7, M*, L*) referenced throughout the code |
| `docs/invite-pairing-v1.md` | invitations, pairing codes, the node roster — **as built** |
| `docs/per-node-identity-v1.md` | identity keys are per node; the hub stores none |
| `docs/desktop-client-v1.md` | the desktop client in full — shell, device linking, roots, packaging, execution order |
| `docs/apps.md` | the group UI's plug-in architecture — as built, and how to add an application |
| `devel-phases-next.md` | the roadmap |

---

## 1. Changes from v5

| # | Category | Change | Source |
|---|---|---|---|
| 1 | Content model | A group's content is **several named roots**, not one directory. Names are unique, derived from the directory's basename, and form a union virtual root | E7 / decision 11, 13 |
| 2 | Identity | **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned, bound by a one-time code the new device generates | E2 / decision 2 |
| 3 | Client | The native client is **Electron**, not pywebview. Structural decision 18 reversed | E1 / decision 1 |
| 4 | Node authority | `gek_rotate` may be a signed MNP op — the C5b rule forbids *key material arriving from outside*, not an operator-signed instruction where the node generates the key | §2.3 below |
| 5 | Server state | **Group-related server state lives on the node, always.** Written down because v5 assumed it | E9 / decision 17 |
| 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 |
| 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 |
| 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b |
| 9 | Node authority | The operator may **close uploading to everyone but themselves**, per group. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control | §2.1b |
| 10 | Client | A group's UI is a **set of pluggable applications** (Chat, Files today), not one monolithic page. Which are shown is a per-group, operator-signed setting on the same pattern as change 9 | §2.7 |
| 11 | Hub role | The hub gains a **runtime instance-policy store** (`hub_settings`). First policy: an admin switches **public groups off** hub-wide, enforced server-side on every hub-mediated path. `suspend` vs `revoke` on a group are now written down as the distinct things they are | §2.8 |
| 12 | Group registry | A group name is **unique per owner account**, not globally; the group's identity is still its UUID. Listed everywhere as `name@owner` | §2.9 |
| 13 | Enrichment | **Chat link previews** — the node unfurls a pasted URL into an OpenGraph card. A new instance of the §2.7 "node on demand, asking device caches, nothing durable" rule; new SSRF surface, gated. MNP 0.12 | §2.10 |

---

## 2. What v6 changes in v5's text

### 2.1 §5.2 Uploads — now per root

v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite,
chunk ordering and a size cap. All four protections stand. Two amendments:

- There is no single `shared_root`. **The operator designates one root as the upload
  destination**; the quarantine lives inside it. If that root is unavailable the upload
  fails with a stated reason and never falls back to another; if none is designated,
  uploads are refused rather than guessed.
- **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier
  draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does
  not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those
  filesystems, so the upload already gets a free name. C5a is not reachable through the
  filesystem. Case folding is needed for index identity, collision reporting and root
  names — comparisons the code makes itself.

v5's rule that nothing derived is written beside the originals is **unchanged**, and it
decides the video-thumbnail question: a frame grab is produced on demand and cached on the
device that asked, so the node keeps no thumbnail store.

### 2.1b §5.2 Uploads — the operator may close them

New. A group where every member may add files is the default and stays the default;
some groups want a library the operator curates, and until now the only way to get one
was to designate no upload root at all, which refuses the operator too.

`member_upload` is a per-group setting, and three things about it are load-bearing:

- **It lives on the node**, in `roster.db`, not in `node.toml` and not on the hub. Not
  the hub because a hub that decides who may write to someone else's disk has authority
  over that node, which is the arrangement this design exists to avoid (change 5). Not
  `node.toml` because that file is hand-written, full of comments recording decisions,
  and `ops.py` deliberately appends to it rather than round-tripping it through a
  writer — a setting changed from a panel must not rewrite the operator's file, and must
  not need a restart.
- **Changing it is a signed operator instruction** (`OP_MEMBER_UPLOAD`, MNP
  `member_upload`), on the same path as removing a member. An unsigned one would let any
  member turn it back on, which makes the control a suggestion. The transcript's subject
  is `on` or `off` — what the operator is shown before signing has to name the outcome,
  not the operation.
- **The node enforces it**; the interface merely stops offering it. `handshake_ack`
  carries `member_upload` so a client knows whether to draw the Upload button and the
  chat paperclip, and the node broadcasts `member_upload_ack` to everyone connected when
  it changes. None of that is the control: a member on an old tab, or one speaking MNP
  directly, is refused by the node with `member_upload_off`.

**Absent means allowed**, at every layer — no row in `group_settings`, no key in the
group context, no field in the ack. A node or client that predates the setting behaves
exactly as it did, and an upgrade never silently closes a group.

The operator is always exempt. Turning it off otherwise locks them out of their own
node, with a config file and a restart as the only way back.

### 2.2 §5.5 Admission — devices, not one key per person

v5 and `invite-pairing-v1.md` bind **one** key pair to an account per node: `identities`
has `user_id` as its primary key and `pin_identity` does `INSERT OR REPLACE`. A person
with a browser and a native client needs two keys on the same node, so:

- `identities` becomes keyed by `(user_id, pk_ed25519)`, with `label`, `added_at`,
  `added_by_pk` and `revoked_at`. **`INSERT OR REPLACE` must go** — today it silently
  overwrites, which becomes a hole the moment a second key is legitimate.
- A new device is admitted when **a key the node already pinned countersigns it**, bound
  by a one-time code the new device generates and displays, hashed together with the new
  keys so the node cannot substitute them.
- The operator's one-time code remains available and is unchanged. Device linking is an
  addition to admission, not a replacement.

**Against an active hub this holds**, and for the same reason §5.5 holds: the hub has
stored no user keys since 2026-08-14, so it cannot produce the countersignature. Against a
malicious node operator it is not a new exposure — a node can only add a device to itself,
where it already reads everything it serves.

**Where it does not hold:** approval performed *in a browser* inherits T3, because the hub
serves that browser its code and can read the typed code. The first browser-to-native link
is therefore the moment of highest exposure for an account, and it happens once.

Full design: `docs/desktop-client-v1.md` §4.

### 2.3 §5.1 GEK activation — the rule, stated precisely

v5 says *"nothing arriving over MNP can activate a GEK"*. Read precisely: the rule targets
**key material arriving from outside** (C5b), not the instruction. An operator-signed
`gek_rotate` where **the node generates the key with its own CSPRNG** satisfies the
property v5 §5.5 actually establishes — the node produces every copy of the key — and is
allowed.

**The initial `gek-init` stays local.** With no GEK, `join_result` answers `no_gek` and no
MNP session completes, so there is no authenticated session to carry a signed op. Placing
it in the pre-proof window is possible and is deliberately deferred; that window is where
C4 and C5b were born.

### 2.4 §8.2 Native client — Electron

pywebview is replaced by Electron plus an optional Python sidecar for hub-less `group://`
over QUIC. The non-negotiable is unchanged and is the entire point: **UI assets ship
inside the package and load from disk.** A shell pointing at the hub's `/app/` is a browser
with a different icon.

What changes is the engine, not the claim. Native still does **not** remove trust in the
hub operator; it converts an undetectable, per-request attack into an artifact that can be
hashed and compared, and that value is realised by reproducible builds (18.7), not by the
packaging format.

Two corrections to v5's client table:

- **Key storage.** Identity keys are generated and kept locally, never bundled. C4 closes
  for a native device unconditionally — and **stays open for any account that also uses a
  browser**, which has no durable storage of its own and still needs a bundle on each
  node. An account is only as strong as its weakest client.
- **Crypto.** The client keeps WebCrypto *and* gains local Argon2id and ChaCha20 in the
  main process. v5 implied WebCrypto is lost with the browser engine; under Electron it is
  not.

### 2.4b §5.1 Authorship — authenticated, not asserted

v5 §5.1 authorizes `file_delete` by "the node operator, or the user who uploaded the file
(verified by the key recorded at upload)". Two changes:

**Authorization moves from the key to the account.** With several devices per person,
`_admin_exec_file_delete` — which verifies against `entry.uploader_pk`, the exact uploading
key — would refuse Alice's desktop the right to delete what her phone uploaded. It becomes
**any non-revoked device of `uploader_id` in the roster**, with `uploader_pk` kept as the
audit record of which device acted. This remains **roster-rooted, not token-rooted**: a hub
minting a token that claims to be Alice holds no key the node pinned for Alice, so the
signature fails — the property `per-node-identity-v1.md` established is preserved.

**Ownership becomes provable.** The uploader signs `meshbay:upload:v1` over node, group,
root, path, content hash, account and timestamp; the node stores it with the index entry.
Ownership is then verifiable by any member rather than asserted by the node, and the C5a
path — overwriting a file to become its recorded uploader — is closed a second time.

**Chat senders must be cryptographically authenticated to each other.** v5 relied on NS6,
where the node enforces `sender_id` from the authenticated session; that is the node's
word. Messages are signed with the sender's **device** key, clients pin `account → device
keys` on first sight using the device-add countersignatures as evidence, and the operator
may sign a roster attestation to close first contact.

**Against whom this holds.** Against another member: fully — no member can forge another's
signature. Against someone holding the node's disk: fully — a stolen chat store cannot be
*extended* with messages that verify. Against the node operator: **partially, and the
partial part is worth having** — once a member's client has pinned Alice's device key, an
operator who turns malicious later cannot forge Alice to that member; forgery is limited to
accounts the victim has never seen. Full protection at first contact requires an
attestation rooted outside the node, which is what the operator-signed roster and safety
numbers provide.

Design: `docs/desktop-client-v1.md` §4.8.

### 2.5 §6.1 Hub role — one addition, one rule

The hub gains exactly one endpoint from all of this: **`POST /v1/users/auth`**, device
Ed25519 authentication on the pattern of `POST /v1/nodes/auth`. Nothing else in the
desktop-client design adds a row or a column to the hub.

And the rule v5 assumed without writing:

> **Group-related server state lives on the node.** Files, indexes, members' devices,
> pending device requests, invitations, chat, per-root availability, and anything a future
> feature wants to keep about a group — all on the node. The hub holds accounts, the group
> registry and membership, signaling, notifications, the moderation surface, and
> instance policy (`hub_settings`, §2.8) — and nothing else about content.

Verified for the multi-root change: `SwarmSource` carries `content_hash`, `node_id` and
`endpoint` — **no paths, no filenames** — and private groups register nothing (H7). The
content model changes end to end without the hub moving.

### 2.6 §7 Cryptography — unchanged, one consumer added

No parameter changes. `keyderive.py` now has a third consumer: the desktop client derives
`auth_key` exactly as the browser does at registration. `test_bundle_kdf_parity.py` covers
it, and the standing warning is unchanged — **never change those parameters in one
place**; a mismatch does not look like an error, it looks like an account nobody can open.

### 2.7 The group UI becomes a set of applications

New (2026-08-23). A group had two fixed tabs, Chat and Files, both defined inside one
monolithic `static/app.js`. Two things motivated splitting it before adding to it: the file
had become the thing every unrelated change touched, and the roadmap wants three more
group-level surfaces — a Netflix-style video browser, a Spotify-style music player, a photo
album viewer. None of the three need a protocol change: the node's indexer already
classifies files as `video`/`audio`/`image`, and they would read the same `index_sync` /
`file_req` / `stream_req` messages Files and the video player already use. What they need is
somewhere to live that is not one file, and a way for an operator to turn one off.

**The shape.** `group-page.js` is now the shell: the WebRTC connection, the file index, the
tab bar, and the video/preview modals, none of which are Files- or Chat-specific. `apps.js`
holds the registry — `[{ key, icon, labelKey, Component }]` — and every registered
component receives the same props object from the shell, spread rather than hand-listed, so
adding an app changes no code in the shell itself. Chat and Files each moved to their own
file (`chat-app.js`, `files-app.js`) to prove the mechanism; nothing else exists yet.

**Enablement is the same pattern as change 9, on purpose.** `apps_enabled` is a per-group
setting: lives on the node (`roster.db`, not the hub, not `node.toml`, for the identical
reason `member_upload` does — a hub or a config file that decided this would have authority
over the node), changed by a signed operator instruction (`OP_APPS_ENABLED`), enforced by
the node refusing to store an unrecognised or empty set rather than by the client's
honesty. **Settings itself is not an app** and cannot be disabled — the one way back if
everything else were turned off.

**What this does not change.** No new server state on the hub (change 5 stands unmoved: the
enabled-apps set is group-related state, and it lives on the node like everything else in
that category). No new adversary or trust boundary — this is a display policy, not a key or
a permission over content; a member whose client shows a hidden tab's data anyway would
still be a member the node already serves that data to.

Full detail — the props contract, the file layout, and a checklist for adding a new
application — is `docs/apps.md`, on the same basis `docs/desktop-client-v1.md` holds the
desktop client's detail: this document states what changed and what holds, not how to build
on it.

### 2.8 §6.1 Hub role — instance policy, and public groups can be switched off

New (2026-08-28). The hub had no runtime-configurable setting: everything was `hub.toml`
(read once, restart to change) or code. It now has `hub_settings`, a key/value table an
admin edits from the panel. The first entry is `allow_public_groups`.

**This does not contradict change 5 / §2.5.** `hub_settings` is *instance policy, not group
content*: it says how this hub behaves, and holds nothing about any group's files, index,
membership or keys. "Group-related server state lives on the node" is untouched.

**What "off" does** — server-side, on every path the hub mediates, read live:

- `create_group` refuses `visibility=public` — staff included; the way back is to
  re-enable it, not to slip past
- the public directory (`GET /v1/groups`) returns nothing, local **and** federated
- open-joining a public group is refused
- `group_online_nodes` hands a non-member no node to connect to
- `webrtc_offer` drops its "the node hosts an open-join group, admit anyone" fallback
- the MHP directory export advertises nothing to peers

Flipping it back restores every path. **Existing members of a group that predates the
switch keep their membership row and their access** — this is a directory-and-brokering
control, not a purge.

**What it is not.** The node still reads `visibility` and `join_policy` from its own
`node.toml`, never the hub (`invite-pairing-v1.md`). A node whose operator set
`join_policy = "open"` still TOFU-pins and serves whoever reaches it directly over MNP.
The switch removes the hub-provided ways to *find and reach* such a node; it is **not a
remote kill**. For a hard stop on one group, `POST /v1/admin/revoke` is the tool.

**`suspend` vs `revoke`, made explicit** — the two moderation verbs on a group were never
written down as distinct, and they are:

| | `suspend` (`PATCH /v1/admin/groups/{id}`) | `revoke` (`POST /v1/admin/revoke`) |
|---|---|---|
| Hub DB | `status = "suspended"` | `status = "revoked"` |
| Node | nothing | signed revocation broadcast → denylist + live sessions dropped, **persisted across a node restart** |
| Reversible from the panel | yes | no |

The client shows the real state now (`Group is revoked`, not a blanket `Group is
suspended`).

### 2.9 §6.1 Hub role — group names unique per owner, shown as `name@owner`

New (2026-08-28). "The name is unique" was an unenforced expectation — the `groups` table
had only a non-unique index. Two changes:

- A functional unique index `uq_groups_owner_name (admin_id, lower(name))`. Uniqueness is
  **per owner account**, case-insensitively, trimmed — two different owners may each have a
  `photos`. `create_group` returns 409 on a clash, with an `IntegrityError` backstop for
  the race. The migration pre-flights and **aborts listing the offending pairs** rather
  than renaming anyone's group.
- **The group's identity is still its UUID.** `#/group/<uuid>`, `_node_groups`,
  `node.toml [[groups]] id`, membership — all unchanged. `name@owner` is a *label plus a
  create-time check*, not an addressing scheme.

`owner_username` (derived from `admin_id`, nothing stored anew) rides `/v1/groups/mine`,
`GET /v1/groups` (local rows), the join response and `/v1/admin/groups`. The SPA renders it
under the name, smaller and grey, prefixed `@`. The handle is **hub-local**:
`foofoo@grenet` on two federated hubs are different groups, and a federated row shows
`@<source_hub>`, not an account.

Design record and the decisions behind it: `~/next/groupnames.md` (kept out of the repo).

### 2.10 Chat link previews

New (2026-08-28). A URL pasted in a group's chat unfurls into an OpenGraph card — title,
description, site name, image.

**Whose request it is: the node's**, never the browser's or the hub's. The browser cannot
(a strict `img-src`/`connect-src` and CORS), and a direct fetch would leak every reader's
IP to the linked host on each render. The hub must not touch group content (§2.5). The node
already fetches third-party metadata for the Videos and Music apps, over the same
authorised path.

This is a new instance of the §2.7 / draft-v5 §5.2 rule: **the node produces enrichment on
demand and the asking device caches; neither node nor hub keeps durable derived state.**
The card text lives in a bounded in-memory TTL cache on the node; the OG image rides the
existing `media_cache` thumb store (blake3-keyed) and the browser fetches it over the
normal `file_req` path, exactly like a poster. Nothing reaches the hub — this is node↔
browser P2P.

**New adversary surface: SSRF.** The URL is a *member's* choice and it triggers an outbound
HTTP request from the operator's machine. `linkpreview.safe_url` is the gate — http(s)
only, no credentials, and every resolved address must be globally routable (loopback,
private, link-local, multicast, reserved and cloud-metadata ranges refused); redirects are
followed by hand so each hop is re-checked. Residual, documented in the module: a name that
resolves clean at check time and internal at connect time (rebinding), closed properly by
pinning the checked IP — deferred.

**MNP 0.12** — `link_preview_req` / `link_preview_resp`, additive: an older node logs
"unknown type" and the client shows the bare link.

---

## 3. Filesystem portability as a security property

New in v6, because it was treated as an edge case and is not one. Most users are expected
to share from an external exFAT or NTFS volume, on Windows.

| Property | Consequence |
|---|---|
| Case-insensitive, case-preserving | `Film.mkv` and `film.mkv` cannot coexist. The index needs a canonical identity and a **case-folding collision check** at scan time. The no-overwrite rule must be case-folded — **this one is a security fix** (§2.1) |
| Unicode normalization | `Café.mkv` written on macOS (NFD) and Windows (NFC) are different byte strings. Normalize to **NFC for identity**, preserve the original bytes for display and opening |
| Windows reserved names, `MAX_PATH` | A group indexed on Linux can hold names Windows cannot create. The client sanitizes on save **and says so**; use `\\?\` paths |
| FAT/exFAT timestamps (2 s, local time) | mtime alone is not a change detector. Size + mtime with tolerance, rehash when in doubt |
| Watcher reliability | `ReadDirectoryChangesW` drops events under load; inotify on a FUSE mount is unreliable. **Periodic reconciliation is mandatory on both platforms** |
| No symlinks, no POSIX permissions | Simplifications: nothing to defend against, and the node runs as the user anyway |

A volume that disappears must **freeze** the affected root's subtree, never empty it.
Emptying propagates deletions for a whole library as though the owner had erased it.

---

## 4. Security claims — deltas only

v5 §2's table stands. Three rows change, and they are the honest version:

| Claim | Passive hub | Active hub | Malicious node operator |
|---|---|---|---|
| Client code integrity | ✅ ships in the package (native) | ⚠️ **detectable, not prevented** — realised by 18.7, not by packaging | ✅ |
| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ **open for any account that also uses a browser** |
| Devices | ✅ | ✅ the hub cannot countersign a device — it holds no user keys | ⚠️ a node adds devices only to itself, where it already reads everything |

**The claim v6 supports:** *the hub cannot read your content, and against a native client
its only remaining lever is the artifact it ships — which can be hashed and compared.*

**The claim it must not make:** that a native client makes the hub untrusted, or that C4
is closed for an account that still signs in from a browser.

---

## 5. Still open

v5 §9's list stands, with these movements:

| # | Item | Status |
|---|---|---|
| C4 | Remote keypair bundles | **Partially closed.** Gone for native devices; open for browser-using accounts until the signed `device_policy {allow_bundle: false}` opt-out ships |
| T3 | Hub serves the SPA | **Accepted permanently** for browser users. Removed for native clients, whose value depends on 18.7 |
| — | Chat encryption (Sender Keys) | Phase 15, unchanged. Pairwise to identity keys, never GEK-derived — and **now to devices**, which multiplies the recipients per person |
| — | Delegation | Designed, deferred, unchanged |
| — | Hub identity pinning | New. `GET /v1/hub/pubkey` exists and nothing pins it; bounded, because a substituted hub can neither read content nor ship the code to a native client |

**Phase 15 has been re-read against device linking (2026-08-17) and was wrong as written.**
The correction is recorded in `devel-phases-next.md` §15.0b; the load-bearing part:

- **A sender key is per device, never per person.** A shared per-person chain advanced by
  two devices produces key and nonce reuse — which is exactly why `first-review.md` C1
  rejected a shared Double Ratchet for groups. The same mistake, one level down.
- `senderkeys.py` already fails this silently: `GroupSenderKeyStore.add_sender` does
  `self._states[dist.sender_id] = ...`, so a second device under the same `sender_id`
  **overwrites the first and drops its chain**. `sender_id` must become a device
  identifier.
- **Revoking a device must rotate**, like revoking a member.
- **A newly linked device cannot read history** until every sender redistributes, unless
  the linking device hands over its own state sealed to the new device's key.
- **Sender attribution stays node-trusted.** A sender key proves a *device*; the mapping
  from device to account comes from the node's roster. Encrypted chat does not make
  senders cryptographically authenticated to each other, and the docs must not imply it.

Ordering consequence: **device linking (Stage C) lands before Phase 15**, or Phase 15 is
built against an identity model that is about to change underneath it.

---

## 6. Where the detail lives

This document states what changed and what holds. It does not restate the desktop client's
design, which is long and belongs in one place:
**`docs/desktop-client-v1.md`** — shell requirements, device-linking protocol and schema,
account creation, node management over signed MNP ops, several roots per group, filesystem
portability, packaging and first run, the web tier, and the execution order for all of it.

**`docs/apps.md`** — the group UI's plug-in architecture (§2.7): the props every
application receives, the enablement mechanism end to end, and a checklist for adding one.