diff options
Diffstat (limited to 'docs/invite-pairing-v1.md')
| -rw-r--r-- | docs/invite-pairing-v1.md | 559 |
1 files changed, 0 insertions, 559 deletions
diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md deleted file mode 100644 index 8eb378f..0000000 --- a/docs/invite-pairing-v1.md +++ /dev/null @@ -1,559 +0,0 @@ -# MeshBay — Invitation and Pairing (design) - -> **Superseded by `MESHBAY_DESIGN.md`.** This was the admission and pairing design; its design -> content now lives in §3.4 and §3.5. -> -> It is kept because code comments, tests and other documents cite its -> sections and its labels, and because it records reasoning a synthesis -> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design -> document is right; where either disagrees with the code, the code is.** -> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its -> replacement, and §13 defines every label. - -> Status: **implemented and deployed** (2026-08-14). Written 2026-08-13 as a proposal; -> §9bis records what shipped, what departed from the plan, and what a browser found -> that the automated run could not. -> 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) group → Members tab → "Pair this browser" → 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` became a legacy -fallback, and was removed on 2026-08-15: the roster is the only source of operator -authority. A config that still names the key is warned about at startup, so a -deployment relying on it learns why its invites stopped rather than discovering a -signature error. - -### 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, node_pk} ← bob signs over both -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 | Ignored since 2026-08-15, with a warning at startup naming the config file. The operator pairs a browser; there is no second path | - ---- - -## 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, and from nowhere else | `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. - -### What a browser found that the harness could not - -The design was exercised end to end by `QE/deploy/e2e.py` and passed. It was then tried in -a browser and failed four times, each for a different reason, none of them the pairing code: - -| Symptom | Cause | -|---|---| -| No code ever requested, "no GEK available" | the browser held no identity keys — session restore never re-read the encrypted bundle sitting in its own localStorage | -| "No nodes available" | the node had gone deaf: its hub socket sat in CLOSE-WAIT because the WebRTC offer handler was awaited inside the read loop (a Phase 11 bug) | -| "Not a member of this group" | the token predated being added to the group; `groups` is baked in at sign-in, and nothing refreshed it | -| "Handshake incomplete — reconnect and retry" | `joinGroup()` read `node_pk` and `nonce_node` before they were assigned | - -The common thread is worth keeping: **e2e.py is a second implementation of the client**, -written in the right order by construction, so it proves the protocol and nothing about -`app.js`. Three of those four were ordering or plumbing mistakes it could never see. Hence -`packages/meshbay-hub/tests/test_spa_ordering.py`, which asserts on source order — inelegant, -and the only thing that catches them. - -### 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 view in the desktop client's Node page (Preact escapes by default; H2) | `static/node-page.js` | -| `_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 over the token-gated loopback API (JSON) - -### 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. |