diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index 1b042ba110..c3769c53a1 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -42,7 +42,7 @@ x.grp.promote.cancel { invitationId } -- O1 -> M ``` - `memberRole` — the role offered; the generic discriminator. Shown in M's prompt. -- `roleData` — an optional role-scoped block, present iff the role needs one; validated as required-for-`GROwner`, absent otherwise. For owner: `{ linkRcvId }` in the invitation, `{ linkRcvKey }` in the acceptance. A new role adds a new variant of this block; older clients ignore an unknown one (the codebase's optional-field forward-compat, `omittedField`). Concretely a sum tagged by role — `PromotionRoleData = PRDOwner OwnerPromoteData | …` — carried optionally, so "no block" and "owner block" are both representable and a future role extends the sum. +- `roleData` — an optional role-scoped block, present iff the role needs one; validated as required-for-`GROwner`, absent otherwise. For owner: `{ linkRcvId }` in the invitation, `{ linkRcvKey }` in the acceptance. Concretely a sum tagged by role — `PromotionRoleData = PRDOwner OwnerPromoteData | …`. Forward-compat for a *future* role is not free: `omittedField` only supplies a default when the key is **absent**, so a present block with an unknown role tag would fail to parse on an old client, not be ignored. Since only owner exists now this is latent, but the parser should decode an unrecognised `roleData` tag to an opaque/ignored variant (not error) so a future role degrades gracefully — the same discipline as `XUnknown`/`CIInvalidJSON` elsewhere. - `invitationId` — binds accept/reject/cancel to one invitation; blocks relay replay. - `memberKey` — M's existing member key, restated on acceptance so O1 binds the verified key. Generic across roles: O1 already holds it and **must** check they match and abort otherwise, so a promotion can never introduce a key. - `linkRcvId` / `linkRcvKey` — owner-only, inside `roleData`: the link queue's recipient ID, and a fresh recipient auth **public** key M generates. @@ -51,11 +51,13 @@ Everything else M needs to write link data is public or derivable: `shortLinkKey `linkRcvId` authorises nothing on its own (`Server.hs:1249` verifies every recipient command against `recipientKeys`), which is equally why the relay carrying these learns nothing. -**Acceptance is not automatic, and either side can withdraw.** On `x.grp.promote.inv`, M stores a pending promotion and surfaces it (§7); it does not act. Only when the user confirms does M generate `linkRcvKey`, send `x.grp.promote.acpt`, and move the record to accepted-pending-completion. Decline sends `x.grp.promote.reject` and clears it; O1 marks the invitation rejected and notifies its UI, mirroring the relay-rejection path (`x.grp.relay.reject`). O1 can withdraw a still-pending invitation with `x.grp.promote.cancel`, which clears M's pending record and its banner. An ignored invitation stays pending until it expires on the `relayRequestExpiry` pattern, or is superseded by a fresh invitation. Cancel racing acceptance is benign: if it arrives before the role event, O1 simply does not complete the worker; if after, the owner is already committed and cancel is a no-op (removal is §11). +**Acceptance is not automatic, and either side can withdraw.** On `x.grp.promote.inv`, M stores a pending promotion and surfaces it (§7); it does not act. Only when the user confirms does M generate `linkRcvKey`, send `x.grp.promote.acpt`, and move the record to accepted-pending-completion. Decline sends `x.grp.promote.reject` and clears it; O1 marks the invitation rejected and notifies its UI, mirroring the relay-rejection path (`x.grp.relay.reject`). O1 can withdraw a still-pending invitation with `x.grp.promote.cancel`, which clears M's pending record and its banner. An ignored invitation stays pending until it expires on the `relayRequestExpiry` pattern, or is superseded by a fresh invitation. Cancel racing acceptance is benign: if it arrives before the role event, O1 simply does not complete the worker; if after, the owner is already committed and cancel is a no-op (removal is §12). This is consent, and — combined with §8 — a second lock. §8 gives O1 M's out-of-band-verified key; the acceptance is signed by M's real key, which O1 checks equals the verified one, so a relay that never delivered the invitation cannot forge an acceptance. It does **not** by itself stop the §8 relay-substitution attack (a malicious relay short-circuits M entirely) — that remains the verified key's job. -The wire already anticipated this: nothing in inv/acpt changes. The additions are one reject event, a stored pending record on each side (§5), and the accept/reject UI (§7). +Promotion is single-target: `APIMembersRole` takes a member list, but `newRole == GROwner` on a channel accepts exactly one member (each promotion is a separate verified, async, consent-gated flow), mirroring the existing `"can't change role of multiple members … or new role is admin"` guard (`Commands.hs:2879`). + +**Open — the delivery transport is wrong as written.** "M's support scope" (`MSMember`) is not a private owner↔M channel: it fans out to M **plus every moderator, admin and owner** (`Subscriber.hs:4276-4290`, `Groups.hs:1273`), a sender cannot target one owner, and a plain observer has no support scope until one is initiated. So routing `x.grp.promote.*` through it leaks the governance action to non-owner moderators. This is the same root gap as owner-removal's key-attribution (§12): the codebase has no **owner-only** delivery/visibility — only `≥ moderator` support scope and public broadcast. Options: (a) accept moderator visibility of promotions (they see the fact, not any secret — `linkRcvId`/`linkRcvKey` are inert/public); (b) build an owner-scoped delivery (fan out to `getGroupOwners` + the target M, excluding mods) — reused by §12; (c) a direct O1↔M member connection (private E2E, but requires M be directly reachable, narrowing who can be promoted). Recommend (b): one primitive unblocks both this and removal. O1 on receiving `x.grp.promote.acpt`, via a durable worker (mirror `runRelayRequestWorker` — multi-step, cross-network, must survive a crash): @@ -67,7 +69,7 @@ O1 on receiving `x.grp.promote.acpt`, via a durable worker (mirror `runRelayRequ Add the keys to `QueueInfo`: `QUE` is already a recipient-authorised command returning queue state, so this is a field, not a mechanism, and only a recipient sees it. `RKEY` then takes an expected fingerprint of the set, as `LSET` does (§4.2), or two owners adding concurrently each replace it with a list missing the other's addition; the rejection returns the current set, so a retry needs no second read. `recipientKeys :: NonEmpty` (`QueueStore.hs:37`) prevents emptying it. One concurrency pattern for both mutable server-side objects. -The set is **anonymous** — public keys with nothing tying a key to an owner. That is sufficient here: adding means appending and keeping the rest, which needs no idea whose the others are. Removing a *named* owner does need that mapping, and it is part of removal's design (§11), not this one. +The set is **anonymous** — public keys with nothing tying a key to an owner. That is sufficient here: adding means appending and keeping the rest, which needs no idea whose the others are. Removing a *named* owner does need that mapping, and it is part of removal's design (§12), not this one. **The role event is the commit point.** M must not treat itself as an owner on sending the acceptance, only on receiving `x.grp.mem.role` for itself — which the existing `xGrpMemRole` self-branch (`Subscriber.hs:3338`) already applies. O1 sends it only after 1 and 2 succeed, so the event means *the link says you are an owner*. Set the role earlier and any failure or crash between acceptance and LSET leaves M believing it is an owner while absent from the chain: it would write link data and be refused, and sign admin messages that every subscriber rejects, since its key is not in the published owners. @@ -94,9 +96,17 @@ Permanent divergence: `fresh = maybe True (v >=) gate` (`Subscriber.hs:3302`; `n **Fix: tie-break on the author's `memberId`** — accept iff `(v, authorMemberId) >= (gate_v, gate_authorMemberId)`. `memberId` is 12 random bytes, identical on every device, so every node picks the same winner and both relays converge on B regardless of order. Keep `>=` not `>`: an equal tuple from the same owner must still accept (`Subscriber.hs:3288-3291`). `roster_sending_owner_gm_id` exists on `groups` but is a **local row id**, not comparable across devices → add `roster_version_owner_id BLOB`. +The tie-break must be applied at **three** gates, not two — the owner↔owner convergence path is the roster *blob*, which is applied later at `rosterCompletion` (`Subscriber.hs:3461`), whose gate is version-only (`maybe False (pendingVer < cur)`) and which persists the local `ownerGMId`, not a cross-device author. Two same-version blobs from different owners arrive as independent per-source transfers that both pass the header gate (`:3430`); whichever *completes* last wins at `:3461` regardless of author, so nodes still diverge. So: thread the author `memberId` into `setGroupLiveRoster` and store it in `roster_version_owner_id`, and apply the tuple compare at all of `applyAtRosterVersion` (`:3302`), the header `notBelowRoster` (`:3430`), and the completion gate (`:3461`). + **Rosters must reach owners.** `sendRosterBlob` targets `getGroupRelayMembers` only and the relay→subscriber broadcast was deliberately removed, so an owner never sees another's roster and its counter goes stale. Relays forward `XGrpRoster` + `BFileChunk`s to `GROwner` members excluding the author — bounded fan-out, owners only. -The loser's change is dropped: it adopts the winner's roster and self-corrects. **No auto-retry** — re-applying a delta to a whole-value object is two owners fighting. UI surfaces the revert; the user re-applies if they still want it. +The tie-break makes every node *converge* — this part is sound and is new work (the gate today is `>=`, arrival-order LWW, `Subscriber.hs:3304`; no tie-break exists). Two things the earlier framing got wrong, both confirmed against the code: + +**Loss is silent — there is no detection path.** Applying an adopted roster (`processRosterEntries`, `Subscriber.hs:3497`) is a full overwrite: every named member's role is set, and any privileged member absent from the blob is reverted to default. It overwrites the receiving owner's own just-made change, and **no pending/optimistic state records what that owner changed**, so nothing can notice the revert — the owner's UI shows only an ordinary `CEvtMemberRole` attributed to the other owner. "UI surfaces the revert" has no mechanism. + +**Whole-value LWW over-loses.** Role changes are per-member and mostly compose: O1 promoting X and O2 promoting Y do not conflict, yet LWW drops one. Treating the roster as a whole value (like the profile) is wrong for it. + +Resolution (decision): the roster wants a **lightweight per-member reconciliation**, not whole-value LWW. Persist the owner's own outstanding role deltas; on adopting a newer roster, re-apply the deltas whose member the winner did not also touch (converges to the union), and surface only a genuine same-member conflict for the user to resolve. This is a small step toward the overview's deferred "linearly ordered signed roster log", stopping well short of it. The alternative — ship whole-value LWW with silent loss — is simpler but drops compatible concurrent changes and cannot honour the "surface the revert" promise. Frontier logic is unaffected: the counter stays one shared sequence, so no gaps in `nextCompleteVersion`. @@ -119,6 +129,8 @@ Publishing stays per join. `updatePublicGroupData` keeps its trigger — it does This also disposes of the wider worry: the count was the only thing needing a *complete* member list, so a promoted owner's partial one stops mattering. Moderation learns members lazily on first post (`Subscriber.hs:4195`); the roster arrives as a served snapshot on join. +Residual, accepted for now: the single publisher still writes per join, and on a very busy channel a concurrent human write (profile/relay) can be CAS-rejected and retried repeatedly against the stream of count writes — bounded by the join rate, not a tight loop. Per-join was chosen over coalescing deliberately (a moving count signals life); rate-limiting the count publish is the lever if this proves painful in practice. + A relay can state a false number, but it can already do that by fabricating or withholding announcements — the threat model records count manipulation as detectable-not-prevented, unchanged. **Writes become deltas.** `LGET` → apply only this write's delta → `LSET`: @@ -206,7 +218,8 @@ Two owners genuinely disagreeing — one adding a relay, the other removing the - New service chat item on M — a `RcvGroupEvent` constructor (e.g. `RGEMemberPromotionInvited {role}`) created in M's support scope, so the invitation shows as unread exactly as `RGENewMemberPendingReview` does for knocking. Cleared/replaced on accept, reject, cancel, or commit. - New commands: `APIAcceptRolePromotion groupId` (M generates owner `roleData`, sends `x.grp.promote.acpt`, marks accepted-pending), `APIRejectRolePromotion groupId` (sends `x.grp.promote.reject`, clears), `APICancelRolePromotion groupId groupMemberId` (O1 sends `x.grp.promote.cancel`, clears the proposal). Role-generic names, owner-only behaviour today. Handlers in `Commands.hs`; wrappers + strings in both apps. - New events `x.grp.promote.inv`/`.acpt`/`.reject`/`.cancel` (`Protocol.hs`, all `requiresSignature`; role-generic packet per §3). `.reject`/`.cancel` mirror `XGrpRelayReject`. -- Inbound handlers in `Subscriber.hs`: `.inv` on M creates the record + service item + UI event; `.acpt` on O1 starts the durable worker; `.reject`/`.cancel` clear the record and proposal on the respective side. +- Inbound handlers in `Subscriber.hs`: `.inv` on M creates the record + service item + UI event; `.acpt` on O1 starts the durable worker; `.reject`/`.cancel` clear the record and proposal on the respective side. **`.inv` must verify the sender is an owner** (owner-signed, key resolved from link data) and reject otherwise — a moderator is inside M's support scope and `memberCanSend` permits any `≥ GRModerator` to send into it (`Subscriber.hs:1707`), so without this check a moderator could inject a promotion invitation. Likewise `.acpt`/`.reject`/`.cancel` match an existing pending record by `invitationId` on the acting side and are ignored otherwise (so the support-scope fan-out to other owners/mods is inert for them). +- **Relay + recipient forward dispatch for the promotion events** — they travel owner→relay→M (and back), so the relay's `processEvent` (`Subscriber.hs:1070`, else `:1121` drops as "unsupported message") must return a `DJSMemberSupport` delivery task for them, and the recipient's `processForwardedMsg` (`:3887`, else `:3910`) must handle them; keep the `isForwardedGroupMsg` set (`Protocol.hs:552`) in sync. Without these three sites the relay silently drops every promotion event and the flow cannot complete. - `Commands.hs:4016` — reorder: write link, then commit and broadcast (§4.2). - `Subscriber.hs:3345` `allowCreate` — widen so an owner-signed `x.grp.mem.role` with a key can TOFU-create a `GROwner`; `isRosterRole GROwner == False` blocks it today, so subscribers can never materialise a new owner. - `Commands.hs:2936` `mKey` — send the key on owner promotion, not only when a roster version is present. @@ -224,10 +237,12 @@ Two owners genuinely disagreeing — one adding a relay, the other removing the ## 6. simplexmq work - `Crypto/ShortLink.hs` — extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth`; redefine `newOwnerAuth` on it; export. Kills the cross-repo duplicate. -- Persist `linkRootSigKey` — `rcv_queues` column + migration (`AgentStore.hs:2514`). +- Persist `linkRootSigKey` — `rcv_queues` column + migration in **both** the SQLite and Postgres agent-store trees (`Agent/Store/{SQLite,Postgres}/Migrations`, selected by `-fclient_postgres`), read by the shared `AgentStore.hs:2514`. - Recipient keys readable + `RKEY` compare-and-swap (§3): add the keys to `QueueInfo` (`QUE` is already recipient-authorised), and give `RKEY` an expected fingerprint with the rejection returning the current set — the same shape as `LSET`, so one pattern covers both. Without the read a promoted owner cannot RKEY without evicting the others; without the fingerprint two concurrent additions clobber. Plus the agent API. - Link-write API for an owner that does not own the queue. Prefer a standalone `setForeignLinkData` taking explicit creds over faking an `RcvQueue`/`ContactConnection`: the latter needs an `rcvDhSecret` owner 2 must not have, and risks it subscribing to owner 1's queue and racing on inbound requests. -- CAS on `LSET` (§4.2): new command tag carrying the expected fingerprint; new `BrokerMsg` for the rejection carrying current user data; `currentSMPClientVersion` 4 → 5. Server check beside `lnkId' /= lnkId -> err AUTH` (`Server.hs:1484`) — bytes to hash and return are already in `queueData qr`, so no storage change. Agent keeps the fingerprint with link creds. Fall back to blind writes below v5. +- CAS on `LSET` (§4.2): new command tag carrying the expected fingerprint; new `BrokerMsg` for the rejection carrying current user data. **Version on the right axis:** these are `Command Recipient`/`BrokerMsg` values, encoded via `ProtocolEncoding SMPVersion` branching on `VersionSMP` (like `shortLinksSMPVersion = 15`) — so gate them on a bump of `currentServerSMPRelayVersion` (`VersionSMP` 18 → 19, `Transport.hs:234`), **not** `currentSMPClientVersion` (that rides only in the client↔client envelope and gates nothing server-facing). Same for RKEY-CAS and the QueueInfo keys field. Server check beside `lnkId' /= lnkId -> err AUTH` (`Server.hs:1484`) — bytes to hash and return are already in `queueData qr`, so no server storage change (the *agent* still needs a fingerprint column, below). Agent keeps the fingerprint with link creds. +- **Old-server behaviour differs by operation and must be explicit.** LSET below v19: fall back to a blind write — lossy but not corrupting, since owners still merge append-only after an `LGET`. RKEY below v19: **must fail closed, not fall back** — `updateKeys` replaces the whole list and QUE can't read it back, so a blind RKEY *evicts* the other owners' link-write keys (the exact harm §3 prevents). So **gate owner-add (and multi-owner generally) on the link queue's negotiated server version ≥ 19**; on an older host, block promotion with a clear error rather than degrade. This also resolves the §2/§4.2 tension: a v4-era server cannot host a safe multi-owner channel. +- Fingerprint persistence: a new `rcv_queues` **agent-store** column (both SQLite and Postgres trees, `-fclient_postgres`) holds the CAS fingerprint, so it survives restart; without it an owner must `LGET` before its first post-restart write. - `LINK` notifies with the client's own `userLinkData` (`Agent.hs:1813`) — return the server's state or drop the payload, so callers cannot mistake it for confirmation. ## 7. UI @@ -262,7 +277,13 @@ Out-of-band verification closes it and already exists. `APIVerifyGroupMember` (` **RKEY as shipped is a replace with no compare-and-swap**, so two owners writing the list concurrently evict each other; last writer wins. §3 gives it a fingerprint, so a loser is rejected and retries against the current list instead. Removal is then replace-minus-one under the same rule; `recipientKeys :: NonEmpty` (`QueueStore.hs:37`) prevents emptying the list, so two owners removing each other cannot leave the link unwritable — one survives, arbitrarily. -**Chain order is load-bearing, and entries are never deleted.** A reordered or partially-published chain makes link data unreadable to every client and bricks joins. Publish in `owner_auth_index` order and validate locally before `LSET` — the agent rejects a bad chain anyway, but as `CMD PROHIBITED`, not a legible error. Dropping an entry breaks every owner it transitively signed, so removal revokes the key and keeps the entry (§11). +**Chain order is load-bearing, and entries are never deleted.** A reordered or partially-published chain makes link data unreadable to every client and bricks joins. Publish in `owner_auth_index` order and validate locally before `LSET` — the agent rejects a bad chain anyway, but as `CMD PROHIBITED`, not a legible error. Dropping an entry breaks every owner it transitively signed, so removal revokes the key and keeps the entry (§12). + +**A co-owner can hostile-take-over link-write.** RKEY replaces the whole key set, and `recipientKeys :: NonEmpty` only forbids emptying it, so a malicious owner can RKEY the set down to one key it alone controls, evicting every other owner from writing link data. CAS makes this deliberate rather than accidental but does not prevent it. Accepted under any-owner-decides — it is the same trust level as "any owner can destroy the channel" — but stated so it is a conscious choice. + +**Auto-connecting to co-owner-added relays is unreviewed.** Removing the owner-exclusion on `syncSubscriberRelays` (§4.2) means every owner (and joiner) automatically connects to any relay any owner published, so a malicious co-owner can insert an attacker-controlled relay the others adopt without review. Also within the any-owner-decides trust model, but a relay sees content, so worth an owner-facing audit surface later. + +**Verification enforcement rests on an unstated invariant.** §8's mitigation only holds if the acceptance's `memberKey` is checked equal to the *exact key that was out-of-band verified* — `memberVerifiedCode` is a hash of both keys, so the enforcement must compare the key O1 signs into the chain against the one whose code was verified, and re-verification must be forced if M's key changed since. State this as a hard precondition on the promotion command. **Creator anonymity is weaker than the overview states.** It claims owners are indistinguishable "provided multiple owners were signed by the root key". Once owner 2 signs owner 3 with its own key the chain shows who delegated to whom, and root-signed entries identify the creator's cohort. Qualify it. @@ -280,7 +301,20 @@ Cases: owner 2 promoted, verified by an existing subscriber (TOFU) and a later j `channels-overview.md` §Governance — v7 to current; qualify creator anonymity; record the §4.1 tie-break. `channels-protocol.md` — new §Owner addition, `x.grp.promote.*` in the signing table. Closes `Internal.hs:1508`, `Subscriber.hs:1426`, `:1451`, `Groups.hs:2320`. -## 11. Open +## 11. Implementation gaps to close (found in review) + +These do not change the design but are load-bearing pieces the sections above under-specify; an implementer that misses one ships a broken feature. + +- **Pending-promotion storage** is member-row columns (per-invitee), not a table — the "durable owner-request worker + table" phrasing is wrong (relay-request state is columns on `groups`, no table). Same shape, on the invitee's / own membership row. +- **Thread the whole `OwnerAuth` into owner records.** `createLinkOwnerMember` (`Groups.hs:3508`) and `updateRelayGroupKeys` (`Groups.hs:2321`) drop `authOwnerSig`; `owner_auth_sig`/`owner_auth_index` stay NULL unless every owner-materialisation site is extended, and a non-creator owner then cannot rebuild the ordered chain to publish (§4.3). +- **Per-relay count storage.** "Max across relays" that can *decrease* needs each relay's last report retained (a column on the relay's `group_members` row); the single `public_member_count` scalar only supports a never-decreasing running max. +- **Client model plumbing.** `canManageLink` (on `GroupInfo`) and `Maybe MemberRoleProposal` + `promotionPending` (on `GroupMember`) must be added to the Haskell types, their JSON, and both Swift and Kotlin structs — with `omittedField`/nullable defaults for remote-desktop forward-compat. None exist today (`grep` is empty across `apps/`). +- **New `RcvGroupEvent` case** (`RGEMemberPromotionInvited`) needs Swift + Kotlin enum cases + strings; both enums are exhaustive with no catch-all, so a missing case throws on M's own device. +- **Invitee banner is new code, not a mirror.** M's own support scope is `memberSupport(nil)`, and the existing pending-member bars are gated on a non-null scope member, so they are skipped; a new composer branch keyed on the self scope, reading the proposal from `groupInfo.membership`, is required on both platforms. +- **Picker gate.** `canChangeRoleTo` hard-returns `[.observer,.member]` for channels behind `isOwner` (`ChatTypes.swift:3092`, `ChatModel.kt:2668`); add `.owner` and decide its gate (link-write capability, not bare `isOwner`). +- **Relay status is a read**, retained mid-handover — gate relay *management* on `canManageLink` but keep relay *status display* on `isOwner`. + +## 12. Open **Owner removal.** Shape settled, details open. Revoke the leaver's link-write key, broadcast the removal, and **keep its `OwnerAuth` entry**, so every owner it transitively signed stays valid and `validateLinkOwners` never breaks. The chain becomes append-only for good — which is already its merge rule (§4.2) — and the entry is inert once the key is revoked, since a signature that cannot be written reaches no one. Enforcement is at SMP, not in the chain: dropping the key from `recipientKeys` means the removed owner can no longer authorise *any* recipient command, including adding a key back. @@ -289,7 +323,9 @@ Open: - **A revocation marker.** Otherwise a new joiner reading link data recreates the removed owner as `GROwner` — `createLinkOwnerMember` builds owner records from the chain, so an entry left for signature validity would also read as current authority. A flag on the entry, or a list of revoked member IDs in chat's `userData`; either is chat-side. - **The chain is append-only and link data is capped** at 13,784 bytes, shared with the profile and its image; an entry is ~108 bytes. Owner churn is therefore bounded, and exceeding the cap would brick the link. -Note removal is **not a security boundary against a malicious owner**: any owner can already destroy the channel (§8), and with additive RKEY could have added a second recipient key it controls before being removed. It is an administrative operation for cooperative cases. +The attribution problem is the **same root gap as promotion delivery (§3)**: both need data restricted to owners, which the codebase cannot express (only `≥ moderator` scope and public broadcast exist). An owner-scoped distribution primitive — deliver/store a signed `memberId → linkRcvKey` map among `getGroupOwners` only — solves both, and is the piece to design first. Until it exists, removal cannot ship; the feature launches add-only. + +Note removal is **not a security boundary against a malicious owner**: any owner can already destroy the channel (§8), and could have added a second recipient key it controls (any owner may RKEY the set) before being removed. It is an administrative operation for cooperative cases. **Last-owner protection.** Missing today, independent of removal: `APIMembersRole` blocks only `selfSelected`, so two owners can already demote each other into an ownerless channel, and RULE-19 is UI-only. Worth adding regardless.