OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social commits / 3fa83e1

docs: the room contract — authority by carrier, Keet's membership rules
room.js adopted Keet's vocabulary but not its enforcement: every op is replayable, every device is an indexer, and add-member admits people without consent. Specifies the fix against keet-core 4.22.28, with the autobee weight-cap probe as a prerequisite for joining. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3fa83e1792987f9c2bacfda6abf27748a2a55c03
by plankatron <fleeky@gmail.com> · 2026-10-01T01:29:12.000Z

Changed files (1) +326 -0

docs/ROOM-CONTRACT.md +326 -0
……@@ -0,0 +1,326 @@
1# The room contract — authority by carrier, Keet's membership rules
2
3**Date:** 2026-09-30 · **Status:** spec, nothing built · **Follows:** #62 §3, #74 §6–§8, `FAVELA-WEB-RETIREMENT.md`
4**Replaces:** the authorisation model of `favela-peer/lib/room.js` (0.0.58) · **Reference:** keet-core **4.22.28**
5(`hyper-fleeky/keet-audit-4.22/pretty/@holepunchto/keet-core`), autobee 2.11.8
6
7#62 §3 said "stop designing the room and adopt keet's contract". `room.js` adopted Keet's **words**
8(admin / moderator / peer / read-only, a closed config set) and not its **enforcement**. This document
9specifies the enforcement. Every Keet claim below cites the file it was read from on 2026-09-30;
10every `room.js` claim cites the line it was read from the same day.
11
12---
13
14## 1. Why — three defects that share one cause
15
16**1.1 Every op can be replayed.** `authorise()` (`room.js:93-98`) checks the op version, the room key
17and the signature over the op bytes. There is no nonce, no seen-set, and nothing ties the op to the
18writer core that carried it. So a correctly signed op is valid every time it appears, from anyone:
19
20| Replayed op | Effect |
21|---|---|
22| `add-member` | re-admits a removed member (`:214` accepts `existing.removed`) |
23| `admit-device` | re-admits a removed device (`:270-279` never reads the device's `removed`) |
24| `set-role` | re-promotes a demoted member |
25| `config` | reverts a config change |
26| `message` | reverts an author's edit (same author passes `:340`) |
27
28**1.2 Authority and weight are separate facts.** A role lives in the view; weight lives in autobee.
29`room.js` makes **every** admitted device an indexer whatever its member's role (`:179`, `:266`,
30`:278`), so a `peer` member's laptop carries the same weight as the founder's.
31
32**1.3 A person can be added without agreeing.** `add-member` (`:209-217`) appoints an identity root
33directly. `:254` already argues this is wrong for a device ("admitting somebody TO a room rather than
34WITH them"); it is equally wrong for a person.
35
36**The common cause.** `room.js` authorises by the identity root signed **into the payload**, not by
37the core that **carried** it. Its own reason (`:128-136`) is relaying: one node appending another's
38op. That case came from the first draft of the tests (`:133-135`). In Keet nobody relays: each device
39appends only to its own core, and that core *is* the authority.
40
41---
42
43## 2. What Keet does (4.22.28, read in source)
44
45Paths are under `lib/core/` unless noted.
46
47### 2.1 Authority is the carrier
48
49- A device is attested to its member **once**, when it is added: `verifyAttestation(proof, deviceKey,
50 {expectedIdentity: memberKey})` (`invitations/index.js:121-130`).
51- After that every op is authorised by **which writer core carried it**:
52 `getMemberForDeviceKey(writer.key)` → `isMemberActive` → role flags (`contract/query.js`). Ops carry
53 no signature. `chat.js:14-24` is the pattern every handler follows.
54- Replay is impossible by construction: only that device can append to its core.
55
56### 2.2 Admin is weight
57
58- `roles.js`: `ADMIN {weight: 2}`, `WRITER {weight: 1}`, `READER {weight: 0}`.
59- A device added with `isIndexing` makes its member an admin (`members/index.js:45`) and is added at
60 `ADMIN` weight (`:53`). Everyone else's devices are `WRITER`.
61- Demoting an admin re-weights each of their active devices to `WRITER` (`:394`).
62- Keet runs rooms on **autobee** (`members/index.js:1`; `autobee 2.11.8` in the audit's `dep-versions.json`).
63
64### 2.3 Removal and status — two different mechanisms
65
66- **`@keet/remove-member`** (`members/index.js:404-445`):
67 - by a moderator → `isRemoved: true` (`:425`), and **nothing anywhere clears it**. Rejoining through
68 an invitation aborts (`:149`); adding a device by member proof needs `isActive` (`:323`, ABI ≥ 12).
69 **Permanent.**
70 - by the member themself → inactive and `isPending`, **not** `isRemoved` (`:424-425`). **Reversible.**
71 - an admin can only be removed by themself: `SELF_ONLY` (`:419`).
72 - all the member's devices are de-weighted (`removeWriter`) and marked inactive (`:426-433`).
73- **`@keet/change-member-status`** (`:366-374`): moderator-only toggles for `isMuted`, `isBanned`,
74 `isShadowBanned`, in both directions.
75 - **Mute is enforced in apply:** `chat.js:24` (`WRITER_IS_MUTED`), reactions (`reactions.js:12,27`),
76 labels (`labels.js:23,53`); muted members cannot create invitations (`members/index.js:207`).
77 - **Ban and shadow-ban are stored and never read** anywhere in keet-core. Whatever they do happens
78 in the client.
79
80### 2.4 Admins
81
82- `@keet/change-member-role` (`:375-403`): the actor must be an active moderator; touching an admin, or
83 granting admin, requires the actor to be an admin (`:388`).
84- **An admin can demote another admin** — unless the target is the **last** admin (`:392-393`), or a
85 device cannot be de-weighted (`removeable` false → no change, `:394`).
86- So "only an admin can remove themself" holds for `remove-member` only. In two steps (demote, then
87 remove) an admin can push out another admin.
88
89### 2.5 Joining
90
91- **Invitations are created in apply by any active member** (`:200-222`), unless muted (silent no-op),
92 or the room sets `isRoomInviteHidden` (moderators only, `:211-213`). An invitation's
93 `canModerate`/`canAdmin` are capped at the inviter's own (`:215-216`). DM rooms take none (`:209`).
94- An invitation is a random seed; its **id is the seed's public key** (`invitations/index.js:92-106`).
95- The joiner's request is signed with the invitation secret, encrypted to the room mailbox
96 (`members/index.js:279-280`), and **names the joiner's writer key, which must be the writer carrying
97 it** (`:297`).
98- **Admission happens in apply, on the joiner's own block** (`:307-310`):
99 - no confirmation required → admitted at once with the invitation's roles (`→ :146-153`);
100 - `requiresConfirmation` → stored as a join request; **any active member** may accept or reject
101 (`:181-199`).
102- Admin/moderator rights on an invitation are **single-use**: cleared after the first join
103 (`:169-171`). A plain membership invitation stays reusable.
104- An existing member adds another device **without an invitation**, by member proof (`:313-328`).
105- **Not checked in apply:** an invitation's `isActive` (cleared by `remove-invitation`, `:226`, never
106 read by the join path, `:303`) and its `expiration`. Possibly enforced at the pairing layer; unverified.
107
108### 2.6 Settings
109
110One closed set, moderator-gated in apply (`internal.js:82-91`): `roomType`, `isExperimental`,
111`isJoiningDisabled`, `isAppendingDisabled`, `isCallingDisabled`, `isMembershipEventsDisabled`,
112`isAttachmentsDisabled`, `isLinksDisabled`, `isRoomInviteHidden` (`:14-24`). Extended only by an ABI
113bump. Authority is never read from message content.
114
115---
116
117## 3. The contract
118
119Normative. "Apply" means `applyOps` in `room.js`; everything here is enforced there, nowhere else.
120
121### 3.1 Authority by carrier
122
1231. **An op's actor is the member owning the writer core that carried it**:
124 `device = devices[node.key]`, `member = members[device.member]`. Unknown writer → op ignored.
1252. **Room ops carry no signature and no `root`.** The `room` anti-lift field and `canonicalOpBytes`
126 go: an op cannot be lifted into another room because a writer core belongs to one room only.
1273. **Signatures remain in exactly two places**, both about binding a *new* writer core to a member:
128 the founding op (§3.4 `found`) and admitting a device (§3.6). They are verified once, at admission;
129 afterwards the core is the credential.
1304. **A device is inactive once removed, for good.** A removed device's core is de-weighted
131 (`removeWriter`) and its later blocks are ignored by rule 1. Coming back means a new device.
1325. `OP_VERSION` becomes **2**. A v1 op is ignored.
133
134### 3.2 State
135
136```
137__founded__ { member }
138__members__/<member> { role, isActive, isRemoved, isMuted, isBanned }
139__devices__/<writerHex> { member, device, isActive } ← keyed by WRITER key, the lookup rule 3.1.1 needs
140__invites__/<inviteId> { by, role, requiresConfirmation, isActive, used }
141__requests__/<writerHex> { invite, member, device } ← only when the invite requires confirmation
142__settings__ { …closed set, §3.9 }
143message/<name> { author, body, at } | { author, deleted: true, by }
144__flags__/<target>/<member> { reason, at }
145listing/<roomKey> { note, by } | { removed: true }
146```
147
148`member` is the identity root (hex), as today.
149
150### 3.3 Roles and weight
151
152| Role | Device weight | May |
153|---|---|---|
154| **admin** | **2** (indexer) | everything below, plus role changes involving admin, settings, admitting devices |
155| **moderator** | 1 | remove members, change status (mute/ban), delete others' messages, list/delist, revoke invites |
156| **peer** | 1 | post, flag, invite (unless `inviteHidden`) |
157| **read-only** | 1 | flag, leave; **cannot post** (enforced in apply) |
158
159- **Admin ⇔ weight 2.** Granting admin re-weights every active device of that member to 2; removing it
160 re-weights them to 1. The role and the weight change in the **same apply**, never one without the other.
161- `read-only` is ours, not Keet's (Keet expresses it per room with `isAppendingDisabled`). Kept because
162 a community room wants some members who read and flag but don't post.
163
164### 3.4 Ops
165
166| Op | Actor | Effect | Keet |
167|---|---|---|---|
168| `found` | genesis block | founder = admin, founder's device at weight 2; optional `initialMembers` | `internal.js:25-68` |
169| `invite` | active, not muted; moderator if `inviteHidden` | records an invitation; role capped at the actor's | `members/index.js:200-222` |
170| `uninvite` | moderator, or the inviter | `isActive: false` | `:223-227` |
171| `join` | the joiner's own block | §3.6 | `:273-311` |
172| `accept-join` / `reject-join` | any active member | admits / drops a pending request | `:181-199` |
173| `add-device` | the new device's own block, with a member proof | §3.6 | `:313-328` |
174| `remove-device` | the member owning it | device inactive, de-weighted | `:345-365` |
175| `set-role` | moderator; **admin** if the target is or becomes admin | §3.5 | `:375-403` |
176| `set-status` | moderator | toggles `isMuted` / `isBanned` | `:366-374` |
177| `remove-member` | moderator, or the member themself | §3.5 | `:404-445` |
178| `message` | active, not muted, not read-only, not banned | post or revise **own** message | `chat.js:14-24` |
179| `delete-message` | the author, or a moderator | tombstone `{deleted, by}`; body dropped from the view | — |
180| `flag` | active, not muted | records a flag; grants nothing | `labels.js` |
181| `settings` | moderator | §3.9 | `internal.js:82-91` |
182| `list-room` / `delist-room` | moderator | unchanged from `room.js:292-312` | — |
183
184### 3.5 Removal, status and admins
185
186**Removal.**
187- By a moderator: `isRemoved: true`, **permanent**. Nothing clears it. `join` and `add-device` abort
188 for a removed member.
189- By the member themself (leaving): inactive, **not** removed. They may come back through an invitation.
190- Either way, every device of the member is de-weighted and marked inactive in the same apply.
191
192**Status — the reversible tools.**
193- `isMuted`: enforced — `message`, `flag` and `invite` from a muted member are ignored.
194- `isBanned`: **enforced in apply as "cannot post or invite", reversible.** This is the one place we go
195 past Keet, which stores ban and never reads it. ⚠ Decide before building (§8.1).
196- So a community has a ban it can lift (`set-status`) and a removal it cannot (`remove-member`).
197
198**Admins.**
199- `remove-member` on an admin is valid **only from that admin** (Keet `SELF_ONLY`).
200- `set-role` may demote an admin, actor must be an admin, and **never the last admin**.
201- Demotion re-weights the target's devices to 1 (§3.3).
202- ⚠ `room.js` today lets a **moderator** remove an admin (`:234-237`). That goes.
203
204### 3.6 Joining and devices
205
206**`join`** — appended by the joiner's own, new writer core:
207```
208{ t: 'join', v: 2, invite: <inviteId>, member: <root>, device: <deviceKey>,
209 attestation: <device→member proof>, sig: <signature by the invitation secret over the rest> }
210```
211Apply checks, in order: the invitation exists and `isActive`; `sig` verifies against `inviteId`;
212the attestation binds `device` to `member`; the carrying writer is the one the attestation names;
213`member` is not removed. Then:
214- invitation without confirmation → member created (or reactivated if they had left) at the
215 invitation's role, device added at the role's weight;
216- with confirmation → a `__requests__` row; nothing granted until `accept-join`.
217
218Unlike Keet we **do** check `isActive`, so `uninvite` actually revokes. No expiry: apply has no clock
219(`namespace.js:79-80` reached the same decision). An admin/moderator grant on an invitation is
220**single-use**; plain membership invitations are reusable until revoked.
221
222**`add-device`** — a member's new device, appended by that device's own core, carrying the member
223proof. Valid only if the member is active and not removed.
224
225**DMs.** The `dm` verb (`rpc.js:342-349`) uses `add-member`. It becomes a `found` with both members
226in `initialMembers`, which is how Keet makes a DM (`internal.js:57-64`).
227
228### 3.7 Moderation scope
229
230Binding moderation happens **inside the room**, through the ops above. A cross-room labeler is its own
231room whose flags are advisory: another room acts on them only through its own moderators' ops (#74 §6,
232"advisory across, binding within"). No separate moderation room for in-room decisions.
233
234### 3.8 What is deleted from `room.js`
235
236- `authorise`, `canonicalOpBytes`, per-op `proof` / `root` / `room` / `at`, `roleOfSigner`
237- `add-member` (replaced by invitations and `initialMembers`)
238- `request` / `admit-request` (replaced by `join` + confirmation) and `admit-device` (replaced by `add-device`)
239- `{ isIndexer: true }` on every admitted device
240
241### 3.9 Settings
242
243One `settings` op, moderator-gated, over a **closed** set. It replaces `CONFIG_KEYS` (`room.js:61`)
244and the `_hs:room-meta` message convention (`ROOMS-ARE-THE-COMPUTER.md:51`), which put
245authority-bearing state where apply cannot gate it.
246- Carried over: `title`, `description`, `avatar`, `supportedLanguages`, `blindPeers`.
247- From Keet: `isJoiningDisabled`, `isAppendingDisabled`, `inviteHidden`.
248- favela's own, from #74 §6: `public`, `bridgedTo`, `curation`. ⚠ Shape not settled (§8.2).
249
250Adding a key bumps `OP_VERSION`.
251
252---
253
254## 4. ⛔ Prerequisite — can a joiner's own block admit it?
255
256The whole of §3.6 assumes a `join` on the joiner's own block can give that block's writer weight.
257**On autobee 2.1.1 it cannot**, measured 2026-09-07 (`favela-peer/test/namespace-admin.test.js:30-44`,
258`probe-weight.mjs`): `system.addWriter` caps a grant at the weight of the carrying block, and a
259joiner's block has none.
260
261**The cap is identical in 2.11.8**, read 2026-09-30 in
262`keet-audit-4.22/extracted/node_modules/autobee/lib/system.js`:
263`effective = coord && carrier < weight ? max(carrier, 0) : weight`.
264Yet Keet admits from the joiner's own block (`members/index.js:307-310`, `addWriter` at `:53`).
265Both versions also record the requested weight as `pending` and have a `promotions` module
266(`pendingPromotion`, `grantHint`). **That promotion path is the likely answer and has not been read.**
267
268Before any of §3.6 is built:
2691. Read `autobee/lib/promotions.js` (2.1.1 and 2.11.8).
2702. Probe on **fresh test storage**: a weightless joiner calls `addWriter(self, weight 1)` in apply;
271 record `maxWeight` and `weight` on both peers before and after an admin's next block.
2723. If the grant is promoted when a weighted writer next appends: §3.6 stands, admission completes when
273 any weighted member is online.
2744. If not: invitations still work, but the admit is carried by any online member's node, unattended —
275 the pattern `namespace.js:73-77` already ships. §3.6's checks are unchanged; only the carrier differs.
276
277Sequence this with the autobee 2.1.1 → 2.12.x upgrade (#74 §8 step 2), not before it.
278
279---
280
281## 5. Migration
282
283- v1 ops are ignored by a v2 apply, so a v2 node opening a v1 room sees an empty room.
284- **Unknown, check first:** whether any `room.js` room on tabertron, stortron or ashatron holds state
285 anyone needs. loser-club is **not** one — it is on the old favela-community stack (#74 §7).
286- If none: no migration; v2 rooms are new rooms.
287- If some: a one-shot re-founding (the founder founds a v2 room, re-invites members) — no in-place upgrade.
288
289---
290
291## 6. Tests — written first, red for the right reason
292
293Each against today's `room.js`, then against v2:
2941. **Replay**, one per row of §1.1: re-append a captured op from another writer → ignored.
2952. A peer's device has weight 1; an admin's has weight 2; promotion and demotion move weight in the same apply.
2963. A moderator removing an admin is ignored; the admin leaving is accepted.
2974. Demoting the last admin is ignored.
2985. A removed member: `join` with a valid invitation aborts; `add-device` aborts. A member who left can rejoin.
2996. Muted: `message`, `flag`, `invite` ignored; unmuted: accepted.
3007. `uninvite` → a later `join` with that invitation is ignored.
3018. A `join` whose attestation names a different writer than the carrier is ignored.
3029. `delete-message` by a non-author peer is ignored; by the author or a moderator, tombstoned.
30310. An admin grant on an invitation works once; the second join gets the plain role.
30411. **The §4 probe**, as a test, so the substrate assumption is checked on every autobee bump.
305
306---
307
308## 7. Deliberately not copied from Keet
309
310- **Unenforced ban.** Keet stores `isBanned` and never reads it; we enforce it (§3.5), pending §8.1.
311- **Revoked invitations that still admit.** Keet's join path does not read `isActive`; ours does.
312- **Shadow-ban.** Needs every reader to agree to hide someone; it is a client display rule, not apply state.
313- **Any member may accept a join request.** Copied as is, but worth confirming for communities (§8.3).
314- **The engine.** Keet is on autobee 2.11.8; we are on 2.1.1. The upgrade is #74 §8 step 2.
315
316---
317
318## 8. Open
319
3201. **Ban in apply, or display-only like Keet?** Enforcing it gives communities a liftable ban that
321 actually stops posting; leaving it to the client matches Keet and keeps apply smaller.
3222. **Shape of `public` / `bridgedTo` / `curation`** in the closed settings set (#74 §6 named them; not designed).
3233. **Who may accept a join request** — any active member (Keet) or moderators only?
3244. **`namespace.js` shares `room.js`'s per-op signature model** (`authorise`-style proofs). Not audited
325 here; it needs the same replay check.
3265. Whether `read-only` should exist as a member role at all, or only as the room setting `isAppendingDisabled`.