summaryrefslogtreecommitdiffstats
path: root/docs/invite-pairing-v1.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/invite-pairing-v1.md')
-rw-r--r--docs/invite-pairing-v1.md526
1 files changed, 526 insertions, 0 deletions
diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md
new file mode 100644
index 0000000..56fccbe
--- /dev/null
+++ b/docs/invite-pairing-v1.md
@@ -0,0 +1,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.