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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
|
# MeshBay — Invitation and Pairing (design)
> Status: **proposal, not implemented.** Written 2026-08-13.
> Supersedes the invite flow described in `meshbay-draft-v5.md` §5.1 and the
> Phase 12.1 milestone in `devel-phases-next.md`, if adopted.
>
> Closes **H3** for the path where it is actually exploitable, and dissolves **M3**
> rather than patching it. Follows the v5 convention: every claim names the
> adversary it holds against.
---
## 1. What is wrong today
Two problems, one visible to users and one invisible.
### 1.1 The workflow problem
An invite runs entirely inside the inviter's browser (`app.js:1391-1420`):
1. fetch the invitee's `pk_x25519` from the hub — `GET /v1/users/{name}/pubkeys`
2. take the raw GEK out of the live transport connection
3. wrap the GEK for that key and push the bundle to the node over MNP
4. add the member on the hub
Step 2 requires the inviter to be **connected to the node with the group key in
hand**. Step 3 requires the node to accept the bundle, which since C5b means the
signature must be the node operator's (`webrtc_server.py:1023-1035`). Together:
- only the node operator can invite — v5 §5.1 records this as deliberate
- the operator must be at a browser, connected, at the moment of the invite
- a group admin who does not run the node cannot add anyone, ever
That is not a workflow. It is the reason the demo needs `demo.py set-admin-pk`.
### 1.2 The security problem (H3)
Step 1 asks the **hub** which key belongs to `bob`. The hub stores those keys as
mutable columns on the user row (`api/users.py:352-354`) and serves them with no
signature and no history. A hub that answers with a key it holds is handed the GEK
by an honest inviter following the protocol exactly. Nothing forged, nothing
injected, and nothing in the client notices.
The account is unique and the account is right. The **key attached to the account**
is what the hub controls.
### 1.3 M3, underneath both
The daemon auto-pins its own keystore key as the admin key (`daemon.py:470-474`);
the browser signs with the user identity key (`app.js:1416`). They differ, so
invites and deletions fail closed with a signature error that looks like a bug
elsewhere. The demo works only because a deploy script writes the browser key into
`node.toml`.
The tempting fix — have the daemon fetch the operator's key from the hub — turns M3
into a second H3: the hub would then be able to install itself as node
administrator. **The node must never learn authority from the hub.**
---
## 2. Principle
Three rules. Everything below follows from them.
1. **The node wraps the group key.** The node already holds the GEK — it encrypts
and serves the content. So it, not the inviter's browser, produces each member's
bundle. No member ever handles another member's key material.
2. **A key is bound to an identity by a one-time pairing code, then pinned.** The
code travels out of band (the inviter sends it to the invitee the way they
already talk). The hub never sees it and therefore cannot claim to be the
invitee.
3. **The node keeps its own roster.** Hub membership is an input, not an
authorization. Otherwise a hub that invents an account and mints a token for it
collects the GEK on connect.
Human cost of the whole scheme: **one code per person, once per node**, plus one
code for the operator at install. No fingerprint comparison, no per-member
signature ceremony, no operator required to be online when someone joins.
---
## 3. Flows
### 3.1 Operator pairing (once per node, replaces M3)
```
operator (SSH) meshbay-node operator pair
node prints PAIR-CODE: K7M2-QX4P (also written to data_dir/pair-code)
operator (SPA) Settings → "Pair this browser with my node" → types the code
SPA → node join_request {role_hint: operator, code, pk_ed25519, pk_x25519, sig}
node code valid, unused, unexpired → pins the keys, role = operator
node writes the pin to its roster DB, prints it in `status`
```
The operator types 8 characters into their own browser. Nothing is pasted, nothing
is copied out of a terminal, no browser is needed on the node host, and the hub is
not involved at any point. `admin_pk_ed25519` in `node.toml` becomes a legacy
fallback, still read, no longer required.
### 3.2 Invite (one click, operator or delegate)
```
grenet (SPA) Groups → Members → "Invite bob"
SPA → node admin_request {op: invite_create, group_id, invitee: bob, ttl}
node → SPA admin_challenge (structured transcript, H5 — the SPA shows one
line: "authorize bob to join <group>")
SPA → node admin_response {sig} ← signed silently with grenet's key
node creates invite: code, group, invitee, expiry; status = pending
node → SPA invite_created {code: "R3H8-TB6V", expires_at}
grenet sends the code to bob however they already talk
SPA → hub POST /v1/groups/{id}/members/bob (membership, unchanged)
```
Grenet's browser signs, but grenet does not *inspect* a signature — one click, one
confirmation line, one code to pass on. Same gesture as any invite link on any
platform.
### 3.3 Join (fully automatic, operator may be asleep)
```
bob (SPA) opens the group; client has no GEK for it
bob → node handshake {token, group_id, nonce_c} (pre-proof window)
node → bob handshake_challenge {nonce_s}
bob → node join_request {group_id, pk_ed25519, pk_x25519, code, sig}
node 1. rate-limit + attempt count on this connection
2. code matches a pending invite for this user_id and group
3. identity not already pinned to a different key
4. pin (user_id → pk_ed25519, pk_x25519), mark invite used,
member status = active
5. wrap the ACTIVE GEK for pk_x25519 (ECIES, as today)
node → bob join_result {ok, pk_eph_b64, nonce_b64, wrapped_b64}
bob unwraps the GEK, completes the normal GEK proof, session proceeds
```
On every later connection bob sends `join_request` **without** a code; the node
recognises the pinned identity, re-wraps the current GEK and answers. So GEK
rotation propagates by itself, and a revoked member simply stops being served.
### 3.4 Open-join groups: no code (decided 2026-08-13)
A group whose hub-side `join_policy` is `"open"` (`groups.py:227-228`) admits anyone
who asks. A pairing code there protects nothing — the hub can create an account,
join through the front door and be a legitimate member — so it is pure friction.
For those groups the node pins on first contact (TOFU, `pinned_via = 'tofu'`) and
wraps the GEK immediately.
Note the axis. `visibility` (public/private) controls **discoverability** and swarm
hash registration (H7); `join_policy` (open/request/invite) controls **admission**.
Only the second one decides whether a code is required. A `visibility = "public"`
group with `join_policy = "invite"` keeps the code — being findable is not being
open.
**Stated plainly, per the v5 convention:** in an open-join group the hub can obtain
the GEK, because it can become a member legitimately. That is a property of open
joining, not of this design — it is equally true today. Content in such a group is
protected from the network and from non-members, and from nobody else. The docs
must say so, and the SPA should say so when someone sets `join_policy = "open"` on
a group that already holds content.
---
## 4. Why a pairing code, and not something lighter
The choice is forced by one question: when bob connects for the first time, what
stops the hub from being bob?
| Option | What the hub can do | Verdict |
|---|---|---|
| Node wraps for the key the peer presents, no binding | Forge a JWT for bob, present its own key, receive the GEK | **Worse than today** — today a forged JWT yields a bundle wrapped to bob's real key, which is useless |
| Bind to the key the inviter fetched from the hub | Substitute at invite time — H3 unchanged, just relocated into the node | No |
| TOFU: first connection wins | Race the real bob with a forged token; small window, total consequence | No |
| Safety-number comparison at invite | Nothing — but it needs two humans reading digits at the worst moment | Correct, unusable as the default |
| **One-time pairing code** | Nothing: the code never reaches the hub | **Adopted** |
The code is the cheapest thing that binds an identity to a key without the
directory. It is also the familiar shape — invite links work this way everywhere.
**Boundary, stated honestly:** for a browser client the SPA is served by the hub, so
a hub that ships malicious client code can read the code out of the page. That is
**T3, accepted permanently** by decision D1 and unchanged by this design. The code
defeats a hub that *lies in its directory* — a silent, undetectable, per-request
attack — not one that *rewrites the client*, which is an artifact and is what the
native client (Phase 13) removes. Do not blur the two in the docs.
---
## 5. Protocol
### 5.1 New MNP messages (`meshbay_common/protocol.py`)
```
JOIN_REQUEST = "join_request" # client → node, served in the pre-proof window
JOIN_RESULT = "join_result" # node → client
INVITE_CREATE = "invite_create" # inviter → node (admin op, see 5.3)
INVITE_RESULT = "invite_result" # node → inviter, carries the code once
```
`join_request`:
| Field | Meaning |
|---|---|
| `group_id` | mandatory, as everywhere since 11.5.4 |
| `pk_ed25519`, `pk_x25519` | the caller's own keys, base64 raw |
| `code` | first pairing with this node only; omitted when `join_policy == "open"` (§3.4) |
| `role_hint` | `operator` or absent; the node trusts the *code*, not the hint |
| `sig` | Ed25519 over the transcript below |
### 5.2 Join transcript
```
"meshbay:join:v1" ‖ len‖node_pk ‖ len‖group_id ‖ len‖user_id
‖ len‖pk_ed25519 ‖ len‖pk_x25519 ‖ len‖nonce_s ‖ len‖ts
```
Length-prefixed and domain-separated per 11.5.21. `nonce_s` is the handshake nonce
the node just issued, so a `join_request` cannot be replayed onto another
connection. `pk_x25519` is inside the signature, so the Ed25519 key vouches for the
X25519 key it is paired with — this is what makes "wrap for the presented key" safe.
The **code is never signed and never echoed** — it is a bearer secret, compared
against a stored hash and destroyed on use.
### 5.3 Invite creation reuses the existing admin machinery
New op in `meshbay_common/adminop.py`, alongside `OP_FILE_DELETE` and
`OP_GEK_BUNDLE_STORE`:
```
OP_INVITE_CREATE = "invite_create"
"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts
subject = invitee user_id
```
Authorized by the pinned **operator** role, or by a **delegate** (§6.2). TTL 120 s,
same as every other admin op. The client rebuilds the transcript and refuses to
sign if the subject is not the person the user typed — H5's rule, unchanged.
### 5.4 Code format
8 characters, Crockford base32 (no `I`, `L`, `O`, `U`), rendered `XXXX-XXXX` — 40
bits. Single use, default TTL 24 h, stored only as `sha256(code)` — a password KDF
would be pointless over 40 uniformly random bits, and `blake3` is not a node
dependency. Guessing is
bounded by: 5 attempts per connection, a node-wide limiter on failed
`join_request`s, and the fact that a code is valid for exactly one `user_id` in one
group. A brute-force attempt is an audit-log event, not a silent grind.
---
## 6. Node state
### 6.1 Schema (new tables, `roster.py`, same SQLite file style as `bundle_store.py`)
```sql
CREATE TABLE identities ( -- one row per person, not per group
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
pk_ed25519 TEXT NOT NULL,
pk_x25519 TEXT NOT NULL,
pinned_at TEXT NOT NULL,
pinned_via TEXT NOT NULL -- 'code' | 'tofu' | 'legacy-config' | 'operator-reset'
);
CREATE TABLE members (
group_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'operator' | 'delegate' | 'member'
status TEXT NOT NULL, -- 'pending' | 'active' | 'revoked'
approved_by TEXT NOT NULL, -- user_id whose signature created the invite
approved_at TEXT NOT NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE TABLE invites (
code_hash TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL,
created_by TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
);
```
Identity is pinned **per node, not per group**: someone already paired for one group
needs no code for the next one. The operator's pairing is the same mechanism with
`role = 'operator'` and no group.
### 6.2 Delegation — **deferred** (decided 2026-08-13)
A `delegate` row would let a group admin who is not the node operator create invites
without the operator being involved again. Not needed while the operator is the
inviter (the demo, and every single-operator deployment), so it is **not built in
v1**.
The `role` column reserves the value and `invite_create` authorization is written as
a role check rather than an equality test against the operator, so adding it later
is a roster row and a CLI command — no protocol change, no migration.
### 6.3 What the node stops doing
- `gek_bundle_store` **no longer accepts member-supplied bundles at all.** Nothing
arriving over MNP contributes key material. C5b's rule is not merely preserved,
it becomes structural — the message can be deleted from the client path entirely.
- Per-member rows in `gek_bundles` are no longer written. The node wraps on demand.
The node's own `_node_{user_id}` bundle stays: that is how the daemon reloads its
GEK across restarts.
- **Consequence worth having:** revocation starts working for key delivery. A
stored bundle today survives revocation; on-demand wrapping does not. (Rotating
the GEK after a revocation is still required — the ex-member has the old key.)
---
## 7. Security analysis
### 7.1 Against each adversary
| Attack | Today | With this design |
|---|---|---|
| Hub substitutes the invitee's key at invite time (**H3**) | Succeeds silently, hub gets the GEK | **Fails** — no key is ever fetched from the directory |
| Hub forges a JWT for a real member | Gets a bundle wrapped to the member's real key: useless | Unchanged: no code, no pin match → refused |
| Hub invents an account and adds it to the group | Blocked only accidentally, by the bundle requirement | **Blocked by the roster** — no invite, no code, no GEK |
| Hub substitutes the *operator's* key (M3's tempting fix) | n/a | **Impossible** — the node pins by code, never asks the hub |
| Member wraps a GEK of their choosing for the operator (**C5b**) | Blocked by operator signature | **Impossible** — the message no longer exists |
| Member replays a `join_request` from another connection | n/a | Bound to `nonce_s` |
| Member presents someone else's `pk_x25519` | n/a | Signed by the paired `pk_ed25519`, mismatch refused |
| Ex-member reconnects after revocation | Stored bundle still unwraps | Not served; GEK rotation still needed |
| Third party guesses a code | n/a | 40 bits, single use, per-user, rate-limited, audited |
| Hub joins an **open-join** group and collects the GEK | Succeeds | Still succeeds — inherent to open joining (§3.4), must be documented, not hidden |
### 7.2 What this does **not** fix
- **T3** — the hub serves the SPA and can read the code out of the page. Accepted
(D1); removed only by the native client plus reproducible builds.
- **C4** — the keypair-bundle pre-proof window is untouched. A first-time joiner
still needs their own identity keys before they can sign anything; that material
belongs on the user's device (Phase 13.3).
- **The node operator reads everything.** Inherent to the model.
- **The hub still knows who is in which group.** Membership is hub-side; the roster
only decides who receives the key.
- **A member can still leak the GEK out of band.** Nothing prevents that, and
nothing in the current design pretends to.
---
## 8. Failure modes and edge cases
| Case | Behaviour |
|---|---|
| Code lost or expired | Inviter clicks "Invite" again; the old invite is superseded and its hash deleted |
| Bob pairs, then loses his keys and runs `regenerateKeys` | Pin mismatch → join refused with a clear message; needs a fresh invite (operator or delegate re-issues). This is the intended blocking warning, moved to the moment it matters |
| Bob is in two groups on the same node | One pin, one code, ever |
| Node reinstalled / roster lost | Everyone re-pairs. Same class of event as losing the keystore; `status` must say so plainly |
| Operator pairs a second browser | New `operator pair` code; both browsers valid, both listed in `status` |
| Two people race one code | Single-use row, `used_at` set under a transaction; the loser gets a plain refusal |
| Invite created while the node is offline | Not possible — invites are created on the node. The SPA must say "node offline, cannot invite" instead of failing obscurely |
| Member connects while the group has no active GEK | `join_result {ok: false, reason: "no_gek"}`; the operator runs `gek-init` |
| Legacy deployment with `admin_pk_ed25519` set | Read at startup and inserted as an `identities` row with `pinned_via = 'legacy-config'`; no migration needed for the current demo |
---
## 9. Operator surface
```
meshbay-node operator pair # print a pairing code for a browser
meshbay-node member list [--group G] # roster: who is pinned, role, status
meshbay-node member invite bob [--group G] # same as the SPA button, from SSH
meshbay-node member revoke bob [--group G] # stop serving the GEK to bob
meshbay-node member unpin bob # force re-pairing after a key rotation
```
`--group` is optional whenever the node hosts exactly one group.
`meshbay-node status` gains a line per group: pinned identities, pending invites,
and — when nothing is paired — the exact command to fix it. Everything an operator
needs is reachable over SSH with no browser on the host, per the standing
constraint. This absorbs milestones 14.3 and 14.4.
---
## 9bis. Implementation status
**Slices 1 and 2 landed 2026-08-13/14.** Not yet exercised against a live
deployment — the operator tests after slice 3, so the slices are written to be
coherent with each other rather than individually demo-able.
### Slice 1 — roster and operator pairing (M3)
| Shipped | Where |
|---|---|
| `identities` / `members` / `invites`, codes, single-use redemption | `meshbay_node/roster.py` |
| `join_transcript` — both public keys signed together | `meshbay_common/join.py` |
| `join_request` / `join_result` handler, valid pre-proof and post-handshake | `transport/webrtc_server.py` |
| Admin authority read from the roster on every check, `admin_pk_ed25519` kept as legacy | `webrtc_server._verify_admin_sig` |
| **Auto-pin of the keystore key deleted** (M3) | `daemon._legacy_admin_pk` |
| `meshbay-node operator pair`, roster in `status` | `daemon.main`, `ui/app.py` |
| Pairing form in the group's Members tab | `app.js`, `transport.js` |
### Slice 2 — the node wraps the key (H3)
| Shipped | Where |
|---|---|
| Node wraps the GEK for the key the joiner proved, on every connection | `webrtc_server._join_ok` |
| Roster decides who may receive it — hub membership alone does not | `Roster.is_authorized` |
| `invite_create` admin op; the SPA shows a code instead of handling keys | `adminop.py`, `app.js` |
| **`gek_bundle_store` deleted** — no member ever hands the node key material | `protocol.py`, `webrtc_server.py` |
| **`gek-init` no longer fetches member keys from the hub** — it was H3 with the node as victim | `ui/app.py` |
| Open-join groups admit without a code; policy read from `node.toml` | `config.py`, `_group_join_policy` |
| Client asks for the key when it has none; prompts for a code when required | `transport.js`, `app.js` |
**Tests: 148 node, 174 hub/common** (from 121/168 before this work). The end-to-end
one worth knowing about is `test_invite_then_join_delivers_the_gek`: over a real
DataChannel, the operator gets a code, and a member who has never held the group
key redeems it in the pre-proof window and receives the key wrapped for a key only
they can open.
### Deliberate departures from this document
| Written | Built | Why |
|---|---|---|
| `blake3(code)` | `sha256(code)` | 40 uniformly random bits; blake3 is not a node dependency |
| Pairing in Settings | Pairing in the group's Members tab | that is where a live node connection exists |
| — | `gek-init` rewritten | not in the plan: it wrapped the GEK for keys fetched from the hub, which is the same substitution the design closes |
Two C5b tests were rewritten rather than kept: they asserted that
`gek_bundle_store` demanded an operator signature, and that message no longer
exists. They now assert the stronger property — that no member can hand the node
key material at all, and that the retired message reaches no handler.
### Slice 3 — the operator surface
| Shipped | Where |
|---|---|
| `member list` / `invite` / `revoke` / `unpin`, all over SSH, no browser | `daemon.main` |
| Roster endpoints behind the per-run session token (11.5.3) | `ui/app.py` |
| Roster section in the local admin UI, every value escaped (H2) | `ui/app.py._render_roster` |
| `_daemon_api` / `_resolve_group` — one loopback call path for every command | `daemon.py` |
| Codes written to `data_dir/invite-code` and `data_dir/pair-code` | `roster.write_code_file` |
Revocation tells the operator what it does **not** do: the ex-member stops
receiving the key on their next connection, but they still hold the current one,
so the message ends with the `gek-init` command that rotates it.
`member revoke`/`unpin` resolve a username against the roster and refuse an
unknown one rather than acting on nobody — a typo must not look like success.
**Tests: 158 node, 174 hub/common** (from 121/168 before this work).
### Code lifetimes (settled 2026-08-14)
| Code | Default | Configurable via |
|---|---|---|
| Member invitation | **7 days** | `[node] invite_ttl_hours` |
| Operator pairing | 24 h | `[node] pair_ttl_hours` |
They differ because the acts differ: an invitation waits for someone to read their
messages, a pairing code is typed during the SSH session that printed it. The
longer window costs little — single use, one account, never seen by the hub, and
40 bits do not fall to guessing in a week against the node-wide lockout.
---
## 10. Work to do
### Node
- `roster.py` — new module, the three tables and their queries
- `transport/webrtc_server.py` — `join_request` / `join_result` in the pre-proof
window (beside the existing bundle fetches, `:243-258`); delete member-supplied
`gek_bundle_store`; `OP_INVITE_CREATE` in the admin-op dispatch
- `transport/quic_server.py` — same handler via the shared path (11.5.4 parity test
must cover `join_request`)
- `daemon.py` — `_resolve_admin_pk` → roster lookup with the legacy config fallback;
new CLI commands; `status` output
- `ui/app.py` — roster and invites in the local admin UI, escaped as per 11.5.16
### Common
- `protocol.py` — four message constants
- `adminop.py` — `OP_INVITE_CREATE`
- `handshake.py` — expose `nonce_s` to the join transcript builder
### Hub
- **No change.** Membership endpoints stay as they are. Worth stating in the commit
message: the fix for H3 removes a hub responsibility rather than adding one.
### SPA
- Invite dialog shows the code and a copy button, instead of doing crypto
- Settings gains "Pair this browser with my node" (code entry)
- `join_request` on connect when no GEK is held; drop the wrap-and-store path
- Delete the `pubkeys` fetch from the invite flow — the line that is H3
### Tests (negative assertions, per §10 of v5)
```
test_join_requires_code_first_time — unpinned identity without a code is refused
test_join_rejects_key_swap — pinned user presenting a new key is refused
test_join_replay_across_connections — join_request bound to nonce_s
test_invite_requires_operator_role
test_gek_never_wrapped_for_hub_supplied_key — the H3 regression test
test_revoked_member_gets_no_gek
test_code_bruteforce_bounded
test_open_join_group_pins_on_first_contact — no code required, TOFU pin recorded
test_invite_group_still_requires_code — public visibility does not skip it
```
### Docs to rewrite
- `meshbay-draft-v5.md` §2 (H3 row in the claims table), §5.1 (the "a group admin
who does not run the node can no longer invite" consequence is reversed), §9
(H3 moves to closed; note what remains open — T3, C4)
- `devel-phases-next.md` — Phase 12.1 becomes this; 14.3/14.4 absorbed
- `second-review.md` — H3 and M3 marked closed by this design
- `QE/deploy/README.md` — `demo.py set-admin-pk` retired in favour of `operator pair`
---
## 11. Decisions
Settled 2026-08-13 with the operator:
| # | Question | Decision |
|---|---|---|
| 1 | Groups that skip the pairing code | **`join_policy == "open"` only** — asked as "public groups"; corrected to the admission axis, since `visibility` governs discoverability, not entry (§3.4) |
| 2 | Delegation (group admin ≠ node operator) | **Deferred.** Role value reserved, authorization written as a role check so it drops in later (§6.2) |
| 3 | Code TTL and length | 24 h, 40 bits, `XXXX-XXXX`. Default unless the first real use says otherwise |
| 4 | `gek_bundle_store` from members | Deleted, not disabled — it is the C5b surface and keeping it dead-but-present invites its return |
Items 3 and 4 are defaults chosen for v1, not constraints; both are one-line changes
if the deployment argues against them.
|