From 0e9a982f4aaf2d05148bb180f12e75d6d640be03 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:55:53 +0000 Subject: [PATCH] docs: channel multi-owner plans: keep the original (596d5f98a) and S1 (661adb5f6) plans as -original and -s1 copies, point the current files to the S3-min plans --- ...2026-07-15-channel-multi-owner-original.md | 189 +++++++++ plans/2026-07-15-channel-multi-owner-s1.md | 348 ++++++++++++++++ plans/2026-07-15-channel-multi-owner.md | 2 + ...nel-multi-owner-implementation-original.md | 246 +++++++++++ ...1-channel-multi-owner-implementation-s1.md | 394 ++++++++++++++++++ ...7-21-channel-multi-owner-implementation.md | 2 + 6 files changed, 1181 insertions(+) create mode 100644 plans/2026-07-15-channel-multi-owner-original.md create mode 100644 plans/2026-07-15-channel-multi-owner-s1.md create mode 100644 plans/2026-07-21-channel-multi-owner-implementation-original.md create mode 100644 plans/2026-07-21-channel-multi-owner-implementation-s1.md diff --git a/plans/2026-07-15-channel-multi-owner-original.md b/plans/2026-07-15-channel-multi-owner-original.md new file mode 100644 index 0000000000..efdbffca57 --- /dev/null +++ b/plans/2026-07-15-channel-multi-owner-original.md @@ -0,0 +1,189 @@ +# Channel multi-owner + +A channel can have N equal owners, per `docs/protocol/channels-overview.md` §Governance ("v7: any-owner-decides"). Any owner can take any administrative action, including destroying the channel; no coordination between owners. Owner *removal* (handing over ownership, dropping an owner) is in scope — it is the point of the feature: a channel must survive losing any single owner. + +Only the creator can do this today. simplexmq already models most of it and leaves it unwired: `UserContactData.owners :: [OwnerAuth]` (a signed chain — each entry signed by root or an earlier owner), `decryptLinkData`/`validateLinkOwners` accept data signed by any listed owner, and `RKEY`/`recipientKeys :: NonEmpty` let a queue have many writers. The single-owner assumption lives almost entirely in `groupLinkData` (`Internal.hs:1670`, self-only owners at `:1676-1682`), which republishes only *self* as the owner. + +**One key per owner.** An owner's member key — the key that signs its chat messages — is also its `OwnerAuth.ownerKey`, the key that signs the link data it writes (the creator's writes stay signed with the root key), and its recipient key on the link queue. The first two are fixed by today's code: joiners and relays take an owner's member key from its chain entry (`Commands.hs:2327-2332`, `Store/Groups.hs:2367-2373`), and link data must be signed by the root key or a chain key (`ShortLink.hs:114-116`). Making the recipient key the same key is what lets every owner know the queue's key set from the published chain (§3). + +## 1. The frame: every shared state needs a sequencer + +The whole problem is ordering concurrent writes, and the answer is fixed by **where the state physically lives**: + +| State | Lives on | Sequencer | How it converges | +|---|---|---|---| +| **Link data** (profile, relay list, owners chain) | one blob on the SMP link queue | the **SMP server** | hash-CAS: write-with-expected-hash, rejection returns current blob, re-merge | +| **Owner queue keys** (`recipientKeys`) | one set on the SMP link queue | the **SMP server** | hash-CAS, same shape; the published owners chain is the key map | +| **Roster** (member/mod/admin roles) | a signed copy on every relay; no trusted central copy | **none** | deterministic tie-break + owner-side auto-retry | +| **Subscriber count** | derived, cosmetic | none — any owner, on channel open | relay-sourced max, timestamp freshness-gate | + +Link data and keys already *have* a sequencer — the server — so they need only optimistic concurrency, no consensus. The roster is the only state with no sequencer, which is exactly why it is the hard part. The count is cosmetic and needs no sequencer at all — any owner corrects it on channel open, gated by a timestamp. + +## 2. Link data + +**What:** the mutable link blob — channel profile, relay list, owners chain, subscriber count. Read by every joiner; written by any owner. The server stores it encrypted and cannot read it. + +**Sequencer — a content hash for the server CAS, a version inside the data for event ordering.** These are two tokens serving two layers. The server's CAS token is the **SHA-256 hash of the stored encrypted user-data blob** — the only thing the server can key on, since it cannot read the content. The new `LSETH` carries the expected hash and only the encrypted user data — the server keeps the stored fixed data, which a promoted owner could not reproduce byte for byte. The server accepts iff `hash(stored) == expected`, stores the new blob and replies `OK`; a mismatch **rejects and returns the current blob**. The hash must be taken where the exact uploaded ciphertext exists — **inside the agent's write path**: encryption uses a fresh random nonce, so the chat layer cannot reproduce the stored bytes by re-encrypting the plaintext. The agent hashes the encrypted blob it uploaded on an accepted write (or the blob the server returns on a rejection) and hands that hash back to chat; chat never re-derives it. Separately, a **monotonic `version` lives inside the mutable data** (invisible to the server); it exists only so recipients can **reject stale events** — a profile event whose version is below what a recipient already holds is ignored, since the profile is a whole value an older event would revert (every recipient stores the version it holds, §9); additions and removals compose and are not version-gated. This version is **advisory**: it is client-set and unverifiable, so correctness rests on the server-enforced hash-CAS and on reconcile-on-open, which reads the actual blob. A corrupted or saturated version (any owner is trusted, §8) can only make recipients skip live events until their next reconcile-on-open — it cannot corrupt stored state. So: hash = server CAS token (server-visible, unordered); in-content version = advisory client-side ordering. A rejected owner re-merges its one change onto the returned blob and retries. + +**Both tokens travel in events, so writers rarely pre-read.** An accepted write is announced by the event for its change — `XGrpInfo` (profile), `XGrpRelayNew` (relay added), `x.grp.owner.add` (owner added, §6), `x.grp.mem.del` of an owner (owner revoked, §7) — carrying `(version, baseHash, hash)`, where `baseHash` is the hash the write replaced. An event carries them only when it describes its write's entire change. **Order: write first (`LSETH`), then broadcast** — a rejected-then-retried write would otherwise announce the wrong hash. A receiving owner applies the content and adopts `hash` as its CAS token **only if `baseHash` is the hash it holds**, so its local state is exactly the blob that hash certifies. Otherwise — it missed an earlier event, or the write was not announced with hashes (the count publish, a relay-departure publish, a write with several changes) — it keeps its old token, and its next write is rejected, gets the current blob back and reconciles. Adopting a hash without holding the content it certifies would let that owner's next write pass the CAS while merging onto a stale view, silently overwriting what it missed. **`LGET` is never needed to write** — a stale writer is simply rejected and gets the current blob back. + +**One reconcile routine keeps local state in sync; `groupLinkData` always builds from local.** "Reconcile" merges published link data into local state, by the kind of field: + +- **Owners and `revokedOwners`** merge as a union keyed by memberId; `revokedOwners` is the permanent tombstone. Removal adds a `revokedOwners` entry (§7), and a revoked owner's chain entry is dropped once no current entry depends on it (§7), so the union never brings a dropped entry back; an owner's own not-yet-published addition survives a merge without a stored delta, and concurrent adds of the same member collapse to one entry. A published entry also fills the signature and position of a local owner row that lacks them, so a write never omits a current owner. +- **Relays:** the published set is authoritative, except this owner's own in-progress relay changes and the relays it knows are gone. A relay it is adding and has not published yet lives in its relay row; a relay it removed, or that left, lives in its relay member row with status removed or left — in channels a relay's removal keeps that row, as a relay's own leave already does (`updateMemberRecordDeleted`, `Internal.hs:2218-2230`), instead of deleting it together with its relay row before the write (`deleteOrUpdateMemberRecordIO` and `fullyDeleteMemberRecordIO`, `Internal.hs:2192-2216`; `group_relays` cascades, `chat_schema.sql:839`), which would leave nothing to subtract — and adding a relay checks for duplicates only among current relays (`APIAddGroupRelays`, `Commands.hs:2782-2789`), so a removed relay can be added again. Each owner caches the last published relay list it knows — a promoted owner reads the link data to fill it before its first write — and a write's relay list is that cache plus its own in-progress additions minus every relay whose member row on this device is removed or left; a write with an empty relay list is refused unless the owner is removing the last relay — today the written list comes only from the writer's own `group_relays` rows (`Store/Groups.hs:1528-1545`), which reference its own chat relays (`chat_schema.sql:840`), so a promoted owner would publish no relays at all. A relay another owner removed is therefore not resurrected by this owner's next write (a write on a stale cache is rejected by the CAS and rebuilt from the returned blob), and a relay another owner added is not dropped. +- **Profile and preferences** (whole values): the published value is adopted. An owner's own profile edit is synchronous and write-then-commit: it keeps the profile it started from in memory, writes, and commits locally only on an accepted `LSETH`. On a rejection it merges field by field against the profile it started from — a field only it changed keeps its value, a field only the other owner changed takes theirs, and a field both changed differently is a conflict, surfaced ("profile edit superseded") with the published value kept — then retries. Today `runUpdateGroupProfile` commits locally (`Commands.hs:4172-4174`) before the link write (`:4190`); the order is reversed. + +Because local state thus stays "published + my in-progress changes" (an owner I am adding, relays I am adding or removing), `groupLinkData` builds the write from local state — no separate delta step. Reconcile runs in four places, all the same code: **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — the owner exclusion is removed, in core at `Commands.hs:1968` and in the apps — with an owner relay step in place of `syncSubscriberRelays`, which would delete this owner's not-yet-published relays and join other owners' relays as a subscriber; the owner step deactivates relays absent from the published data that this owner is not adding, and connects to relays other owners added by joining through the relay's link as itself — which needs a relay change: today a relay accepts a join onto an existing member only for roster roles (`memberJoinRequestViaRelay`, `Subscriber.hs:1696-1706`), so an owner falls through to a fresh member insert; the relay instead accepts a join from a current, non-revoked owner that is signed with that owner's key, with the same `viaRelay` replay check, onto the existing owner row with role owner; on the owner's side, completing that join updates only the relay member — the subscriber path it reuses would overwrite the owner's group profile with the relay's copy and, for the creator, its membership role and status (`updatePreparedUserAndHostMembers'`, `Store/Groups.hs:821-844`), and its connection handler would mark the owner's own membership connected and create the joined-channel items (`Subscriber.hs:892-922`)), **when the owner's chat starts** (so owners that never open the channel view — CLI, bots — also heal revocations and publish the count), **on a write rejection** (adopt the returned blob, then rebuild), and as the missed-event backstop. The `owners` list keeps **insertion (signer) order** — a new entry is appended after the entry that signed it, so `validateLinkOwners` ("signed by root or an earlier entry") holds as-is, with no ordering change; the union merge dedups by memberId. (The key-set hash in §3 *is* recomputed by every owner and so genuinely needs a canonical sort; the owners blob is hashed as ciphertext and never recomputed, so it does not.) `revokedOwners` is a list in the mutable data, append-only (the remover writes it on removal, the leaver on leave, any owner corrects an inconsistency it sees on reconcile); each entry is a `memberId` with the roster version its writer held and whether it is a leave, which bound a leaver's in-flight roster changes (§4) — both asserted by the entry's writer, so a reader caps the version (§4) — and the merge keeps the first entry this device saw for a `memberId`. The count comes from relays (§5). + +**Relay activation keys off the agent's `LINK` event** — or, for a promoted owner's foreign write, which produces none, off its `OK` (§9) — and `LINK` fires only after the server accepted the write (`Agent.hs:2255-2258`); under CAS it fires after the accepted `LSETH` and carries the data actually written, after any re-merge. A relay that another owner removed never produces a `LINK` on this device (the TODO at `Subscriber.hs:1475-1477`); reconcile deactivates it. + +## 3. Owner queue keys + +**What:** the set of recipient auth public keys allowed to write the link queue = the owners' member keys. A promoted owner's member key is added via `RKEYH`. Recipient keys are Ed25519 (`rcvAuthAlg`, `Agent/Env/SQLite.hs:227`), as member keys are. + +**Why the member key.** Writes to the link queue cannot be anonymous among owners whatever the queue keys are: link data is signed by the writing owner's chain key inside a blob that any holder of the public channel link decrypts (`ShortLink.hs:108-116`), and the server knows which recipient key authorised each command (`Server.hs:1333-1334`). So an SMP operator that holds the link attributes every write to an owner, and a separate per-owner queue key would hide nothing while needing its own key→owner map distributed among owners. With the member key, the published chain *is* that map: every owner computes the queue's key set as the `ownerKey` of each chain entry not in `revokedOwners`, and removal reads the target's key from its entry. + +**The creator's key.** The creator's link queue was created with an agent-generated recipient key. Before its first promotion the creator rotates it to its member key in three resumable steps — `RKEYH` adding the member key, persisting it as the queue's signing key, `RKEYH` dropping the old key — each CAS-checked, so a crash between steps is finished on retry. + +**Sequencer — the server, hash-CAS.** The CAS token is the SHA-256 hash of the sorted key set (the server stores the set sorted). `RKEYH` carries the expected hash and the full new set; success replies `OK`, a mismatch rejects and returns the current set. Since every owner computes the set from the chain, a rejection happens only while a key change is in flight (a promotion between its `RKEYH` and `LSETH`, a removal between its `LSETH` and `RKEYH`); the returned set settles it. Two concurrent additions: one wins, the other re-merges its key onto the returned set and retries, so both land. CAS is needed because `RKEY` *replaces* — without it, two concurrent adds each drop the other's key. + +**So removal never blocks and self-heals:** it is an **async command** whose steps chain by continuation (`LSETH` the revocation into `revokedOwners` → `RKEYH` out the key → announce, §7), CAS rejections retrying inline — no new durable-worker type; the removal's durable step (§9) resumes it after a restart. **Every `RKEYH` payload is built by applying the delta to the server's *current* set** — the believed set, or the set the server returns on a CAS rejection — never by rebuilding the full set from the chain, which would silently drop a key another owner just added (e.g. a concurrently-promoted owner's, whose `RKEYH` precedes its chain entry). The revocation is written **first** because joiners read `revokedOwners` from the link data and never see the key set: published first, it stops new joiners treating the target as an owner at once, and a crash before the `RKEYH` leaves the *healable* state — marked revoked, key still live. An owner that sees, on reconcile, a revocation this device has not yet confirmed enforced issues an `RKEYH` whose expected set still contains that key and whose new set drops it (a CAS mismatch returns the current set: a key still there is dropped from that set on retry, an already-absent key needs nothing more) and then records it as enforced. Only a revocation first seen in the link data needs this: the removal or leave announcement is sent after the key drop (§7), so an owner that receives it records the revocation as enforced at once, and the link server sees no extra direct connections. A missing *addition* self-heals via CAS. **`recipientKeys` is `NonEmpty`** — the server rejects a zero-length set at parse, but it has no notion of owner or revocation, so it enforces `>= 1` *key*, not `>= 1` *live* owner. "Do not remove/leave the last live owner" is therefore a **client-side precondition**, and it is checked on the blob each revocation `LSETH` replaces — the believed blob on the first attempt, the returned one on every CAS retry: the actor must not be in that blob's `revokedOwners`, and at least one chain owner outside `revokedOwners` must remain after the change; otherwise the operation stops before any key change and is surfaced. Because the server serialises the writes, of a **concurrent mutual removal** (O1 and O2 each remove the other), or two last owners leaving at once, exactly one revocation lands and the other's retry sees it and stops, so the channel always keeps a recognized owner with a live key. Nothing is ever un-revoked. + +## 4. Roster + +**What:** the owner-signed set of member/moderator/admin roles (owners are on the link chain, not the roster). It has **two representations**: individual signed `x.grp.mem.role` events sent to members, and a versioned blob sent to relays and served to joiners. These can diverge under concurrency — which is the whole difficulty. + +**No sequencer exists** (a copy sits on each relay; relays are untrusted and cannot sign), so convergence is owner-side, in three parts: + +1. **Union from the deltas; deterministic winner for the served blob.** The signed per-member events *compose*: the event gate (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`, author-blind) applies every delta, so two owners' concurrent *different-member* changes both land; adding an author tie-break here would wrongly drop one. A completed blob, however, replaces the roster: privileged members absent from it revert to the default role (`processRosterEntries`, `Subscriber.hs:3610-3619`). So the blob that wins a same-version race reverts the losing owner's delta on every relay that completes it (and on members a relay later serves it to) until the loser's retry (2) lands. The served **blob** is made deterministic by *adding* a `(version, authorMemberId)` tie-break to the completion gate (`Subscriber.hs:3572-3578`, today a version-only downgrade check that then last-writer-overwrites via `setGroupLiveRoster`). The existing downgrade bound stays: reject `pendingVer < roster_version` (the gate, which per-member deltas also advance — this is what prevents applying a full snapshot older than intervening deltas). What changes is the equal-version case, which today overwrites by arrival order: the incoming blob replaces the held one iff `pendingVer > stored_roster_version`, or `pendingVer == stored_roster_version` and either the stored blob's author is no longer a current owner (a re-send after its departure, below) or `author > storedAuthor`. The tie-break author is compared against the **stored blob's own** `storedAuthor`, never the gate `roster_version` (which carries no blob author), so a delta cannot corrupt the comparison. The author is stored **with the blob** — a cross-device `memberId` beside `stored_roster_version` (`roster_sending_owner_gm_id` today is a device-local `GroupMemberId`), written only by `setGroupLiveRoster`; the delta path (`setGroupRosterVersion`) advances the gate and writes no blob author. `memberId` is identical on every device, so every relay serves the same snapshot regardless of arrival order. A roster never changes an owner's role: applying a roster skips members who are currently owners, and members with a removal record (§6). Without that, a member promoted to owner from a roster role — or such an owner after it leaves or is removed — would be set back to that role by the blob relays still store — today the apply sets each listed member to its listed role (`processRosterEntries`, `Subscriber.hs:3622-3628`), and a joiner receives the stored blob right after its owner intros (`introduceInChannel`, `Internal.hs:1330-1331`). A relay being set up acks the roster version it was sent, and stays unpublished until the ack matches the owner's current version; with several owners another owner's delta can advance that version before the ack arrives, so an ack below the current version makes the owner re-send its current roster to that relay — today it only logs a warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`), and the owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would wait forever. + +2. **Auto-retry to the union.** The winning blob may lack a concurrent *different-member* change, and members and relays can lose one outright: the event gate ignores an event below the version a device already holds (`Subscriber.hs:3414-3425`), a relay forwards nothing its gate drops (no delivery task for an ignored event, `:1133-1144`), and a relay's delivery can reorder one owner's events (it batches the pending tasks of the oldest task's job scope, `Store/Delivery.hs:178-202`). So of two owners' changes made from the same version, a device that first received a later change drops the other, and when every relay dropped it, no owner even hears of it. Repair is owner-side and needs three things. + - **Owners order the roster per member, not by the gate.** Each owner keeps, for each member, the key of the change that set its current role — the version the change was made at, its author's memberId, and whether it is a removal — and applies a role or removal event, original or retry, exactly when its key beats the member's: a removal beats a role change; otherwise the higher made-at version, then the higher author. An owner's roster is then the same whatever order its events arrived in, and a replayed old event never beats the key it was superseded by. A member with no key yet — every member at the migration, and every member on a newly promoted owner — holds an empty key whose floor is the version the owner held then; an event beats it only when its version is above that floor. Owners never apply a served roster blob. Members and relays keep today's gate. + - **Relays pass dropped owner events on, and tell their sender.** An owner's role or removal event that a relay's gate drops is still sent, unchanged, to the relay's other current owners (not to members, whose gates would drop it too), batched as the relay received it, so every owner hears every owner's change through any honest relay. The relay also tells the sending owner, proving its version with the owner-signed event or roster header that set it (it keeps both signed); the sender raises its gate to that version and re-sends the change as a retry above it — so the change of an owner whose gate was stale (away, or racing) is not lost even when no other owner is left to repair it. Versions are `Int64` and every owner change is made at gate+1 (`Internal.hs:2644`), so an owner must never take a version on a relay's word, or one relay could raise every gate to the maximum and freeze all role changes: it acts only on a version above its own gate, signed by an owner key it holds — a departed owner's only within a fixed margin above its gate (2^20 versions: far more than any owner falls behind, as each role-change call adds one, and far below the `Int64` range), since a removed owner still holds its key — and on a notice sent directly by a current relay, not forwarded, at most one per relay per interval (a later one is deferred, not discarded). + - **Owners re-send what devices may have lost.** An owner re-sends when it applies another owner's change below its own gate (relays and members whose gates were as high dropped it); when it applies another owner's change at or below the version of the last roster blob it sent itself (that blob, at an equal version, may have won the relays' tie-break without the change, `Subscriber.hs:3573-3578`, and reverted it there, `:3614-3618`); and when it receives a change that loses to a member's non-empty key with a different value, at a version at or above the version that key's change was made at, for a member with no removal record (devices may have applied the loser; a replayed change below the change that superseded it triggers nothing, and a removed member needs no repair, since every device ignores its role changes). It coalesces triggers — after the first it waits a short quiet period with no further roster event, within a bound — and, except when a relay reports a value it or its members hold wrongly (always re-sent), skips members whose current key it has already re-sent, so a bulk change, however a relay splits or spaces it, costs each member one re-send; this is safe because a relay forwards every roster event in the order it received it (below), so a later re-send never overtakes an earlier one; it re-sends, as retries, the current value of the triggering changes' members and of every member whose key was made at a version at or above the lowest triggering change's made-at version — a retry at a higher version could otherwise overtake those in flight and get them dropped — together with its blob, at one above the highest version it has seen. A retry carries the made-at version and author of the change it re-sends, so every owner compares it by the same key, and that change's own `prevKey` (below). + **Both** owners of a race do this, so O1 (X→admin, then Y→admin) and O2 (W→admin, then Z→admin), racing from one version through two relays that each drop one owner's first change, converge the served blob, every relay and every member to all four — as do two owners making one change each at one version. Re-sends stop: an owner re-sends only on a change that is new to it (it applies each once) or that loses to it, and once every owner holds every change, re-sends carry only values every owner already holds. A change made after its owner had seen the others is applied above every owner's gate and its last blob, and triggers nothing, so a later change is never undone, and nothing ping-pongs. + +3. **True conflict.** When two owners changed one member without either having seen the other's change, it is a real conflict, not a dropped delta, and every owner resolves it the same way: a removal wins over a role change whatever their versions (the removed member's connections are already gone, and every device ignores a role change of a removed memberId, §6); otherwise the change made at the higher version wins, and at the same version the higher `authorMemberId`. The winner re-sends as above, so members — which apply equal-version events in arrival order — end on its value; the loser, applying the winner's change, surfaces "your change to X was overridden" instead of re-applying. Every role and removal event carries the key it replaced on its sender's device, so an owner whose own change is replaced surfaces it exactly when the replacing change was made without seeing it, and a later change made after seeing it surfaces nothing. No silent revert. + +**The blob's author must stay verifiable.** Relays serve the stored blob as a message signed by its author (`serveRoster`, `Internal.hs:1295-1315`), which a joiner verifies only against an owner it holds with a key, and an existing member ignores a roster served under a departed author (`Subscriber.hs:4019-4020`). After an owner leaves or is removed a joiner has neither its key nor its owner role (its chain entry is dropped or listed in `revokedOwners`, §7). So when an owner's departure is applied, each remaining owner re-sends its current blob to relays at the version it holds, signed by itself, and a relay at an equal version prefers it to a blob whose author has departed (part 1). A relay that applies that departure only after the re-sends reached it — a removal it learns from its periodic link check, say — would have refused them; so a relay that applies the departure of its stored blob's author asks each connected current owner for its roster (`XGrpRosterRequest` with the relay's gate, proved as for a drop notice), which an owner answers by re-sending its blob to that relay at the higher of its own version and the relay's, raising its gate to it — an owner that never applied the departed owner's last change would otherwise be refused. It adds no version: members already hold every role, and a version without an event would read as a gap to every member (`Commands.hs:3015-3017`). Relays must forward roster events in the order they received them: a departing owner's earlier role events must not be overtaken by its departure, after which every device ignores them (`Subscriber.hs:4019-4020`), and no change may be overtaken by another owner's later one, which members would then drop below their gate with no gap to show it (`Subscriber.hs:3414-3425`). Today a relay batches the pending tasks of the oldest task's job scope (`Store/Delivery.hs:178-202`), and an event about a moderator or above gets a different group job scope from one about anyone else (`Delivery.hs:115-120`), so one half of a bulk change can be sent after a later event. Channel sends ignore that difference (`Subscriber.hs:4338-4340`), so in channels every group-scope delivery job of an event gets the one job scope (the relay's own-removal job stays separate); and the relay's batch packer, which today skips a task that does not fit and goes on accepting later, smaller ones (`Messages/Batch.hs:88-95`), stops at the first that does not fit. A relay then forwards them all in received order. + +**Catch-up from relays, not blobs.** Today an owner fetches the stored roster on any gap in the versions it has received — its own changes leave one, as they advance only its gate (`Subscriber.hs:3438-3446`, `Internal.hs:2644-2645`) — and a completed roster reverts every member absent from it (`Subscriber.hs:3610-3619`); an unkeyed blob would overwrite the owner's per-member keys, and a blob below the owner's gate, or its own blob served back, is never applied at all (`:3518`, `Store/Messages.hs:320-330`). So owners stop requesting and applying blobs, and catch up from signed events instead: each relay keeps, for each member, a copy of the latest owner's role or removal event it applied — signed body, signatures and author, so neither message cleanup nor member cleanup can take it away; an owner asks each relay at chat start (`XGrpRosterRequest`, carrying the highest version it has been served by that relay) and the relay answers with those events above it, which the owner applies by key like any other — bringing back an owner whose queued events expired while it was away (servers keep messages 21 days by default, `defMsgExpirationDays`). A relay drops everything for an owner whose queue on it is full until that owner's connection resumes, the answer to that request included, so on that resumption the relay sends a current owner its catch-up unasked, through the reactivated connection, and counts a version as served only once a send was actually handed to its agent. And when a blob that completes on a relay reverts a member whose latest event there is by another owner, the relay passes that event to every current owner: the blob's author, whose blob was made without it, applies it by key; and an owner that holds that very change — when its author has since departed, nobody else can — re-sends it under its own signature. + +**A departing owner's changes outlive it.** Every device ignores content from an owner that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner, through one relay, keep changing roles — yet a departing owner's last changes are often still in flight: the creator changes a role and then leaves, and the new owner reconciles the link data, which shows the revocation the leave writes first (§7), before a relay has forwarded the change; a member does the same on channel open; an owner back after its queued events expired learns of the departure at chat start. Left alone, the change would end up on some members and relays only — when it was a demotion, relays and joiners would keep the member's old privileged role. Remaining owners carry a leaver's changes, in three steps; members and relays keep ignoring departed authors. + +- **Hold, for a leave.** A relay forwards group-scope events in the order it received them (above), so a leaver's role changes and removals reach every device, through each relay, before its `x.grp.leave`. An owner that learns of a leave from the link data first (a `revokedOwners` entry marked as a leave, §2) keeps applying the leaver's role changes, and removals of members, by key as before — whether they arrive forwarded, passed on, in a relay's departure report or as catch-up — until it processes the leaver's `x.grp.leave` (which it does for this even though it already holds the author as departed) or a removal of the leaver, or every relay that was current and connected when the hold started has reported applying the departure (below), or, for a relay that never reports, a fixed period has passed during which the device heard from the channel's relays; time spent offline, asleep, or with the relays down counts toward a hold at most about one cleanup interval (30 minutes) per sleep, and at each end of an offline or relays-down stretch. Only originals (not retries) are held, made at a version no higher than the one published with the leave — capped at the holder's own version plus the departed-owner margin (part 2), far above any real gap between owners, since the entry's writer asserts it — never naming an owner, and verified against the key kept with the removal record, as member cleanup may have deleted the leaver's row. A leaver colluding with a relay can thus, until the hold ends, make only a change it could have made before leaving; it cannot raise a gate or touch an owner. A removal starts no hold: the remover cannot vouch for the removed owner's last changes, which may be the very reason for the removal. A leave entry cannot be told from one a removed owner forged with its own key before its key was dropped, so such an owner can, with a relay, have changes to non-owner members held by an owner that reads that entry first, and adopted when the hold ends — until its remover's announcement, or, if the remover never announces, until its own `x.grp.leave` or the hold period (§8). +- **Adoption.** When an owner applies another owner's departure — the end of its hold, the departure event when it learned of the departure from it, or the announcement of its own removal of that owner — it re-sends, as retries, every member whose key the departed owner authored and that it has not already re-sent, with its blob (part 2's re-send; when none is left, the blob alone at the version it holds, which reads as no gap): the departed owner can no longer re-send its own changes, and devices that learned of the departure first dropped them. Retries carry the departed owner's key, so every owner compares them alike, and a member that already holds the value sees no change. +- **Undo.** A role change the departed owner made that no remaining owner holds — one racing its removal, or one a device received after the departure event through a slower relay — may still have been applied by some members and relays. A relay that applies an owner's departure passes its kept copies of that owner's events to every current owner and then reports that it applied the departure; catch-up does the same for an owner that was away. An owner that has applied the departure and holds a different key for that member, for a copy made at or below the roster version published with the departure plus the departed-owner margin, re-sends that member's current value — at most once for that member and its own current key, and at most once per relay per interval, so neither a replayed nor a fabricated copy can make it re-send more. If another remaining owner does hold that change, it rejects the undo as a losing different value and re-sends the change (part 2), and every owner adopts it by key. Either way every device ends with one role per member. A departed owner's removal that a remaining owner does not hold cannot be undone — a removal record is permanent (§6) — and is not adopted, since a removed owner and a relay could fake one: the owner is shown that the departed owner removed that member on some devices, and finishes the removal itself if it agrees. + +A leaver also sends any re-sends it has pending, and starts its revocation only once its earlier messages are on the relays' queue servers, within a bound. A relay that applies the revocation from the link data — in its periodic check, or in the immediate read when an owner it does not know joins or is announced — before processing a change already in its queue drops the leaver's connection with it. What remains is consistent: a change every relay lost that way, a role change racing a removal that no remaining owner held, or a leaver's change still pending for relays that stayed backlogged past its wait, is absent on every device; a removal racing a removal is shown to the remaining owners to finish. + +**Why no leader:** a leading owner that sequences the roster reintroduces the single-owner dependency multi-owner exists to remove — if it is asleep, all role changes stall. Auto-retry needs no one to be online but the acting owners. Races are rare (concurrent role edits within seconds), so the extra broadcast is acceptable. A bulk change (one call, one version bump, N events) is N changes under one key; a retry re-sends each member's current value with the owner's union blob, so a concurrent single-member change by another owner is preserved, not regressed. + +## 5. Subscriber count + +**What:** the "subscribers: N" display — cosmetic, best-effort. The creator hears of every joiner (relays announce each joiner to owners, `introduceInChannel`, `Internal.hs:1320-1326`), but an owner promoted later knows the subscribers who joined before it only from the relays' catch-up on its commit (§6), which relays predating multi-owner never send. The count must come from relays. + +**Source — max across relays, with a timestamp.** Each relay sends owners `x.grp.relay.count` with its own *current absolute* subscriber count and the time of that reading — directly to owners, as it sends the join announcement, and only when the count changes: when a subscriber connects to it, leaves or is removed, recounting at send time. (Today a relay recounts only on leave and removal, `Subscriber.hs:3773`, `:3820`, never when a subscriber connects, `Subscriber.hs:966-972`.) The timestamp is the relay's own, carried in the event: the broker timestamp an owner sees is set by that owner's own queue server, so it would differ between owners for the same reading. An owner clamps the relay's `ts` to the broker timestamp of the message that carried it, so one relay's far-future clock cannot set a `subscriberCountTs` that no later reading beats. An owner keeps the latest `(count, ts)` **per relay** (a field on the relay's member row); its estimate is the **max** count over *current* relays, carrying **that relay's own `ts`** — the value is always vouched by the relay that reported it, so a stale reading can never be republished as a fresh one. A relay that has left or been removed (`GSMemRemoved`/`GSMemLeft`) has its per-relay `(count, ts)` cleared so its stale reading stops pinning the max; a live relay that merely goes silent (crash) can still freeze the max at its last reading until it returns — an accepted residual for a cosmetic number. The max is what ignores a newly-added relay that subscribers are still slowly connecting to (its low count stays below the max until it catches up) while still decreasing on a real departure (the max-holding relay's own count drops, republished under its own fresh `ts`; a lagging second relay reporting a slightly lower number never lowers the max, so cross-relay skew causes no spurious decrease). A second cosmetic residual: when a shrink moves the max onto a *different* relay whose last reading is older than `subscriberCountTs`, the decrease is not published until that new max-holder next reports (its stale `ts` gates it out meanwhile) — the display stays briefly high and self-heals on the next reading; this is deliberately preferred over decoupling the `ts` from the max-holder, which would let one relay's fresh `ts` vouch for another's stale-high count and republish a wrong value. Deltas cannot do any of this: a leave is *deduplicated* across relays, so an owner cannot attribute a decrease; the per-relay count event, being distinct per relay, is not deduplicated. + +**Published on channel open, not on events.** Link data carries `(subscriberCount, subscriberCountTs)`. When an owner opens the channel (or its chat starts, §2) it already reconciles link data; if its estimate differs from the published count (by any amount) *and* the max-holder's `ts` is newer than `subscriberCountTs`, it publishes `(estimate, that ts)`; a published `subscriberCountTs` later than the owner's own clock counts as stale. No delta threshold — whenever an owner reconciles, the exact current estimate is published if fresher, so a small/new channel converges to its true value (1 → 2 → 3) instead of being pinned by a threshold. The number refreshes at owner-open, not per join: while an owner holds the channel open the published value does not advance with each new joiner — acceptable for a cosmetic display, and it means publishing is gated by *channel open*, not by joins. The `ts` gate means an owner writes only when it is genuinely fresher — if the published count is already current it writes nothing. The timestamp is thus a **freshness gate** — a *behind* owner (stale data, older `ts`) stays quiet and cannot push a wrong value in either direction, which is what lets the update go **both ways** safely. Concurrent opens CAS: one wins, the other re-reads and sees it already fresh. This needs no leading owner, no handover, no staleness detection, no debounce, and no extra query — the count tracks owner activity (fine for a cosmetic number) and writes the link at most once per owner-open, not per join. (The freshness gate compares the max-holder relay's `ts` against `subscriberCountTs` — two relays' clocks when the max-holder differs from the last publisher's — so under relay clock skew a genuinely fresh reading can be briefly suppressed or a slightly stale one admitted; for a cosmetic count this is acceptable and self-corrects on a later open.) + +**This is the only count publish.** Today the owner branch of `updatePublicGroupData` (`Internal.hs:1620-1627`) republishes link data on every join, leave and removal, from six call sites, so an owner catching up after being offline would write once per queued event. It stops publishing for subscriber changes and publishes only when the publishable relay set changed (a relay member removed or left — it is also the relay-removal publish path). Every write other than this count publish carries the published `(subscriberCount, subscriberCountTs)` unchanged, and the owner branch stops recounting its own tally (`Internal.hs:1623`) — otherwise a profile or relay write would publish a promoted owner's low tally past the freshness gate. Only the creator, while it is the sole live owner and no current relay supports the count event, falls back to its own tally (complete, since relays have announced every joiner to it), with its own clock as `ts` — no other owner publishes then, and promoting a second owner requires every relay to support it (§9). A promoted owner left as the sole owner never falls back: with no relay supporting the count event, none has sent it the subscribers who joined before its promotion (§6), so its tally is low; it keeps the published count. A relay sends its current reading to an owner it learns of through `x.grp.owner.add`, so a newly promoted owner has readings without waiting for the next change. The relay-announcement threshold that assumes a single owner (`Subscriber.hs:1445-1449`, `publicMemberCount > 1`) uses the relay-sourced estimate instead. + +## 6. Owner promotion (adding an owner) + +**Trigger:** `APIMembersRole … GROwner` on a channel starts an async, single-target invite flow instead of a plain role change. The UI warns the promoter to verify the invitee's key first (§8). A role change whose target is a current owner is refused — removal (§7) is the only way to drop an owner — and receivers ignore `x.grp.mem.role` whose target is a current owner: applied, it would make members and relays treat that owner as an admin while it keeps its chain entry and key, until the next link-data refresh restored it (today only `newRole == GROwner` is blocked, `Commands.hs:2994-2995`). + +**Events** (role-generic, so a future non-owner promotion reuses them), all `requiresSignature`; the first four are delivered owner→relay→M in M's support scope: +``` +x.grp.promote.inv { invitationId, memberId, memberRole, roleData? } -- O1 -> M +x.grp.promote.acpt { invitationId, memberKey, roleData? } -- M -> O1 +x.grp.promote.reject { invitationId } -- M -> O1 +x.grp.promote.cancel { invitationId, memberId } -- O1 -> M +x.grp.owner.add { memberId, ownerAuth, profile } -- O1 -> all members and relays (the commit) +``` +`memberId` in `inv` and `cancel` names M: a relay routes them into M's support scope by it, and since support-scope delivery also reaches moderators, admins and owners (`Subscriber.hs:4424-4438`), every recipient other than M ignores them. M accepts `inv` and `cancel` only signed by a current owner, and `cancel` only from the `inv`'s signer; O1 matches `acpt` and `reject` only from M's own member. `memberRole` is the offered role; `roleData` is an optional role-scoped block (owner: `linkRcvId` in inv, nothing in acpt). `memberKey` is M's existing key, which O1 must check **equals the key it already holds for M**; O1 adds the key it holds, never a key from the wire — a consistency check, distinct from the OOB verification in §8, which is advisory. A future role adds a `roleData` variant; the parser must decode an unknown tag to an opaque/ignored value (not error), since `omittedField` only covers an *absent* field. + +**Consent:** acceptance is not automatic — M confirms in the UI, and records its acceptance before sending `acpt`. A pending record on each side; reject or cancel clears it. M holds at most one pending promotion: an `inv` that arrives while one is pending is rejected automatically, so two owners never run chains for the same member. Each record expires after a fixed TTL, checked at chat start and by the periodic cleanup, only while it waits on the other side — the inviter's until M answers (its expiry sends `cancel`), M's until it accepts. After acceptance M's record holds the `linkRcvId` the commit needs, so it is never cleared blind: `cancel` clears it, and when the inviter is revoked M settles it from the link data — an entry for M in the chain completes the promotion (below), none clears the record — so a lost promoter cannot block other invitations. The creator reads its link queue's recipient id from its agent to put in the `inv`. On acceptance O1 chains, as an async command resumable from the pending record (no new worker type — the record, with the step reached, is the durable state, re-driven on receipt and on startup under the group lock). Before each step it re-checks that M is still a current member with no removal record (below) and that O1 is not itself revoked, and undoes otherwise (below). Before any key change it refuses a memberId listed in `revokedOwners` (a revoked owner cannot be promoted again — its key would be added but its entry never written, since the merge excludes revoked memberIds) and checks that the link data still fits with M's entry added (§7). Then, on the creator's first promotion, the key rotation (§3) → `RKEYH` (add M's member key, §3) → `LSETH` (append M's `OwnerAuth` after its signer in `owners`, §2; each attempt re-checks, on the blob it replaces, that M's entry still fits and that O1 is not in `revokedOwners`, as every revocation does, §3) → `x.grp.owner.add` (the **commit point** — M treats itself as owner only from this, below). M's entry enters O1's local owners only through that `LSETH`, so no other write publishes it early. + +**The commit announces the chain entry, not a role change.** `x.grp.owner.add` carries M's `OwnerAuth` and M's profile. Every member and relay accepts M as an owner only if that entry validates — signed by the root key it stored at join, or by a current owner's key — and names M's memberId; it then sets M's key to `ownerAuth.ownerKey`, replacing any key a relay asserted for M (with a warning when they differ), and creates M if it was unknown, with the profile from the event (relays introduce owners only to later joiners, `introduceInChannel`, `Internal.hs:1320-1332`, so a member that joined earlier would otherwise show M under a placeholder). This is how members who joined before M learn M as an owner, and only with the chain's key. `x.grp.mem.role … GROwner` cannot do it: it would apply the role to whatever key a relay asserted for M in an unsigned intro (`Subscriber.hs:3465-3468`), handing a relay that substituted M's key owner authority, and would not create M for a member that never saw it (`:3458`). A new event also makes clients that predate it ignore the promotion and keep rejecting M's admin messages — failing closed — rather than applying it with a relay-asserted key. **Replay and self-reinstatement:** a relay could re-deliver an old, validly signed `x.grp.owner.add` for an owner since removed, and a removed owner still holds its own root-signed entry. So `x.grp.owner.add` is accepted only when its sender is a current owner, and never for a memberId with a removal record. Removal records live in a group-level record on every member's device and relay — seeded from `revokedOwners` at join and at relay invite, and added by every applied removal and owner leave — not on the member row: member cleanup deletes a removed channel member's row a day after removal when it has no chat items (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2192-2203`; `cleanupRemovedMembers`, `Commands.hs:5960-5966`). A removal can reach a member or relay before the promotion it revokes, through a different relay, while it does not yet hold the target as an owner or at all — today such a `x.grp.mem.del` is dropped with an error and leaves nothing (`Subscriber.hs:3748-3751`), and the late commit would reinstate the removed member. So a removal is recorded whatever the receiver knows of its target — an applied one, or one signed by a current owner when the target is unknown — and the relay's owner join refuses a recorded memberId as `x.grp.owner.add` does. A recorded memberId likewise never regains a role: every device ignores `x.grp.mem.role` for it, which today updates a removed member's row or recreates it after cleanup (`Subscriber.hs:3458-3468`), and a relay refuses any join onto it, which today it accepts onto a roster-role row on the key alone, whatever the row's status (`memberJoinRequestViaRelay`, `Subscriber.hs:1699-1704`). A removed memberId is never promoted again (above), so the record only grows, and recording a member that never was an owner is harmless. If a removal and a promotion of the same member both land — the chain lists as an owner a memberId that some owner holds a removal record for — the removal wins: that owner's reconcile revokes it (§7). Members that miss an event converge on their next link-data refresh, which applies the chain — validated entries as owners, `revokedOwners` as removals — where today it applies only the profile and count (`updateGroupFromLinkData`, `Internal.hs:1634-1654`): subscribers on channel open, relays in their periodic check, which already reads the link data every 15 minutes (`checkRelayServedGroups`, `Commands.hs:6001-6020`), and a relay at once when an owner join or `x.grp.owner.add` names a memberId it does not hold as owner. A revocation seen there first is applied as that owner's `x.grp.mem.del` would be — a relay drops its connection, a member marks it removed, and the revoked owner's own device handles it as its own removal (§7) — since authority checks are role-based: an owner whose removal was never announced (its remover crashed before announcing and never came back) would otherwise keep posting as the channel, removing members and deleting the channel, so `x.grp.del` is also accepted only from a current, non-revoked owner (today a role check, `Subscriber.hs:3828-3831`). + +**Relays bring the new owner up to date.** Whenever a relay starts treating a memberId as an owner — on `x.grp.owner.add`, in its periodic link check, or on that owner's join — it sends the new owner `XGrpMemNew` for each of its current members, batched, and its current count reading (§5). A promoted owner that completed from link data (below), whose commit — and any catch-up sent with it — may have been lost or expired, asks each relay for them with `x.grp.owner.sync`, which a relay answers for a current owner. Relays announce each joiner only to the owners and moderators of that moment (`introduceInChannel`, `Internal.hs:1323-1326`), so without it a promoted owner could not promote, remove or change the role of any subscriber that joined before it; the keys are relay-asserted, the same trust the creator has in its members' keys today. The count reading also tells M that relay applied the commit: until it arrives M holds its owner actions for that relay, which would otherwise drop M's posts as the channel (`Subscriber.hs:1074-1078`). + +**M completes its promotion** — sets its own role to owner and takes `linkRcvId` from its pending record into its link-write credentials — on receiving the commit for itself, or when a link-data refresh shows a validated, non-revoked chain entry with its memberId (a commit lost with a crashed promoter, or expired while M was offline); in both cases only while it holds an accepted promotion record, and only if the entry's `ownerKey` is its own member key. A commit for M that M never accepted is ignored on M's device and shown as a service item (other members still apply it): applying it would let any owner make a subscriber's device connect directly to the link server (§8). An entry with a key other than M's own is a relay's substitution signed into the chain by a promoter that skipped verification (§8): M does not complete and alerts. Once complete, **M has its link-write credentials** — the link server and `LinkKey` (from the channel link), `linkRcvId` (from the inv), the root public key (stored at join), and its member private key, which both signs the link data and authorises its link-queue commands — the input to the new foreign-link write path (§9), since M as a former joiner has no recipient link connection and no existing agent API writes a link queue the device does not own. + +**Cancel or abandonment after the key is added** must not leave M a live key: any recipient key authorises every recipient command, `DEL` and `LDEL` included (`Server.hs:1289-1290`). The pending record stores the step reached, and the inviter records the entry step *before* issuing the chain's `LSETH`, since that write may land — or sit queued in the agent and land later — after a cancel. Cancelling, or the chain stopping because M left or was removed, after the `RKEYH` drops M's key with `RKEYH`, and from the entry step on revokes M instead (§7, revocation first), before the record is cleared. Revoking also removes M from the channel, since every member and relay applies a revocation as a removal and a removed memberId is never promoted again (above), so the promoter's Cancel says so from that step on. A residual remains: a promoter whose device is lost between its `RKEYH` and `LSETH`, or that is itself removed in that window (its key is gone, so it can neither write the entry nor undo), leaves M's member key in the key set with no chain entry — no owner status, but able to delete the queue. Another owner cannot tell it from a promotion still in flight, so it is not auto-removed; it is accepted, as M is a member who consented to becoming an owner. + +**Sign new owners with the root key when the adder holds it** (i.e. the creator, who keeps the root private key). Root-signed entries are each independently valid — `validateLinkOwners` accepts any entry signed by root — so all owners sit at one level and the list has no delegation dependencies. A non-creator owner has no root key, so it must sign with its member key (delegation), and *those* entries depend on their signer; the creator being the usual adder keeps the list flat, which is what makes removal (§7) simple. + +**Transport is not confidential, and that is fine.** Support scope reaches M plus all moderators, so a moderator sees the invitation — but nothing secret is in it (`linkRcvId` authorises nothing without a key in `recipientKeys`), and a moderator that *injects* a fake `x.grp.promote.inv` achieves nothing: completion needs an owner holding a matching pending record, which no one does, so no key or link write ever happens. M additionally verifies the `.inv` is owner-signed and ignores others. + +## 7. Owner removal + +Add the target's `memberId` to **`revokedOwners`** in the mutable link data (`LSETH`) **first**, then drop its key — the `ownerKey` of its chain entry — from `recipientKeys` (`RKEYH`), then announce the removal (`x.grp.mem.del`) — so a crash leaves the healable state (§3), and the target never processes its removal while its key could still delete the queue. **The revoked `OwnerAuth` entry is dropped from the chain unless a current entry depends on it** — a revoked entry that is an ancestor, through any number of delegations, of a current entry stays, because every entry must be signed by root or an earlier entry (`validateLinkOwners`, `Agent/Protocol.hs:2012`) and an orphaned entry would make every owner's agent refuse every write; a revoked entry with no current descendant, including any root-signed one (the usual case when the creator adds owners), is dropped. Members that held the owner keep their own copy of its key; a later joiner never gets it, so what relays serve under a departed owner's signature is re-signed or served unsigned instead — the stored roster (§4) and its as-channel history posts (§9). `revokedOwners` stays as the tombstone that stops the union merge (§2) bringing the entry back. Dropping matters because link data is a fixed-size blob — user data is padded to 13,784 bytes (`ShortLink.hs:46`), the profile alone may take up to 10,240 bytes uncompressed (`encodeShortLinkData`, `Internal.hs:1704-1711`), and each entry is about 124 bytes — so an append-only chain would, over a channel's life, make every link write fail. Promotion checks the size before its `RKEYH` (§6), and with a large profile and three relays the chain holds roughly 20 entries. Enforcement is at the SMP layer (no key = no authority), not in the chain. The revocation `LSETH` checks the last-owner precondition on the blob it replaces (§3). **Leave** is the same, self-sequenced by the leaver: once its earlier messages are on the relays' queue servers (§4) — in channels a message to a relay whose connection is backlogged is kept pending and sent when it recovers, as in other groups, instead of being dropped (`Internal.hs:2840-2851`) — revoke itself in an entry marked as a leave, keeping its own chain entry in that write (a non-creator's agent accepts a write only while the writer's key is in the chain, `Agent/Protocol.hs:1996-2001`; the next other owner's write drops it), drop its own key, then send `x.grp.leave`; it makes no roster change after its leave starts, so the roster version it publishes with its revocation bounds all of its changes. Both run from a durable record written before the first `LSETH` and re-driven at chat start through the announcement; a leaver that crashed after dropping its own key gets `AUTH` for that key, which counts as done. The announcement — `x.grp.leave` or `x.grp.mem.del` — carries the roster version published with the revocation, so an owner that learns of the departure from it can republish that entry and bound the undo of the departed owner's changes (§4). If the leaver's or remover's device is lost, a remaining owner finishes the key drop (§3), and relays and members apply the revocation from the link data (§6). An owner also runs this removal for a chain owner it holds a removal record for — a removal that raced that member's promotion (§6). + +**Leaving or being removed never deletes the link queue while another live owner remains.** Today the creator's client deletes its link connection — `DEL` on the shared link queue — when it leaves (`APILeaveGroup`, `Commands.hs:3246`), when it processes its own removal (`Subscriber.hs:3736`), and when an owner that is no longer current deletes the chat (`Commands.hs:1400`), all through `deleteGroupLinkIfExists` (`Internal.hs:1739-1748`), and any key still in `recipientKeys` authorises `DEL`; `APIDeleteGroupLink` also deletes it on request (`Commands.hs:3373-3376`), and channels refuse it. With another live owner, these delete the creator's link connection locally only (a new agent call that forgets a connection without `DEL`); the key drop that precedes them already makes a `DEL` fail. Destroying the channel still deletes the queue: the creator through its link connection, as today, and any other current owner — which has no link connection — through a foreign `DEL` with its own key (§9), so the link stops resolving even after the creator was removed. Only a current, non-revoked owner destroying the channel sends that `DEL`; deleting the chat locally (by a removed owner whose key a crash left live, say) never does. + +**The creator's root key is the one thing removal cannot revoke:** it is the immutable trust anchor in the signed `FixedLinkData`, distinct from the creator's link-write key. "Removing the creator" evicts that link-write key and marks its owner entry revoked — stopping its own queue writes — but it retains the power to mint new valid `OwnerAuth` entries; true creator eviction would require rotating the root key (re-signing `FixedLinkData` under a new root and re-issuing the link), which is out of scope. Removal is administrative hand-off, not a defence against a malicious owner (any owner can already destroy the channel). + +## 8. Security + +- **Promotion is only as safe as the verified key.** An owner's copy of a subscriber's key is relay-asserted (from unsigned `XGrpMemNew`), so a relay that substituted it could otherwise be promoted. The defence is out-of-band verification, which is **already implemented** for channels (`verifyChannelMemberCode`, `Commands.hs:3826`, called at `:2074`, hashes both members' keys, sorted, so comparing detects substitution). It is **advisory**: the promoter is warned in the UI to verify before promoting (matching the existing channel model), not hard-blocked in the backend. Residual risk: an owner who ignores the warning and promotes an unverified member could sign a relay-substituted key into the chain, making that relay an owner. With one key per owner, the key that gains link-queue authority is exactly the member key the promoter was asked to verify; the backend also enforces the consistency check (§6) that the accepted key equals the one O1 already holds. +- **Members take an owner's key only from a validated chain entry** — from the link data at join, or from `x.grp.owner.add` (§6) — never from a relay-asserted intro. +- **Any owner can destroy the channel** (`Server.hs:1289-1290`: any recipient key authorises `DEL`/`LDEL`) and can RKEY the key set down to itself, evicting the others. Both accepted under any-owner-decides; recorded because they exceed any chat-level action. +- **A removed owner can forge a leave while its key is live.** Between its remover's revocation and the key drop — by the remover, or by the first other owner's reconcile (§3) — it can rewrite its own `revokedOwners` entry as a leave, and with a colluding relay have changes to non-owner members (roles, and removals with their messages) held by an owner that reads that entry first and re-sent under that owner's signature when the hold ends (§4). A holder cannot tell this from a real leave, both being written with the leaver's key, and a remover that never announces leaves the forged entry published, so each owner that reads it first holds in turn. Each hold's version cap keeps the gate raise it allows within the departed-owner margin (§4), each holder's hold period bounds its window, and the remover's announcement ends it. Accepted as a residual of an interrupted removal. +- **Link writes are attributable to owners, and a promoted owner's network address is visible to the link server.** Any link holder can tell which owner signed each version of the link data (§3). Recipient commands are not proxied — the proxy forwards only `SEND`, `SKEY`, `LKEY`, `LGET` and `RSLV` (`Server.hs:2205-2211`) — so a promoted owner's device connects directly to the link server the creator chose, and that server's operator, holding the public link, can tie the owner's network address to its channel identity. The invitee's confirmation says so (§10), and a commit makes M an owner on its own device only after M accepted (§6) — otherwise any owner could make a subscriber's device connect directly to the link server; Tor or SOCKS avoids the exposure. Foreign-link commands use a transport session of their own, one per link (the agent shares a session per user and server by default, `mkTSession`, `Agent/Client.hs:1442-1443`), so the link server cannot also tie them to the owner's other queues on that server. +- **The creator is identifiable among owners**, contrary to the overview's claim that root-signing several owners conceals it (`channels-overview.md:159`): its chain entry is first, the link data it writes is signed with the root key (`Agent.hs:1076`), and every owner it promotes appears root-signed in a promotion it announces. Ordering the chain by memberId would not hide it while the creator root-signs the owners it adds (§6). Qualify the overview. + +## 9. Data model + +- **SMP link queue (simplexmq server):** the CAS token per mutable object — the **SHA-256 hash of the stored user-data blob** for link data, and the SHA-256 hash of the sorted key set for `recipientKeys` (the server computes each on write; no version counter). The CAS must be atomic in both queue stores (the journal message store uses one or the other, `MsgStore/Journal.hs:107-117`): the STM store compares inside its transaction, and the Postgres store — with or without its cache — uses an `UPDATE … WHERE` predicate: it loads link data as empty placeholders (`rowToQueueRec`, `QueueStore/Postgres.hs:726-730`), and under the Postgres message store its queue lock is a no-op with no cache (`MsgStore/Postgres.hs:84`, `:100`), so two commands each hold their own copy and an in-memory compare would let concurrent writes both pass. A 0-row update reads and returns the current value. These wire changes gate on a new SMP **relay** version — `VersionSMP` 24, bumping **both** `currentClientSMPRelayVersion` and `currentServerSMPRelayVersion` from 23 (`Transport.hs:228-232`; the negotiated session version is the *min* of the two peers' relay versions, so the client constant must also advance for a client to perceive and exercise it); `proxiedSMPRelayVersion` stays at 23, since recipient commands are not proxied. This is the relay handshake version, *not* `currentSMPClientVersion` (the client↔client envelope, which gates nothing server-facing). **Multi-owner requires the link queue's server ≥ v24**, read from the connected link-server session: below it, blind `LSET` is merely lossy, but blind `RKEY` *evicts* other owners (replace with no CAS), so promotion must **fail closed** on an old link server, not degrade. A sole live owner on a link server below v24 keeps today's blind `LSET`, so single-owner channels on lagging servers keep working; with two or more live owners there is no fallback, and a write that cannot be CAS-checked fails and is surfaced. +- **Agent (owner device):** no new stored state. The CAS APIs are stateless — the expected hash in; `OK` with the new hash, or the current state with its hash, out — and chat keeps the hashes. A promoted owner writes through a foreign-link API with explicit credentials (§6) — `LSETH`, `RKEYH` and `DEL` on a queue the device does not own — so no agent row needs `linkRootSigKey` (`AgentStore.hs:2903` keeps `Nothing` for the creator, whose link signing key is the root). Foreign writes are synchronous: an agent async command reports to a connection, and a promoted owner has no link connection; callers that are asynchronous today run them in a thread, and their intents are durable in chat state (relay rows, promotion and removal records) that reconcile re-drives. The creator's key rotation (§3), a read of its own link queue's recipient id (§6), forgetting a connection without `DEL` (§7), and a read of whether the link queue's server session supports v24 — which the inviter checks before sending `inv`, and below which the CAS calls fail with a typed version error — are new agent operations. +- **Chat (owner device), one writer for every link write:** each link-write site resolves the writer once, from the channel's keys — the creator's own link connection when it holds the root private key, otherwise a promoted owner's foreign-link credentials — never from whether a group link row exists: both apps create a group link when an owner opens the link screen without one (`GroupLinkView.swift:164-166`), and channels refuse `APICreateGroupLink`. Today every site needs the creator's group link row or connection, and the profile write silently skips without one (`setGroupLinkData'`, `Internal.hs:1581-1586`). Relay activation, which today runs in the handler of the agent's `LINK` event (`Subscriber.hs:1431-1481`), becomes a function of the written data, called from that event and after a foreign write returns `OK` — a foreign write produces no `LINK` event. +- **Chat DB (owner device):** owners chain (`owner_auth_sig`/`owner_auth_index` on `group_members`, threaded through `createLinkOwnerMember`/`updateRelayGroupKeys`); a removal record on every member's device — memberId, key, whether this device confirmed the key drop, and for a removal or leave this device runs the step reached (written before its first `LSETH`), so a crashed one is re-driven at chat start — in a group-level table that member cleanup does not touch (§6); the last published relay list (§2), with removed relays' member rows kept rather than deleted; pending-promotion record (member-row columns, with the step reached; member cleanup skips a row with a pending promotion); per member, the key of the change that set its role (made-at version, author, removal — or an empty key with its floor), per relay the highest version that relay has served it, and on a leaver's removal record its roster bound and hold state — which relays have reported its departure, and the running time left (§4); on a relay, a copy of each member's latest owner's role or removal event (§4); a cross-device roster-blob-author `memberId` on `groups` beside `stored_roster_version` (superseding the device-local `roster_sending_owner_gm_id` for the §4 blob tie-break); per-relay latest subscriber count + the relay's timestamp (fields on each relay's member row); the last-known link-data and key-set hashes; and, for a promoted owner, the link queue's `linkRcvId` (its server, `LinkKey`, root key and member key are already stored). Published link data — chat's `GroupShortLinkData` JSON inside `UserContactData.userData`, so simplexmq's link-data encoding is unchanged — gains `linkDataVersion`, `revokedOwners` (a list of `memberId`, each with the roster version its writer held and whether it is a leave) and `subscriberCountTs` beside the count; every member and relay stores the version and timestamp it holds, and owners cache the rest locally for reconcile. +- **Relay:** creates owner rows for chain entries it has none for, skipping entries in `revokedOwners` (today `updateRelayGroupKeys` throws on a missing owner, `Store/Groups.hs:2366-2368`); keeps the removal record; applies `x.grp.owner.add`, and sends a new owner its members and count reading when it starts treating it as an owner and on its `x.grp.owner.sync` (§6); applies the chain from the link data in its periodic check, a newly seen revocation as a removal (§6); sends `x.grp.relay.count` whenever its subscriber count changes (§5); passes an owner's role or removal event its gate drops to its other current owners and tells the sender, keeps a copy of each member's latest owner's role or removal event and serves them to an owner as catch-up, passes one to every current owner when a blob reverts its member, passes a departed owner's copies to every current owner when it applies the departure and then reports it, and forwards every group-scope channel event in the order it received it (§4); accepts a signed join from a current owner onto its existing row (§2); and serves a no-longer-current owner's as-channel history posts as channel posts without a signature, which a joiner holding no key or owner role for that author would otherwise drop (today a signed as-channel item is forwarded under its author, `Internal.hs:1540-1548`). A relay without these keeps a promoted owner an observer and drops its posts, never forwards `x.grp.owner.add` (`isForwardedGroupMsg`, `Protocol.hs:528`), and reports no count. So promotion requires every current relay to support the multi-owner chat version (`multiOwnerVersion`, the next `VersionChat`) — owners already check a relay's version (`supportsVersion`, `Subscriber.hs:933`) — and is refused until they all do. Adding a relay to a channel with more than one owner fails closed instead: a relay's version is known only once it answers, and an older relay never answers (it throws on the owner it has no row for), so the owner times out the invitation and shows an error. +- **Apps (iOS + Kotlin):** `canManageLink` on `GroupInfo`; a backend-supplied flag on `GroupInfo` that another live owner exists (the chat list has no member list); `MemberRoleProposal` (`MRProposed`/`MRRejected`) + `promotionPending` on `GroupMember`; a new `RcvGroupEvent` case for the promotion service item — all optional/forward-compatible so remote-desktop parsing across versions holds. + +## 10. UI/UX + +- **Promoter:** keep the role picker, add `.owner`; selecting it sends the invitation and shows a proposed-role row (proposed → invitation-sent + Cancel; rejected → declined + re-invite). Once the chain has reached the entry step, Cancel warns that it also removes the member from the channel (§6). Warn if the invitee's key is unverified and route to the verification screen — the consequence is the channel's trust chain, not one conversation. +- **Invitee:** a service chat item (unread) plus an accept/reject banner in M's support chat; accept goes through a confirmation stating that any owner can delete the channel and that this device will connect directly to the channel's link server (§8). (The banner is new code, not a reuse — the existing pending-member bar is gated on a non-null scope member, and M's own support scope has none.) +- **`canManageLink`** gates link/relay *management* (an owner mid-handover cannot write yet); relay *status display* stays on `isOwner`. A promoted owner's link screen shows the channel's published link — it has no group link of its own, and the screen must not create one. +- **Leave:** an owner can leave a channel when another live owner exists (the backend flag, §9); the last live owner cannot. +- **Conflict surfaces** (both rare): the losing owner is told "profile edit superseded" (link-data same-field conflict) or "your role change to X was overridden" (roster same-member conflict). No special affordance — the owner re-applies through the normal picker/edit if they still want to. + +## 11. Open decisions + +None. The implementation plan (`2026-07-21-channel-multi-owner-implementation.md`) lists what to build. + +--- + +## Appendix A — Why not a consensus protocol (owners or relays) + +§1 fixes the answer before any protocol is chosen: the coordination a state needs is set by where it physically lives. Three of the four states already sit on one SMP server (link data, owner keys) or are cosmetic (count), so a server-side compare-and-swap — "consensus with one acceptor", the cheapest sequencer there is — suffices (§2, §3, §5). Only the roster has no sequencer (§4). Under CAP a replicated store is either consistent or available during a partition; multi-owner exists to stay available when owners are absent, so this design takes the available side and converges rather than blocking on a quorum. A real quorum consensus (Paxos/Raft/PBFT/Simplex-style propose → majority-vote → commit) has two candidate homes here — the owners, or the relays — and both fail. + +### A.1 Consensus between owners fails on liveness + +- **Owners are offline for unbounded time, with no direct connections.** Every owner-to-owner message crosses relays (peer owners are stored as keys, not connections), and a phone can be off for a week, so there is no partial-synchrony bound. By FLP a deterministic quorum cannot both stay safe and terminate here except during network windows nothing can guarantee. +- **A quorum inverts the feature.** Majority-quorum needs a majority *present* to act — a 2-owner channel would need both phones online, and losing an owner would freeze governance. Multi-owner exists to *survive* losing an owner; a quorum makes losing one *harder* to tolerate. +- **A timer that "applies if owners are offline" is not consensus.** A consensus protocol never commits without a quorum; the moment a change is applied on timeout it becomes optimistic replication — which is what this design already is — so the timer needs the same convergence layer underneath and only adds latency plus a relay round-trip. The cases collapse: a lone writer never conflicts; concurrent edits to *different* members converge with or without it; a concurrent same-member edit still resolves by a deterministic tie-break; a partitioned timeout applies on both sides and then reconciles by that same tie-break. Nothing is removed. +- **Reconfiguration is the hardest part, and it is the headline feature.** Changing a consensus group's membership is the most error-prone part of every such protocol, and "add an owner" *is* reconfiguration. + +### A.2 Consensus among relays fails on trust, fault-tolerance, and bootstrap + +Relays are the stronger candidate: they are always-on, so A.1's liveness objection does not apply — a relay quorum commits without any owner online — and administrative content stays owner-signed, so relays would order operations, not author them. It still fails: + +- **Fault-tolerance needs a relay count channels do not have.** Tolerating one *malicious* relay needs 4 relays (BFT, n ≥ 3f+1); one *offline* honest relay needs 3 (CFT, n ≥ 2f+1). Channels run 1–3 relays, so Byzantine safety is unreachable and the design collapses to assuming every relay orders *honestly* — the exact trust the architecture refuses to grant, since a relay may be operated by anyone. +- **Ordering power alone is an attack, even on signed content.** A dishonest relay inside the agreement can equivocate (show different orders to different members → a forked roster), censor (stall an operation it is party to), or reorder to exploit semantics ("remove O2" before "O2 promotes X"). Signatures stop forgery, not reordering, and CFT gives no protection at all. Today no single relay can fork governance — the owner's latest signed state wins through any honest relay — so this is a strict regression. +- **It is a large new subsystem for a small gain.** Relays do not talk to each other today; consensus requires an all-to-all inter-relay mesh plus a full BFT/CFT protocol — security-critical code whose net effect raises complexity while only the roster gets simpler. +- **Relay reconfiguration is circular and can veto itself.** The relay set is mutable governance the owners control, so every relay add/remove/rotate is a consensus-cluster reconfiguration; the worst case is a relay stalling the operation that would *remove it*, defeating the relay-transience objective (owners must be able to remove a bad relay, including the last one). +- **It cannot cover the bootstrap root, so it does not unify.** Link data (profile, relay list, owners chain) and owner keys live on the SMP link-queue server — the entry point a joiner reads with only the short link, before it knows any relay, and where the relay list itself lives. That server already sequences those writes censorship-free via CAS; the actual commit is the owner's LSET, which relays cannot and must not perform (a link-queue key also authorizes `LDEL`). Routing link-data changes through relay-consensus would be redundant with the SMP sequencer and would hand relays a veto over governance — including their own removal. So relay-consensus could cover only the roster, leaving link data and keys on CAS: two sequencing systems, not one. + +The general rule: a relay-provided sequencer is only worth considering for state that has *no* sequencer *and* that relays have no conflict of interest about. Link data fails both, owner keys live on the SMP server, the count is cosmetic — leaving only the roster, where the costs above outweigh the gain. + +### A.3 A single designated relay as the roster sequencer — also rejected + +The lightest form — one designated relay holding the roster under hash-CAS (no inter-relay consensus, reusing the link-data pattern) — is rejected too: it concentrates trust in a single relay that can then fork the roster by serving different states to different members, or freeze roster writes whenever it is offline. That worsens the current threat model, in which no single relay can fork governance. The deterministic tie-break (§4) deliberately needs zero trusted parties; a single-relay sequencer trades that property away for a sequencer the roster already approximates without trust. + +### The coordination the design does use + +The right-sized coordination is already in place: single-acceptor CAS for the server-backed states (§2, §3); a two-party propose → accept → commit handshake for owner promotion (§6), where the action is inherently bilateral (consent) rather than a quorum; and deterministic convergence for the roster (§4), which needs no trusted sequencer. A future M-of-N multisig (governance roadmap) is an *authorization* threshold — who must approve — a different axis that still rides this same convergence substrate and does not need a classical consensus protocol for race resolution. diff --git a/plans/2026-07-15-channel-multi-owner-s1.md b/plans/2026-07-15-channel-multi-owner-s1.md new file mode 100644 index 0000000000..73c55d897b --- /dev/null +++ b/plans/2026-07-15-channel-multi-owner-s1.md @@ -0,0 +1,348 @@ +# Channel multi-owner + +A channel can have several equal owners, per `docs/protocol/channels-overview.md` §Governance ("v7: any-owner-decides"). Any owner can take any administrative action without coordinating with the others: post, moderate, change the profile, manage relays, update the link data, add owners, destroy the channel. The channel keeps working after any owner, including its creator, leaves. Owners cannot remove one another in this version (§11). + +simplexmq already models most of this and leaves it unwired: + +- `UserContactData.owners :: [OwnerAuth]` is a signed chain: each entry is signed by the root key or an earlier entry (`validateLinkOwners`, `Agent/Protocol.hs:2003-2017`). +- Readers accept user data signed by the root key or any chain owner (`ShortLink.hs:114-116`). +- `ShortLinkCreds.linkRootSigKey` exists for a writer that is not the root (`Agent/Protocol.hs:1602`), and `validateOwners` accepts such a writer when its key is in the chain (`:1991-2001`). +- `RKEY` gives a link queue several recipient keys, and any of them authorises every recipient command (`Server.hs:1288-1289`, `:1574`). + +The single-owner assumption lives in chat. `groupLinkData` republishes only the creator as owner (`Internal.hs:1670-1683`). Only the creator's agent holds the link queue. `APIMembersRole` refuses the owner role in channels (`Commands.hs:2994-2995`). + +The design rests on five decisions: + +- **No SMP change.** A promoted owner writes link data with today's `LSET`. The server requires the stored encrypted fixed data to be resent byte-identical (`QueueStore/STM.hs` `addQueueLinkData`), and `LGET` returns it exactly as stored (`Server.hs:1671-1672`). Write access comes from `RKEY`. +- **One key per owner.** An owner's member key is its `OwnerAuth.ownerKey`, the key that signs the link data it writes, and its recipient key on the link queue. The exception is the creator: it keeps its agent's queue key and publishes it with its memberId (§3). +- **Every link write by an owner of a multi-owner channel reads the link first** and changes only what its action changes (§2). +- **A promotion requires the promoter to have verified the new owner's security code.** There is no invitation handshake (§6). +- **The roster stays a single-writer protocol for members and relays**, with a few rules for owners that write concurrently (§4). + +## 1. What lives where + +| State | Lives on | Written by | How it converges | +|---|---|---|---| +| Link data: profile, relay list, owners chain, departed owners, count | one blob on the link queue's SMP server | any owner | read, change one thing, write; owners and departed owners only grow; the writer re-reads after owner and relay changes | +| Link queue recipient keys | the same queue | any owner | computed from the link data; set with `RKEY` around the link write that changes the owners | +| Roster: member, moderator and admin roles | a signed blob on each relay; signed per-member events | any owner | today's version gate on members and relays; tie-break and re-send rules on owners | +| Subscriber count | link data | the creator | frozen when the creator leaves | + +There is no leader and no consensus (Appendix). + +## 2. Link data + +**Contents.** The link data holds the profile, the relay list, the owners chain and the count, as today. Chat's `GroupShortLinkData` (`Protocol.hs:1621`) gains three optional fields: + +- `revokedOwners :: [MemberId]` lists the owners that have left. It is the tombstone that stops a later write from bringing a departed owner back, and every party marks the owners it lists as left (§7). +- `creatorQueueKey {memberId, key}` is the creator's memberId and its link-queue recipient public key (§3). +- `linkRcvId` is the link queue's recipient ID, which every owner needs to send recipient commands (§6). It authorises nothing without a key in the recipient set. + +The creator writes its own `creatorQueueKey` and `linkRcvId` in every write it makes, so a write that dropped them is repaired by the creator's next one. + +simplexmq's link-data encoding is unchanged. Its `UserContactData` parser drops trailing fields it does not know (`Agent/Protocol.hs:2075-2079`), so a future agent-level field needs a version gate, since an older owner's write would drop it. + +**A channel is multi-owner** once this device holds an owner row other than its own, current or left. Owner rows are never deleted in channels (§7). The creator's writes before its first promotion stay as today (`setGroupLinkData`, `setGroupLinkDataAsync`, `Internal.hs:1588-1604`). + +Every write in a multi-owner channel, by any owner, runs under the group lock, in five steps: + +1. Read the link data (`LGET`). +2. Build the write from what was read, applying only this action's change. "What was read" means the read JSON, so fields this version does not know are kept. The action's change is one of: + - **profile edit:** the new profile, as a whole value; + - **owner added:** its entry, appended after the signer's entry, so the chain stays in signing order and `validateLinkOwners` holds as-is; + - **this owner leaving:** its memberId added to `revokedOwners`, with its own chain entry kept in this write (the agent accepts a non-root writer only while its key is in the chain, `Agent/Protocol.hs:1996-2001`); + - **relays:** the read list, plus the relays this action adds, minus the relays this action removes, minus relays this device holds as left. + + The writer then merges what it knows into what was read: + - owners this device holds as left go into `revokedOwners`; + - an owner this device holds as current, missing from the chain and not in `revokedOwners`, gets an entry signed by the writer (root key if the writer is the creator, otherwise its member key); + - an entry in `revokedOwners` is dropped from the chain once no current entry was signed by it, transitively, except the writer's own entry in its leave write. Entries are about 124 bytes, and the user data is padded to 13,784 bytes (`ShortLink.hs:46`); + - the creator writes its own `creatorQueueKey` and `linkRcvId`, and every other writer keeps the read ones; + - the count is the read count, unchanged (§5). +3. Write it (`LSET`). +4. If the written chain differs from the read one, set the queue's recipient keys (§3). A promotion sets them before its write instead (§6). +5. After a write that adds an owner, leaves, or adds or removes a relay, read the link data again. If the change is missing, because another owner's write landed in between, repeat once from step 1. Relays are activated after this read (§8). + +**What this gives up.** Two owners writing within one read-write round trip can lose one write. Steps 2 and 5 limit the damage as follows: + +- **Owners and departed owners** come back with the next write of any owner that knows them. +- **A relay change** that the re-read misses has to be repeated by hand. Until then, a relay missing from the published list deactivates itself at its next 15-minute check, and deletes its connections to the channel a day later (`checkRelayServedGroups`, `checkRelayInactiveGroups`, `Commands.hs:6001-6026`; `relayInactiveTTL`, `Chat.hs:135`). +- **A removed relay that a concurrent write keeps listed** stays listed for good, because nothing records the removal. Joiners and subscribers fail to connect to it, since its link no longer accepts them, and connect through the other relays. +- **A profile edit** has to be repeated by hand. Members who applied the lost edit's `XGrpInfo` show it until they next open the channel and read the link data (`updateGroupFromLinkData`, `Internal.hs:1634-1654`). +- **A single-owner write the creator queued just before its first promotion** (the agent sends it later, `setGroupLinkDataAsync`) can land after the promotion and drop the new owner's entry, `creatorQueueKey` and `linkRcvId`. The new owner cannot write the link until the creator's next write puts them back; the creator's leave is such a write (§7). Until then, joiners do not take the new owner from the chain, so they treat it as a subscriber, as older members do. The creator keeps its own access: that write does not change the queue's keys. + +**Reconcile.** An owner reads the link data when it opens the channel, and when its chat starts in a multi-owner channel. The apps' owner branch of the link-data refresh, and the core's (`Commands.hs:1968`, `ChatView.swift:764-779` and its Kotlin counterpart), are replaced by this reconcile. From the read it: + +- applies the profile, as subscribers do; +- applies the owners chain, as every party does (§6, §7); +- connects to listed relays it has no relay member for (§8). + +The creator also publishes its count during reconcile (§5). Only the creator makes automatic link writes; a promoted owner writes only from its user's actions (§9). + +## 3. Link queue recipient keys + +The queue's recipient key set is computed from the link data: + +- `creatorQueueKey.key`, while `creatorQueueKey.memberId` is not in `revokedOwners`; +- the `ownerKey` of every other chain entry not in `revokedOwners`. + +The creator's queue was created with a key its agent generated. The creator keeps it, so its link connection and subscriptions are unchanged. It publishes the key with its memberId as `creatorQueueKey`: first in the write that adds its first co-owner, then in every write it makes. A promoted owner's recipient key is its member key, which is already public in the chain. Either way, link holders can see the queue's recipient public keys; they authorise nothing without the private keys. + +`RKEY` replaces the whole set, so every `RKEY` sends the full set computed from the link data the writer just read or wrote. The order of steps keeps the set consistent with the chain: + +- **Promotion:** `RKEY` adding the new owner's key comes first, then the chain write (§6). If the promoter stops between them, the new owner's key is authorised before its entry is published, until the next owner change drops it. Its holder is the member being promoted, whose code the promoter verified. +- **Leave:** the `revokedOwners` write comes first, then `RKEY` without the leaver's key (§7). If the leaver stops between them, its key stays authorised until it repeats the leave or another owner changes the owners. + +**What this gives up.** Two owner changes within one round trip can each send a key set computed from data without the other's change: + +- A new owner's key can be missing. Its link writes then fail with `AUTH` until the next owner change. +- A departed owner's key can stay authorised until the next owner change, which may never come. The departed owner erased its private key after leaving (§7), so nobody holds it, unless the owner copied it while still an owner. Such an owner was already able to do anything an owner can, including deleting the channel. + +The re-read in §2 step 5 sees the chain but not the key set. `recipientKeys` is `NonEmpty` (the server rejects an empty set), and "at least one current owner" is a client-side precondition of leaving (§7). + +## 4. Roster + +The roster is the owner-signed set of member, moderator and admin roles. Owners are in the link chain, not in the roster. It has two forms: + +- signed per-member events (`x.grp.mem.role`, `x.grp.mem.del`) that carry a roster version; +- a versioned blob, which relays store and serve to joiners and to members that see a version gap (`serveRoster`, `Internal.hs:1295-1315`). + +It is a single-writer protocol: + +- Members and relays apply an event at a version at or above their gate, and advance the gate (`applyAtRosterVersion`, `Subscriber.hs:3404-3446`). That gate is also what stops a relay from replaying old signed role events. +- A completed blob replaces the roster and reverts privileged members absent from it (`Subscriber.hs:3572-3578`, `processRosterEntries` `:3610-3619`). +- An owner makes each change at its gate plus one (`broadcastRoster`, `Internal.hs:2642-2645`). + +Members and relays keep this protocol unchanged. With several owners, two owners can make a change at the same version. Members and relays apply both events, because the gate accepts an equal version, but a relay keeps whichever blob arrives last. Seven rules handle owners that write concurrently. + +1. **Equal-version tie-break on relays.** At the stored blob's version (`stored_roster_version`), a relay replaces its stored blob only if the incoming blob's author memberId is not lower than the stored author's (`roster_sending_owner_gm_id`). Today the later arrival wins (`Subscriber.hs:3572-3578`). With the tie-break, every relay serves the same blob whatever the arrival order. +2. **Concurrency re-send.** An owner that applies another owner's role or removal event at a version equal to its own gate re-sends its current blob to relays at its gate plus one, as a blob without events. It does this once, after it has processed the whole received message. + - The blob that won rule 1 may have reverted one of the two changes on relays. The re-sent blob is built after the owner applied both, and its higher version replaces any blob at the old version, whatever its author. + - Members already hold both changes. Their next event reads as a gap, so they fetch the served blob once (`Subscriber.hs:3438-3446`). +3. **Departure re-send.** When an owner applies another owner's departure, it re-sends its current blob to relays at its gate plus one, as rule 2 does. It does the same when it applies a served blob whose author has left (rule 6). A departure is applied either from the departed owner's `x.grp.leave` or from `revokedOwners` in a link-data read. + - A departure read from the link data can arrive before the departed owner's last messages, which this device then drops (`Subscriber.hs:4019-4020`), so its gate can stay below the relays'. The owner therefore first asks every current relay for the roster, before its own re-send, so the request carries its frontier from before that re-send. It re-sends again once the served blob is applied (rule 6). + - Joiners verify a served blob against its author's key and ignore a departed author (`Subscriber.hs:3506`, `:4019-4020`). The re-send's higher version replaces the departed author's blob on every relay. +4. **Owners are outside the roster.** + - Applying a roster skips members who are current owners, and members held as removed or left. Otherwise a blob made while a new owner was an admin would set it back to admin, and a stale blob would bring back a removed member. + - An `x.grp.mem.role` or `x.grp.mem.del` whose target is a current owner (this device's own membership included) is ignored, and so is an `x.grp.mem.role` that assigns the owner role. `APIMembersRole` and `APIRemoveMembers` refuse owner targets. + - A current owner has the owner role and is neither left nor removed. +5. **An owner's own versions advance its complete frontier.** That covers its changes and its blob-only re-sends (rules 2 and 3). Today only received events advance the frontier (`Subscriber.hs:3413-3421`), so every own change would leave an owner a false gap. +6. **An owner sends a blob only when it is in sync.** It sends a blob, for its own change or for rules 2 and 3, only when its complete frontier equals its gate. While it is out of sync: + - it sends `XGrpRosterRequest` to every current relay, and again with each role change it makes; + - its role and removal events go out without a blob, so they cannot overwrite relays with a stale view; + - rules 2 and 3 wait until it is back in sync. + + Every owner, in sync or not, applies a served blob even when the blob's author has left. It verifies the blob with the key it kept for that author, at a version not below its gate. Content from a departed author is otherwise ignored (`Subscriber.hs:4019-4020`). Without this exception, an owner could not catch up after the blob's author left: neither one that is out of sync, nor one that dropped the author's last messages after reading its departure from the link data (rule 3). +7. **An owner back after a long absence requests the roster.** When its chat starts and the newest item it received in the channel is older than message expiry (21 days by default), or there is none, each owner sends each relay `XGrpRosterRequest` with its complete frontier, as members do on a gap. It then holds the current gate before it changes roles. + +**What this gives up.** + +- **A dropped role change.** A change can be dropped on some devices and relays when its version is below their gate. A relay that drops an event does not forward it (`Subscriber.hs:3423-3425`, `:1133-1144`), and the author still shows the change. This happens: + - when two owners change roles within seconds of each other and one of them makes more than one change in that time, or when three owners do; + - when an owner changes a role right after a long absence, before its request (rule 7) is answered. + + When the dropped change is a demotion that some relays had applied, another owner's later blob, made without it, reverts it on those relays. Rule 4 still keeps a removed member removed. A dropped role change is fixed by repeating it. A dropped removal cannot be repeated: the author's member list already shows the member removed. +- **Two owners changing the same member at the same version.** Members keep whichever event they received last, while relays and joiners keep the value in the higher author's blob. +- **An owner that cannot get back in sync.** An owner stays out of sync in two cases: + - No relay stores a blob at its gate (the last author's blob send failed, and that author has left). + - It makes a role change before the blob it asked for arrives. The change reserves a version above that blob, so the blob is then refused (`Subscriber.hs:3574-3575`), and relays do not serve it again (`:3685`). + + It stays out of sync until an owner that is in sync sends a blob. A sole remaining owner stays out of sync for good. Its role changes still reach relays and members as events. Joiners, and members that fetch the roster on a gap, get the last blob without them, and a member it demoted shows at its earlier role to them. Relays applied the demotion, so they do not forward that member's actions. +- **The last changes before a leave.** A role change made seconds before an owner leaves can reach a device after the leave, and devices ignore content from a departed author (`Subscriber.hs:4019-4020`). A relay can also forward an owner's events out of order (`Store/Delivery.hs:178-202`, `Delivery.hs:115-120`); this exists in single-owner channels too (§11). A device that reads the link data before it receives a departed owner's last messages drops them as well, posts included. The remaining owners' blob re-send (rule 3) carries the change to relays only if they applied it, or once they apply the served blob. Until then, a remaining owner's own role changes can fall below the relays' gate and be dropped. + +These outcomes need two honest owners acting within seconds, or an owner acting right after a long absence. A relay can add nothing beyond delaying or reordering one owner's events, as today. + +## 5. Subscriber count + +The count is cosmetic. Today the owner branch of `updatePublicGroupData` (`Internal.hs:1620-1627`) recounts and republishes on every join, leave and removal. In a multi-owner channel: + +- That branch runs only on the creator. A promoted owner has no group link row, so the branch would fail there, and its tally misses the subscribers who joined before its promotion. +- The creator publishes its tally only during its reconcile (§2), through the read-merge write, instead of on every event. +- Every other write carries the count it read. +- When the creator leaves, the count stays at its last published value (§11). + +## 6. Owner promotion + +**Trigger.** `APIMembersRole [M] GROwner` on a channel, by any owner. It is refused unless: + +- M is the only target and a current member with a key, not an owner, and not in `revokedOwners`. A former owner cannot be promoted again with the same identity. +- **The promoter has verified M's security code.** `member_security_code` (`setGroupMemberVerified`, `Store/Groups.hs:1754-1760`) must equal the code computed from both keys now (`getChannelMemberCode`, `Commands.hs:3811-3815`). + - The promoter knows M's key from relay-asserted intros. A relay that substituted it would become an owner, and owners cannot be removed (§11). The out-of-band comparison detects the substitution. + - Checking that M confirms the key over a relay would not: a substituting relay would confirm with its own key. +- Every current relay supports `multiOwnerVersion` (the next `VersionChat`). + +M's own version is not checked. The promoter cannot know it: it learns a subscriber's version only from that subscriber's join (`Store/Groups.hs:2533-2551`). An older M ignores the commit, and becomes an owner after it upgrades and reads the link data (below). + +**Steps** (synchronous, under the group lock): + +1. Read the link data. Build the write with M's `OwnerAuth` appended. The creator signs it with the root key (as it signs itself, `Commands.hs:2757`); any other owner signs it with its member key, after its own entry. The creator's first promotion also adds `creatorQueueKey` and `linkRcvId`. Encode, sign and size-check the write now. A promoted owner's write that would not fit fails here, before any key change. The creator's write is size-checked only when it is sent (`setConnShortLink`), so the creator's oversize write fails at step 3, after its `RKEY`. +2. `RKEY`: the set computed from what was read, plus M's member key, plus the creator's own queue key on its first promotion. +3. Write the link data. +4. Re-read the link data. If M's entry is missing, repeat once from step 1. If a step fails after step 2, the command fails, and M's key stays authorised until the next owner change (§3). +5. Set M's role to owner locally. Send `x.grp.owner.add {memberId, ownerAuth, profile}` to the relays. The profile is the redacted one, as member intros send it (`redactedMemberProfile`, `Internal.hs:1361`). + +**The commit, `x.grp.owner.add`.** Every relay and member accepts it only when all of these hold: + +- the sender is a current owner (owner role, neither left nor removed; the event requires a signature); +- `ownerAuth.ownerId` is `memberId`; +- `ownerAuth` is signed by the channel's root public key, or by an owner key this device holds, current or left (a delegated entry stays valid after its signer leaves, §2); +- this device does not hold `memberId` as a member that left or was removed. Every link-data read turns memberIds in `revokedOwners` into such rows (§7). + +It then sets the member's role to owner and its key to `ownerAuth.ownerKey`, replacing a different relay-asserted key with a warning. If the member is unknown, it is created with the profile from the event. Relays introduce owners only to later joiners (`introduceInChannel`, `Internal.hs:1320-1332`), so an earlier subscriber may not know M. Today's admin promotion carries no profile: earlier subscribers see an admin under a name derived from its memberId. A promoted owner's redacted profile reaches every subscriber. + +Relays forward the commit to members. It is a new event, not `x.grp.mem.role … GROwner`, for two reasons: + +- older clients ignore it and keep rejecting M's admin and signed messages, which fails closed; +- `x.grp.mem.role` would raise whatever key a relay asserted for M in an intro (`Subscriber.hs:3465-3468`). + +**Owners from the link data.** Every party applies the owners chain whenever it reads the link data: members when they open the channel, owners in their reconcile, relays at invitation and in their 15-minute check (§8). For each chain entry not in `revokedOwners`, whose member this device holds neither as owner nor as left or removed, the party sets that member as owner with `ownerKey`. The agent has already validated the chain against the root key (`ShortLink.hs:114-116`). + +This also covers a promoter that stopped after its write, and a commit that was lost or expired on the way. + +**M's own device.** M becomes an owner in either of two cases: + +- when it applies the commit about itself; +- when a link-data read shows its memberId in the chain, not in `revokedOwners`. + +In both cases `ownerKey` must be its own member key, and its membership must be current. Otherwise it shows an alert and stays a subscriber. + +M already has connections to the relays it joined as a subscriber, and those relays now treat it as owner. It holds every credential for link writes, and stores nothing new: + +- the server and `LinkKey` from the channel link; +- `linkRcvId`, which each of its link writes takes from the link data it reads first (§2); +- the root public key; +- its member private key. + +It sends none of them to the link server until its user acts (§9). + +**A removal of M racing M's promotion.** Each device ends with whichever event it applied first. Where the commit came first, the removal targets a current owner and is ignored (§4 rule 4). Where the removal came first, the commit is refused. M ends removed on some devices and an owner on others and in the link. + +**What the new owner sees.** It knows: + +- the subscribers it knew before; +- those who join after its promotion (relays announce joiners to every owner, `introduceInChannel`, `Internal.hs:1320-1326`); +- those who post. + +It cannot see, verify or promote a silent subscriber who joined earlier (§11). + +## 7. Leaving, and destroying the channel + +**Leave.** Any owner can leave while the link data it reads, merged as every write merges it (§2), shows another current owner, that is another chain entry not in `revokedOwners`. The core refuses the last owner. Steps (synchronous): + +1. Read the link data, merge it as every write does (§2), and check that the result shows another current owner. The merge re-adds a current owner missing from the chain. +2. Write the link data with its own memberId added to `revokedOwners`, keeping its own entry (§2). +3. Re-read, and repeat once if the entry is missing. +4. `RKEY` with the set computed from what was written, which no longer includes its key. +5. Send `x.grp.leave` to the relays and delete its connections, as today. +6. Erase its link credentials and keys: + - the creator deletes its link connection locally, without `DEL`; + - the creator erases its root private key, keeping the root public key; + - every leaver erases its member private key. A later load of the group generates an unrelated key, so the erased one is gone. + +**Repeating a leave.** A crash or a failed step leaves the leave half done. What happens next depends on where it stopped: +- **Before step 5.** The membership stays current, because applying `revokedOwners` never changes this device's own membership (below). The app keeps offering Leave. A repeated leave that finds its memberId already in `revokedOwners` continues at step 4, and an `AUTH` error at step 4 counts as done. Until then, the device keeps its key, and devices that have not read the link data since still treat it as owner. Deleting the chat in this state is still a current owner's destroy: it sends `x.grp.del` and `DEL` on the link queue, the creator's through its link connection, a promoted owner's with its credentials (below). +- **After step 5.** The chat is left. Deleting it completes step 6's link deletion locally, never with `DEL` (below). + +**Why no `DEL`.** Today `APILeaveGroup` deletes the link connection with `DEL` on the shared queue (`deleteGroupLinkIfExists`, `Commands.hs:3246`, `Internal.hs:1739-1748`). After step 4 the creator's key is normally no longer authorised, but a concurrent `RKEY` by another owner can authorise it again (§3), so the deletion is local. + +**Why erase the keys.** A key that a race leaves authorised then has no holder. The root key cannot be revoked: it is the trust anchor in the signed fixed data. + +**Revocation is durable on every device.** In channels: + +- **Owner and privileged rows are never deleted.** Item expiry, Clear chat and removed-member cleanup leave alone every member row with the owner role or a roster role (`getGroupMembersForExpiration`, `Store/Groups.hs:1357-1373`, used at `Commands.hs:1429-1430` and `:6078-6079`; `getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`). Today these delete left and removed members that have no chat items. +- **An intro never changes the status of a member held as left or removed.** Today an intro sets any existing channel member current (`xGrpMemIntro`, `Subscriber.hs:3314-3322`, `updatePreparedChannelMember`, `Store/Groups.hs:3608-3623`). +- **Every link-data read applies `revokedOwners`.** Every member row whose memberId is listed is marked left, whatever role this device holds it at, because a device that missed the commit holds the owner at its earlier role. This device's own membership is the exception: a listed membership means a half-done leave. A relay also drops that member's connection, as `xGrpLeave` does. For memberIds it holds no row for, it creates rows of members that left. This happens on members when they open the channel, on owners in reconcile, and on relays at invitation and in their 15-minute check (§6). + +A departed owner's later content is then ignored everywhere (`Subscriber.hs:4019-4020`), as are a replay of its old `x.grp.owner.add` and an intro that would revive it. A device that has not read the link data since the leave, and whose relays withheld the leave, keeps treating the departed owner as current until its next read. + +**Members and relays** handle an owner's `x.grp.leave` as today: they mark the owner left and drop its connection (`xGrpLeave`, `Subscriber.hs:3815-3826`). **Joiners** take owners from the chain as today (`Commands.hs:2324-2332`), and create memberIds in `revokedOwners` as members that left. **Remaining owners** re-send their roster blob when they apply the leave (§4 rule 3), and drop the leaver's chain entry in a later write once no current entry was signed by it (§2). + +**Two last owners leaving at once.** Each can read the other as current and verify its own write before the other's write lands. Both leave, and the channel ends with no owner. + +**Destroying the channel** stays any current owner's action through `APIDeleteChat`, which sends `x.grp.del` (`Commands.hs:1384-1400`). The creator deletes the link queue through its link connection, as today. A promoted owner deletes it with `DEL` using its link credentials, and only when it sends `x.grp.del`. `deleteGroupLinkIfExists` runs whether or not `x.grp.del` was sent, so it never sends this `DEL`. A creator whose membership is no longer a current owner, for example after a leave that stopped after step 5, deletes its link connection locally when it deletes the chat, never with `DEL`. + +**Group-link commands.** `APICreateGroupLink`, `APIAddGroupShortLink` and `APIDeleteGroupLink` are refused for channels (`Commands.hs:3349-3384`): + +- a channel's link is created with the channel; +- the apps would otherwise create a direct group link for a promoted owner that opens the link screen (`GroupLinkView.swift:164-166`); +- `APIDeleteGroupLink` sends `DEL` on the shared queue. + +## 8. Relays + +- **Invitation.** A relay invited by any chain owner reads the link data as today, and checks the inviting owner's key against the chain, excluding `revokedOwners` (`getLinkDataCreateRelayLink`, `Subscriber.hs:4586-4599`). `updateRelayGroupKeys` creates an owner row for each chain owner it has no row for, as joiners do (`createLinkOwnerMember`); today it throws (`Store/Groups.hs:2366-2373`). It applies `revokedOwners` (§7). +- **15-minute check.** The relay's periodic link check, which already reads the link data (`checkRelayServedGroups`, `Commands.hs:6001-6020`), also applies the owners chain and `revokedOwners` (§6, §7). +- **Commit.** A relay applies `x.grp.owner.add` (§6) and forwards it, which upgrades M's existing subscriber row. It is added to the relay forwarding set (`isForwardedGroupMsg`, `Protocol.hs:528`) and to forwarded-event handling (`processForwardedMsg`, `Subscriber.hs:4041-4064`). +- **Relay removal and a relay's leave.** + - The owner that removes a relay publishes the removal through §2, subtracting that relay. Today only the creator's `updatePublicGroupData` publishes it, and the removed relay's member row is deleted (`Internal.hs:2195-2196`). + - A relay that leaves on its own is published by the creator when it processes the leave. + - Any owner's later write subtracts a relay it holds as left. +- **An owner joins a relay another owner added.** + - **How the owner joins.** It joins through the relay's link, the path subscribers use. It connects this way when it receives `XGrpRelayNew` (`xGrpRelayNew`, `Subscriber.hs:3881-3886`), and in reconcile for any listed relay it has no relay member for. + - **Relay side.** The relay accepts a join onto an existing owner row when the join is signed with that row's key, with the same `viaRelay` replay check as for roster roles (`memberJoinRequestViaRelay`, `Subscriber.hs:1696-1714`). It refuses any join onto a row of a member that left or was removed; today the roster branch checks the key but not the status. + - **Owner side.** Completing the join updates only the relay member. `updatePreparedUserAndHostMembers'` would replace the owner's group profile with the relay's copy and, for the creator (`GSMemCreator`), its membership role and status (`Store/Groups.hs:821-844`). The `CON` handler for a host member would set the membership connected and create the joined-channel items (`Subscriber.hs:892-922`). + - **History.** The relay sends the joining owner recent history as to any joiner. Posts older than message deduplication (30 days) come back to it as received items. +- **History for joiners.** A relay serves an as-channel history item whose signed author is no longer a current owner as an unsigned channel item (`FwdChannel`). Joiners have no key or owner role for a departed author and would drop it. Today a signed as-channel item goes out under its author (`itemForwardMsgs`, `Internal.hs:1540-1548`). + +**What this gives up.** + +- **A relay addition can stay unpublished.** This happens in two ways: + - The background link write after the relay's acknowledgement is not retried if it fails. The relay has already moved to `RSAcknowledgedRoster`, which nothing publishes again (`xGrpRosterAck`, `Subscriber.hs:3658-3672`), so only removing and re-adding the relay recovers it. + - If another owner's role change advanced the adding owner's roster version before the relay acknowledged, the acknowledgement no longer matches and only warns (`Subscriber.hs:3660-3667`). The relay stays unpublished until the owner next sends a blob (§4). +- **Inviting a relay that already serves the channel.** A promoted owner cannot tell which configured relay already serves the channel, because relay links are per channel. If it invites such a relay, that relay creates a second group for the channel. + +## 9. Security and privacy + +- **Owner keys come only from signed chain entries and owner-signed commits.** Relays still cannot create owners. The verified code (§6) means a relay that substituted a member's key cannot become an owner through a promotion. +- **Any owner can destroy the channel.** Any recipient key authorises `DEL` and `LDEL`. Any owner can also replace the key set with `RKEY`, locking the others out of the link. Each owner is trusted as today's single owner is; this is inherent to equal owners. Owners cannot remove one another, so a lost or compromised owner device stays an owner (§11). +- **Departed owners keep no power.** + - Revocation is durable on every device (§7). + - A departed owner erased its keys (§7). + - A departed owner that kept a copy of its member key, or the creator a copy of its root key, while still an owner can still act with it: + - with the link's SMP server's help, it can serve forged link data to readers, including new owner entries that every party then applies (§6); + - through a colluding relay, it can serve a roster blob it signs with the kept key, at any version not below an owner's gate, which that owner applies and re-sends (§4 rules 3 and 6). A colluding relay alone can replay a blob the departed owner signed before leaving. + + Such an owner was already able to do anything an owner can. +- **The link server sees a promoted owner's network address** when it writes. Recipient commands are not proxied (`Server.hs:2205-2211`), so the owner's device connects directly to the server the creator chose. + - That device sends link-server commands only when its user acts: a profile edit, a relay change, a promotion, a leave, or destroying the channel. The app says so before the first one. + - These commands, and the link reads in the same write when they are not proxied, use a transport session of their own per link. The default session is shared per user and server (`mkTSession`, `Agent/Client.hs:1442-1443`), so a shared session would let the server tie the owner's link writes to its other queues there. + - The creator has the same exposure today. Tor or SOCKS avoids it. +- **The promoted member is not asked first,** as with admin promotion today, which also reveals the member to every joiner (`introduceInChannel`, `Internal.hs:1320-1332`). The commit also gives every existing subscriber its redacted profile (§6). +- **`linkRcvId` is readable by anyone with the link.** It authorises nothing without a key in the recipient set. +- **The creator is identifiable among owners.** Its chain entry is first, its link writes are signed with the root key, `creatorQueueKey` names it, and the owners it promotes carry root-signed entries. This qualifies `channels-overview.md:159`. +- **Races between owners are not adversarial.** The races in §2, §3, §4 and §7 need two honest owners acting within seconds. A relay can add nothing beyond delaying or reordering one owner's events, as today. + +## 10. UI/UX + +- **Promoter.** + - The role picker offers Owner in channels. If the member's code is not verified, it routes to the verification screen first. + - The confirmation states that owners cannot be removed, that any owner can delete the channel, and that a member with an older app becomes an owner after it upgrades. + - Remove and role change are hidden for owners in channels. +- **New owner.** It sees a service item saying it became an owner. Before its first link-changing action, the app states once that the device will connect directly to the channel's link server. +- **Link screen.** A promoted owner sees the channel's published link (`publicGroup.groupLink`), as subscribers do. The screen never creates a link for a channel. +- **Leave.** An owner can leave a channel. Leaving as the last owner shows the core's refusal. +- **Relays screen.** For a promoted owner it lists the channel's relay members, not only the relays it added. + +## 11. Later + +- **Owner removal.** + - `RKEY` without the target comes first. A remover that gets `AUTH` was itself removed first, so mutual removals serialise at the server. + - Then a `revokedOwners` write, a re-read, and `x.grp.mem.del`. + - The removed owner's own device handles it as its removal. + - Relays apply `revokedOwners` in their 15-minute check, as they already do for leaves. + - A race with a concurrent promotion is closed only by a compare-and-swap on the server: `LSET` and `RKEY` with an expected hash, under a new SMP version. The same change would close every race in §2 and §3. +- **Asking the member before promotion**, or promoting over a direct contact, which also carries the key end-to-end. +- **A new owner's member list.** Relays send it their current members on the commit. +- **A relay-sourced subscriber count** that keeps updating after the creator leaves. +- **Multi-writer roster convergence**, so that concurrent or stale role changes by several owners all reach every device. It would include per-member ordering on owners, relays reporting dropped events to their author, and catch-up from relays. It would also carry a departing owner's last changes. +- **Profile edits merged field by field**, and stale profile events rejected by a version in the link data. +- **A tombstone for removed relays.** +- **Relays forwarding in received order, and messages to a backlogged relay queued instead of dropped.** These are single-owner fixes too (`Store/Delivery.hs:178-202`, `Messages/Batch.hs:88-95`, `Internal.hs:2840-2846`). +- **The creator retiring the root key** after its first promotion. + +## Appendix — Why no consensus or leader + +Link data and recipient keys live on one SMP server, which orders the writes. Read, merge and write, with grow-only owners and departed owners, converges without consensus. A compare-and-swap would only close races between honest owners. + +The roster has no single server. A quorum of owners would need several owners online to change a role, which inverts the feature: multi-owner exists to survive the absence of owners. A relay quorum would need more relays than channels have, and it would trust relays with ordering, which the current model does not. + +A designated leading owner, or a designated relay, would bring back the single point that multi-owner removes. So the roster keeps the single-writer protocol, and owners that write concurrently follow the rules in §4. diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index 73c55d897b..39ae80c838 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -1,3 +1,5 @@ +> Superseded by the S3-min plans: `2026-10-15-channel-multi-owner-s3min.md` (overview) and `2026-10-15-channel-multi-owner-s3min-implementation.md` (implementation). + # Channel multi-owner A channel can have several equal owners, per `docs/protocol/channels-overview.md` §Governance ("v7: any-owner-decides"). Any owner can take any administrative action without coordinating with the others: post, moderate, change the profile, manage relays, update the link data, add owners, destroy the channel. The channel keeps working after any owner, including its creator, leaves. Owners cannot remove one another in this version (§11). diff --git a/plans/2026-07-21-channel-multi-owner-implementation-original.md b/plans/2026-07-21-channel-multi-owner-implementation-original.md new file mode 100644 index 0000000000..f6c3988002 --- /dev/null +++ b/plans/2026-07-21-channel-multi-owner-implementation-original.md @@ -0,0 +1,246 @@ +# Channel multi-owner — implementation plan + +Companion to the design overview (`2026-07-15-channel-multi-owner.md`) — read that first for *why*. This covers *what to build*: data model, migrations, types, protocol events, commands, and sequencing. It is a map for implementing agents, not an exhaustive edit list; trivial wiring (JSON instances, `CMEventTag` cases, view strings) is implied. + +The sequencer frame (overview §1) drives everything: link data and owner keys are sequenced by the SMP server via **hash-CAS** (the server keys on a content hash, not a version counter); the roster has no sequencer and converges by a deterministic blob tie-break plus owner-side auto-retry; the subscriber count is cosmetic and any owner corrects it on channel open. Each owner has **one key** — its member key is its `OwnerAuth.ownerKey`, its link-data signing key (the creator's link-data writes stay root-signed) and its link-queue recipient key — so the published owners chain is also the queue's key map. + +## Sequencing + +Two repos, in order. **simplexmq first** (the relay version bump, hash-CAS on the SMP link queue, the foreign-link write path, the creator's key rotation, forgetting a connection without `DEL`), then **simplex-chat** (data model, events, logic, apps). Multi-owner is gated on the link queue's server negotiating the new SMP relay version; below it, promotion is blocked, not degraded. Relays must accept channels with several owners a release before promotion is enabled (Part 8). + +--- + +## Part 1 — simplexmq + +### 1.1 SMP server — hash-CAS on the link queue + +- **Version bump.** New `VersionSMP` `linkCasSMPVersion = 24`; bump **both** `currentClientSMPRelayVersion` and `currentServerSMPRelayVersion` 23 → 24 (`Transport.hs:228-232`). The negotiated session version is the *min* of the two peers' relay ranges, so the client constant must also advance for a client to perceive and exercise v24. `proxiedSMPRelayVersion` stays at 23 (`:241-242`): recipient commands are not proxied. This is the SMP *relay* version (`VersionSMP`, gating `Command`/`BrokerMsg` encoding) — **not** `currentSMPClientVersion`, which rides the client↔client envelope and gates nothing server-facing. +- **The CAS token is a content hash, not a counter.** For link data it is SHA-256 of the stored *encrypted* user-data bytes (nonce + ciphertext, exactly as uploaded — the only thing the server can key on, since it cannot read the content); for `recipientKeys` it is SHA-256 of the encoding of the sorted key set. The server stores `recipientKeys` sorted, so it and the owners hash the same bytes. No server version counters. +- **The CAS is atomic in both queue stores** (the journal message store uses one or the other, `MsgStore/Journal.hs:107-117`). STM store: compare inside the existing STM transaction (`addQueueLinkData`, `QueueStore/STM.hs:168-188`; `updateKeys`, `:200-206`). Postgres store, with or without its cache: an `UPDATE … WHERE` predicate for both — link data `… AND sha256(user_data) = ?` (the blob is not loaded on the normal path: `rowToQueueRec` uses empty placeholders, `QueueStore/Postgres.hs:726-730`), keys `… AND recipient_keys = ?` against the value just read — because the Postgres message store runs with no cache and a no-op queue lock (`MsgStore/Postgres.hs:84`, `:100`), so each command holds its own copy and an in-memory compare would let two concurrent writes both pass. A 0-row update reads and returns the current value. +- **Two new recipient commands** (`Command Recipient`, gated ≥ v24; new tags rather than extending `LSET`/`RKEY`, so version-gating is clean): + - **`LSETH expectedHash linkId userData`** — hash-CAS on the user data only; the server keeps the stored fixed data. (`LSET` must resend the fixed data byte-identical, `QueueStore/STM.hs:178`, `QueueStore/Postgres.hs:319`, which a promoted owner cannot reproduce: encryption uses a fresh nonce and the read path keeps only the decrypted data.) If `hash(stored) == expectedHash`: store, reply `OK`. Else reply the current encrypted user data. The link-id check stays as in the `LSET` handler (`Server.hs:1575-1579`). + - **`RKEYH expectedHash keys`** — hash-CAS replace of the key set (as `RKEY`, `Server.hs:1574`). Accept: store sorted, reply `OK`. Else reply the current set. +- **One new `BrokerMsg`** for a rejection, carrying the current value (user data or key set); success is the existing `OK`, and the writer computes the new hash itself. Returning current state on rejection is what removes the writer's pre-read. +- **Auth unchanged:** both are recipient commands, so any key in `recipientKeys` authorises them (`Server.hs:1289-1290`, `:1333-1334`) — this is what lets a promoted owner write. `recipientKeys` stays `NonEmpty` (the parser rejects a zero-length set), so an `RKEYH` can never empty the set; the server has no notion of owner/revocation, so "≥ 1 *live* owner" is a client-side concern (Part 5). +- **Tests** (`tests/ServerTests.hs`, for each queue store): two concurrent `RKEYH` adds both land after one retry; of two concurrent `LSETH`, one is rejected with the winner's blob; `LSETH` cannot change the fixed data; the new commands' AUTH timing matches the existing "Timing of AUTH error" tests. + +### 1.2 SMP agent — hash-CAS APIs, foreign-link write, key rotation, local forget + +- **Stateless hash-CAS APIs.** Chat keeps the hashes; the agent takes the expected hash and returns either `OK` with the new hash (computed from the exact ciphertext it uploaded — never by re-encrypting, since encryption uses a fresh nonce) or, on conflict, the decrypted current data with its hash. The agent never re-merges (merge policy is chat's). No new agent-store columns. + - `setConnShortLinkCAS` — synchronous, on the device's own link queue. + - **An async variant** of the agent's `LSET` command (`setConnShortLinkAsync`, processed at `Agent.hs:2255-2258`), because the creator's link writes mostly run asynchronously: on success the `LINK` event carries the expected hash the command was issued with and the new hash; on conflict a new event returns the expected hash, the decrypted current data and its hash, and chat reconciles and re-enqueues. (The expected hash is the `baseHash` the announcing event needs, overview §2; reading it from chat's state when the event arrives would be wrong after an intervening reconcile.) + - `updateRcvKeysCAS` — `RKEYH` on the own queue. + - A local read of the own link queue's recipient id, which the creator puts in the promotion `inv` (today it is reachable only through `getConnectionQueueInfo`, a `QUE` round-trip to the server). + - A read of whether the link queue's server session supports v24 (connecting if needed; for the own queue and, with the server from the channel link, for a promoted owner), which the inviter checks before sending `inv`. Below v24 the CAS calls fail with `TEVersion`, as the client already gates version-dependent commands on `thVersion` (`directResolveName`, `Client.hs:1078-1084`); a sole live owner then writes with today's `setConnShortLink`/`setConnShortLinkAsync` (blind `LSET`), and with two or more live owners the write fails and is surfaced (Part 8). +- **Foreign-link path** — a promoted owner does not own the link queue. `setForeignLinkData`, `updateForeignRcvKeys` and `deleteForeignLinkQueue` (`DEL`, for destroying the channel) take **explicit credentials**: `SMPServer`, the link-queue recipient id (`linkRcvId`, delivered in the promotion `inv`; it is *not* derivable from the public short link, which yields only the sender-side `linkId`), the `LinkKey`, `rootPubKey`, the owner's member private key (it signs the user data and authorises the commands), and the expected hash. Validation builds the link credentials in memory with the root key set, so `validateOwners` checks that the signing key is a chain key (`Agent/Protocol.hs:1991-2001`) — no `rcv_queues` row and no stored `linkRootSigKey` (`AgentStore.hs:2903` keeps `Nothing` for the creator, whose signing key is the root). Prefer this over materialising a fake `RcvQueue`/`ContactConnection`, which would need an `rcvDhSecret` the owner must not have and risks it subscribing to the creator's queue. Reads use the existing `getConnShortLink`. Foreign calls are **synchronous**: an agent async command delivers its result to a connection, and a promoted owner has no link connection to receive it. They run on a transport session of their own, one per link (the default session is shared per user and server, `mkTSession`, `Agent/Client.hs:1442-1443`), so the link server cannot tie them to the owner's other queues. +- **Creator key rotation** — `setConnRcvAuthKey connId privKey` replaces the own queue's recipient key with the given one in three resumable steps: `RKEYH` adding the new key, persisting it as the queue's `rcv_private_key`, `RKEYH` dropping the old key. A rejected step returns the current set, from which the next step is decided. +- **Forget a connection without `DEL`** — a new local-only deletion; today every agent deletion path sends `DEL` (`deleteConnectionsAsync_`, `Agent.hs:1022-1030`). +- **`Crypto/ShortLink.hs`:** extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth` (an existing owner key — the promotee's member key — signed by the signer's private key); redefine `newOwnerAuth` on it; export (kills the formula duplicated in chat at `Internal.hs:1680`). The creator signs a new owner with the **root** private key (as `Commands.hs:2757` already does for itself), keeping the chain flat; a non-creator owner signs with its **own member** key (delegation). `validateLinkOwners` is **unchanged** — it accepts "signed by root or an earlier entry", which holds as long as chat writes the owners list in insertion (signer) order (Part 2), so no order-independence change is needed. +- **Tests** (agent): a conflict is returned on the sync and the async path, with the expected hash; a foreign-link write signed by a non-creator owner's key is accepted by a reader; a foreign `DEL` by a non-creator owner deletes the queue; the rotation completes after a crash between each pair of steps. + +--- + +## Part 2 — simplex-chat: data model & migration + +One migration `M20260721_channel_multi_owner` (register in `Migrations.hs` + `.cabal`, both SQLite and Postgres; `chat_schema.sql` regenerates from tests). All new columns nullable / defaulted, so `ALTER TABLE ADD COLUMN` is safe on the STRICT tables and remote-desktop JSON stays forward-compatible. + +### `group_members` (columns) +| column | on which rows | purpose | +|---|---|---| +| `owner_auth_sig BLOB` | owners | chain entry signature (with the existing `member_pub_key` = the entry's `ownerKey`, this is the full `OwnerAuth`) | +| `owner_auth_index INTEGER` | owners | **insertion (signer) order** in the chain — a new entry is appended after the entry that signed it; load-bearing for `validateLinkOwners`, *not* a memberId sort | +| `roster_key_version INTEGER` | any member (on an owner's device) | made-at version of the change that set the member's current role; `NULL` for an empty key, which only an event above `groups.roster_key_floor` beats (Part 6) | +| `roster_key_author BLOB` | ″ | that change's author `memberId` (with the version, the member's roster key; a removal's key lives on its `group_revoked_owners` row) | +| `roster_key_prev_version INTEGER`, `roster_key_prev_author BLOB` | ″ | that change's own `prevKey`, which a retry of it carries (Part 6) | +| `roster_key_resent INTEGER` | ″ | whether this owner has already re-sent the current key; cleared when the key changes (Part 6) | +| `roster_key_reported INTEGER` | ″ | one bit per relay-reported trigger: whether (5), and whether (7), has already re-sent the current key — separate bits, so one trigger's re-send never uses up the other's; separate from `roster_key_resent`, which (1)–(3) and (6) skip on; cleared when the key changes (Part 6) | +| `relay_subscriber_count INTEGER` | relays | latest absolute count that relay reported | +| `relay_subscriber_count_ts TEXT` | relays | the relay's own timestamp for that reading (the freshness token; cleared with the count on `GSMemRemoved`/`GSMemLeft`) | +| `promote_invitation_id BLOB` | the promotee (invitee row on O1; own membership on M) | pending promotion | +| `promote_role TEXT` | ″ | offered role | +| `promote_status TEXT` | ″ | invited / accepted / key_added / entry_published / rejected / cancelled / expired — the step reached drives resume and the undo on cancel (Part 5); `entry_published` is set *before* the chain's `LSETH` is issued, so from it on the entry may be live | +| `promote_link_rcv_id BLOB` | M's own row | link-queue recipient id (from `inv`) | + +`MemberRoleProposal` (Part 3) is *derived* from `promote_*`, not a stored column. Member cleanup (`getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`) skips a row whose `promote_status` is `accepted`, `key_added` or `entry_published`, so a chain interrupted by the member's removal can still be resumed and undone. + +In channels a relay's removal keeps its member row (status `GSMemRemoved`) and relay row (`RSInactive`), as a relay's own leave already does (`updateMemberRecordDeleted`, `Internal.hs:2218-2230`), instead of deleting both on either removal path (`deleteOrUpdateMemberRecordIO` and `fullyDeleteMemberRecordIO`, `Internal.hs:2192-2216`; `group_relays` cascades, `chat_schema.sql:839`): the next link write subtracts it (Part 6). `APIAddGroupRelays` then checks for duplicates only among relays whose member row is current (today any `group_relays` row, `Commands.hs:2782-2789`), so a removed relay can be added again; `group_relays` has no uniqueness on the chat relay, so the new invitation gets a row of its own. + +### `groups` (columns) +| column | purpose | +|---|---| +| `link_data_hash BLOB` | the hash this device holds for the published link data — the value it CASes on (from its accepted writes, rejections, and events whose base hash matched) | +| `key_set_hash BLOB` | last known key-set hash | +| `published_relays BLOB` | the last published relay list this device knows (from reconcile, accepted writes, and adopted events); a write's relay list is this plus the owner's own in-progress additions minus every relay whose member row on this device is removed or left (Part 6). `NULL` until first filled: a promoted owner reads the link data to fill it before its first write | +| `link_data_version INTEGER` | on every member and relay: the `linkDataVersion` it holds, from accepted writes, applied events and link-data refresh — on a relay only from an applied `XGrpInfo`, since a relay never applies the profile from link data (Part 6) and would otherwise drop the event that carries it; an `XGrpInfo` below it is ignored (Part 4) | +| `subscriber_count_ts TEXT` | the published `subscriberCountTs` this device holds, beside the existing `public_member_count`; non-count writes carry both unchanged (Part 6) | +| `roster_key_floor INTEGER` | on an owner: the roster version it held when it started keeping per-member keys (at the migration, or at its promotion); an empty key loses only to an event above it (Part 6) | +| `roster_blob_sent_version INTEGER` | on an owner: the version of the last roster blob it sent (a change, retry, relay setup or departure re-send); drives the equal-version re-send trigger (Part 6) | +| `stored_roster_owner_id BLOB` | cross-device author `memberId` of the **stored roster blob**, written beside `stored_roster_version` only by `setGroupLiveRoster`; the tie-break key (supersedes the device-local `roster_sending_owner_gm_id` for comparison) | +| `link_rcv_id BLOB` | for this user as a non-creator owner: the link queue's recipient id. With the server and `LinkKey` from the channel link and the existing `root_pub_key` and `member_priv_key` columns, it completes the foreign-link credentials (the creator keeps `GroupKeys` unchanged) | + +Do **not** add a `roster_version_owner_id` on the gate `roster_version` — the delta path (`setGroupRosterVersion`) must advance the gate *without* writing any blob author, or a delta would corrupt the blob tie-break (Part 6). + +### `group_roster_catchup` (new table) +`(group_id, relay_group_member_id, roster_version)` — on an owner, per relay, the highest roster version that relay has served it as catch-up (Part 6). + +### `group_roster_latest_events` (new table) +`(group_id, member_id, roster_version, author_member_id, shared_msg_id, msg_body, msg_chat_binding, msg_signatures, broker_ts)` — on a relay, for each member, a copy of the latest owner's `x.grp.mem.role` or `x.grp.mem.del` carrying a roster version that it applied, kept in the table itself as `groups.roster_msg_*` keeps the stored roster's signed header (`chat_schema.sql:205-210`), so neither message cleanup and chat-item TTL (`Store/Messages.hs:392-394`, `:3621-3623`) nor member cleanup removes it. Served to owners as catch-up and passed back to a blob's author (Part 6). + +### `group_revoked_owners` (new table) +`(group_id, member_id, member_key, key_drop_enforced, removal_step, roster_key_version, roster_key_author, roster_key_prev_version, roster_key_prev_author, with_messages, revoked_version, revoked_leave, roster_bound, hold_running_left)` — on every member's and relay's device, one row per removed memberId: every revoked owner, and every member removed by an applied `x.grp.mem.del` or by an owner-signed one whose target this device does not know (a removal can arrive before the promotion it revokes, through a different relay — overview §6). It lives outside `group_members` because member cleanup deletes a removed channel member's row a day after removal when it has no chat items (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2192-2203`; `cleanupRemovedMembers`, `Commands.hs:5960-5966`; `getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`). Rows come from `revokedOwners` at join and at relay invite, from link-data refresh, and from removal and owner leave events; they are never deleted. +- `member_id` — the replay record: `x.grp.owner.add` and `x.grp.mem.role` for it are ignored, a relay refuses any join onto it, and promotion refuses it (Parts 4–6). +- `member_key` — the key the heal drops (`NULL` when this device never knew it). +- `key_drop_enforced` — false when the revocation was first seen in the link data and the key drop is not yet confirmed; true once confirmed, or for a row from an event (a removal or leave event is sent after the key drop). Drives the heal (Part 6). +- `removal_step` — for a removal or leave this device runs: revoking / revoked / key_dropped / announced. The row is written with `revoking` before the first `LSETH`, so a crash at any point is re-driven at chat start through the announcement (Part 5). +- `roster_key_version`, `roster_key_author`, `with_messages` — on an owner's device, the key of a roster removal (made-at version and author) and its `withMessages` flag, kept here because the removed member's row is cleaned up; a removal beats any role change of that member, and a re-sent removal carries the flag (Part 6). A removal that carries no roster version — every removal in a call that removes no roster member (`Commands.hs:3136-3139`; a call that removes one stamps its version on every removal it sends, `:3196`) — has no key and is never re-sent; its record makes it stick on every device that saw it. A joiner records only `revokedOwners`, so if such a removal of S races another owner's promotion of S, a later joiner sees S at the promoted role from the relay's stored blob until the promoter's next roster change (cosmetic: S's connections are gone). +- `revoked_version`, `revoked_leave` — for a revoked owner, its `revokedOwners` entry's `rosterVersion` and `leave` as this device first saw or wrote them — from the link data, from this device's own revocation write, or from the departure event's `revokedVersion` (Part 4) — republished unchanged in its link writes; `NULL` only for a removed non-owner, which `revokedOwners` does not list. A row first created by the removal of a non-owner that raced the promotion it revokes takes the entry's values when the entry is first seen. +- `roster_bound`, `hold_running_left` — on an owner's device, for an owner that left and whose leave this device learned of from link data before processing its `x.grp.leave`: the roster version published with the leave (`revokedOwners`, below), and the running time left in the hold during which this device still applies that owner's held role and removal events (Part 6); `NULL` once the hold ends, when adoption runs. + +### `group_departure_notices` (new table) +`(group_id, member_id, relay_group_member_id)` — on an owner, the relays that have reported applying a departed owner's departure (`x.grp.owner.departed`, Part 4), recorded whenever a report arrives; a hold ends when every relay that was current and connected to this owner at its start, and not since removed or left, has reported (Part 6). + +An owner's own roster changes are the member keys whose author is this owner — no separate record. + +### No pending-edit record for whole-value fields +Profile/prefs edits are synchronous and write-then-commit (Part 6), so nothing is stored while they are in flight. Owners and revoked owners merge as a union keyed by memberId; a relay this owner is adding is durable in its relay row, and a removed or departed relay in its kept member row. + +### Published link data (not DB — chat's `GroupShortLinkData` JSON) +`GroupShortLinkData` (`Protocol.hs:1621`), carried in `UserContactData.userData`, gains optional fields — simplexmq's `UserContactData` encoding is unchanged: `linkDataVersion` (advisory ordering — recipients drop an older profile event; correctness rests on the server hash-CAS and reconcile, so a corrupted value only degrades live-event ordering, never stored state), `revokedOwners :: [RevokedOwner]` (`{memberId, rosterVersion, leave}` — the roster version its writer held when writing the entry, and whether the entry is the owner's own leave, whose held changes that version bounds, Part 6; a memberId appears once, and the merge keeps the first entry this device saw for that memberId), and `subscriberCountTs` beside `publicMemberCount` in `PublicGroupData` (`:1616`). `group_revoked_owners` locally holds `revokedOwners`, besides the other removed memberIds it records. + +--- + +## Part 3 — new Haskell types + +- **`MemberRoleProposal = MRProposed GroupMemberRole | MRRejected GroupMemberRole`** (`Types.hs`); `GroupMember` gains `memberRoleProposal :: Maybe MemberRoleProposal` (derived from `promote_*`) and the app-facing `promotionPending :: Bool`. +- **`PromotionRoleData`** for the wire: a role-tagged sum with an opaque fallback — `PRDOwnerInv { linkRcvId }` / `PRDUnknown` (decode an unrecognised tag to `PRDUnknown`, do **not** error — `omittedField` only covers an *absent* field, not a present unknown one). +- **`ForeignLinkKeys`** — the input to the Part 1.2 foreign-link APIs: `SMPServer`/`linkRcvId`/`LinkKey`/`rootPubKey`/member private key, assembled from `groups` columns, plus the expected hash. +- **`GroupLinkWriter = GLWOwn Connection GroupLink | GLWForeign ForeignLinkKeys`** — resolved once per link write (Part 6), from the keys: `GRKPrivate` (the creator, holding the root private key) → `GLWOwn`; otherwise a stored `link_rcv_id` → `GLWForeign`; neither → not a link writer. Never from whether a group link row exists (Part 5 refuses creating one for a channel). +- Reuse: `OwnerAuth` (simplexmq), `MemberId`, `MemberKey`, `VersionRoster`. + +--- + +## Part 4 — new protocol events (`ChatMsgEvent`, `Protocol.hs`) + +All `requiresSignature`. Wire is JSON with optional-field forward-compat (`.=?` / `opt`). + +| event | dir | fields | delivery | +|---|---|---|---| +| `x.grp.promote.inv` | O1→M | `invitationId, memberId, memberRole, roleData?` (`memberId` = M; `roleData` = `PRDOwnerInv{linkRcvId}`) | M's support scope (`DJSMemberSupport` of `memberId`) | +| `x.grp.promote.acpt` | M→O1 | `invitationId, memberKey, roleData?` (none for owner) | M's support scope (the sender's) | +| `x.grp.promote.reject` | M→O1 | `invitationId` | M's support scope (the sender's) | +| `x.grp.promote.cancel` | O1→M | `invitationId, memberId` | M's support scope (`DJSMemberSupport` of `memberId`) | +| `x.grp.owner.add` | owner→all | `memberId, ownerAuth, profile` (+ the link-data hash fields below) | `DJSGroup` — all members and relays | +| `x.grp.relay.count` | relay→owners | `count, ts` (the relay's own clock) | sent directly to owners, as the relay sends the join announcement (`introduceInChannel`, `Internal.hs:1320-1326`) — not a `DJSGroup` job, which would fan out to every member | +| `x.grp.owner.sync` | owner→relay | — | sent directly to each relay by a promoted owner that completed from link data (Part 6); the relay answers a current owner with its member catch-up and count reading, below | +| `x.grp.roster.dropped` | relay→owner | `sharedMsgId, version, proof` (`proof` = the owner-signed role or removal event, or roster header, that set the relay's gate to `version`) | sent directly to the owner whose role or removal event the relay's gate dropped; never forwarded, and ignored when it arrives as a forward (Part 6) | +| `x.grp.owner.departed` | relay→owner | `memberId` (the departed owner) | sent directly to every current owner when the relay applies that owner's departure, after its kept copies of that owner's events; never forwarded (Part 6) | + +`memberKey` in `acpt` is M's existing key; O1 checks it **equals the key it already holds for M** and adds the key it holds (a consistency check, distinct from the advisory OOB verification in Part 7). Support-scope delivery also reaches moderators, admins and owners (`Subscriber.hs:4424-4438`), so every recipient other than `memberId` ignores `inv` and `cancel`; M accepts them only signed by a current owner, and `cancel` only from the `inv`'s signer; O1 matches `acpt` and `reject` only from M's own member row. + +**`x.grp.owner.add` is the promotion commit.** A member or relay applies it only if its sender (the message signer) is a current owner, `memberId` is not in `group_revoked_owners` and its member row (if any) is not removed or left, `ownerAuth` validates — signed by the stored root key (`root_pub_key`) or by a current, non-revoked owner's key — and `ownerAuth.ownerId` is `memberId`; it then sets the member's `member_pub_key` to `ownerAuth.ownerKey` (logging a warning when a relay-asserted key differs), creates the member if unknown with `profile` (or updates a placeholder profile with it — relays introduce owners only to later joiners, `Internal.hs:1320-1332`), and sets its role to owner. M, receiving it for itself, completes its promotion only as Part 6 describes. It is a new event rather than `x.grp.mem.role … GROwner`, which would apply the role to a relay-asserted key (`Subscriber.hs:3465-3468`) and never create an unknown owner (`:3458`); clients that predate it ignore it and keep rejecting the new owner's admin messages (Part 8). The sender check stops a removed owner reinstating itself with its own still-valid root-signed entry; the removal record stops a relay re-delivering an old add, and a removal that arrived before the add it revokes (Part 2). `group_revoked_owners` survives member cleanup (Part 2), and a removed memberId is never promoted again (Part 5), so no version gate is needed — which would also drop adds that relays merely reordered. Whenever a relay starts treating a memberId as an owner — applying `x.grp.owner.add`, applying the chain in its periodic link check, or accepting that owner's join — and when a current owner sends it `x.grp.owner.sync`, it sends that owner `XGrpMemNew` for each of its current members, batched (relays announce joiners only to the owners and moderators of that moment, `Internal.hs:1323-1326`, so a promoted owner would otherwise hold no row or key for earlier subscribers; the keys are relay-asserted, as the creator's are today), and then its current `x.grp.relay.count` reading, which M also takes as that relay's confirmation that it treats M as an owner (overview §6). A device stores `x.grp.relay.count` from a relay member whatever its own role, since the reading can arrive before the forwarded commit. + +`x.grp.mem.role` and `x.grp.mem.del` gain an optional `retryOf :: {version :: VersionRoster, author :: MemberId}`, the key of the change a re-send carries (absent on an original, whose key is its own version and signer, Part 6), and an optional `prevKey` of the same shape, the key the change replaced on its author's device — the sender's own previous key on an original, the original change's stored `prevKey` on a retry (used only to decide whether to surface an override); members and relays apply a retry through the gate like any change, and owners order it by that key. A relay whose gate drops an owner's role or removal event sends it unchanged to its other current owners (directly, as it sends `x.grp.relay.count`, the events of one received message batched in one message, `encodeBinaryBatch`, `Internal.hs:2899-2903`), so every owner hears every owner's roster change, and sends the sending owner `x.grp.roster.dropped` with its gate and that gate's signed proof (table above; Part 6). `XGrpRosterRequest` from an owner asks a relay for catch-up — its kept latest role and removal events above the requested version — instead of the blob a member's request gets; from a relay, carrying the relay's gate and, as a new optional field, the same signed proof, it asks an owner to re-send its blob (Part 6). + +Additive: `linkDataVersion`, `linkDataBaseHash` and `linkDataHash` become optional fields on the events that announce link-data writes — `XGrpInfo`, `XGrpRelayNew`, `x.grp.owner.add`, and `x.grp.mem.del` of an owner — present only when the event describes its write's entire change (a write that activates several relays at once carries none). `x.grp.mem.del` of an owner and an owner's `x.grp.leave` also carry, as optional `revokedVersion`, the `rosterVersion` of the `revokedOwners` entry their write published; a receiver stores it as `revoked_version`, with `revoked_leave` set for a leave (Part 2), so it can republish the entry and bound trigger (7) without reading the link data. Order: write first (`LSETH`), then broadcast. A receiving owner adopts `linkDataHash` into `link_data_hash` only when `linkDataBaseHash` equals the value it holds (overview §2); otherwise it applies the content and keeps its hash. Every recipient ignores an `XGrpInfo` whose `linkDataVersion` is below its `link_data_version` — the profile is a whole value, so an older one arriving late through another relay would revert a newer one (`xGrpInfo` applies any differing profile, `Subscriber.hs:3848-3849`); the other announcing events compose (relay and owner additions, removals) and are not version-gated. + +The chat version these events and the relay behaviour gate on is `multiOwnerVersion = VersionChat 23`, with `currentChatVersion` raised from 22 (`Protocol.hs:96-97`). + +The roster tie-break needs **no wire field** — the author is the message signer (`withAuthor`), already available at the completion gate; store its cross-device `memberId` in `stored_roster_owner_id` at completion and compare there. + +The new owner events (`x.grp.promote.*`, `x.grp.owner.add`) are added to the relay forwarding set (`isForwardedGroupMsg`, `Protocol.hs:528`), the relay's dispatch (`processEvent`) and, since members and owners receive them as relay forwards, `processForwardedMsg` under `withAuthor` (`Subscriber.hs:4041-4064`, whose catch-all rejects unknown events) — or relays and members drop them. The direct-only events — `x.grp.relay.count`, `x.grp.owner.sync`, `x.grp.roster.dropped`, `x.grp.owner.departed` — are not forwarded and are ignored when they arrive as a forward. + +--- + +## Part 5 — commands (`ChatCommand`, `Controller.hs`) + +- **`APIAcceptRolePromotion GroupId`** — M marks accepted, then sends `acpt` (a crash between them leaves an accepted record, which the chain may still commit); the async chain (overview §6) then runs on O1. M holds at most one pending promotion: an `inv` received while one is pending is answered with `reject` automatically. Before acceptance M's record clears on `cancel`, when the inviter is revoked, and on its own TTL. After acceptance it holds `promote_link_rcv_id`, which completing needs (Part 6), so only `cancel` clears it; when the inviter is revoked, M settles it from the link data on its next refresh — an entry for M completes the promotion, none clears the record. +- **`APIRejectRolePromotion GroupId`** — sends `reject`, clears. +- **`APICancelRolePromotion GroupId GroupMemberId`** — O1 sends `cancel`. If the chain already added M's key (`promote_status` `key_added`), it drops the key with `RKEYH`; from `entry_published` on — set before the chain's `LSETH` is issued, so the entry may be live or still queued in the agent — it revokes M instead (the removal chain below, revocation first), which also removes M from the channel — a revocation is applied as a removal everywhere (Part 6), and the Cancel confirmation says so from that step on; then clears the proposal. The same undo runs when the chain stops because M left or was removed (below). The inviter's record expires only while `invited` (its expiry sends `cancel` to M), and M's only before it accepts — after acceptance the chain runs to completion or to an explicit cancel. Both TTLs are fixed in `ChatConfig` and checked at chat start (with the re-drive of pending records, under the group lock) and by a per-user step in the periodic cleanup manager (`cleanupUser`, `Commands.hs:5922-5933`) — not by the relay-request pattern, whose TTL is checked only on a temporary error inside its own worker (`retryTmpError`, `Subscriber.hs:4554-4559`), which an invitation waiting on a person never reaches. +- Route **`APIMembersRole … GROwner`** on a channel into the invite flow — single-target (reject a multi-member owner promotion, as the existing guards do: channel owner-role guard `Commands.hs:2994-2995`, multi-member admin guard `:2990-2991`). Before sending `inv` the inviter checks that the link queue's server session supports v24 (Part 1.2). Before any key change the chain refuses a memberId in `revokedOwners` or in `group_revoked_owners` (its key would be added but its entry never written, since the merge excludes revoked memberIds) and checks that the link data still fits with M's entry added (user data is padded to 13,784 bytes, `ShortLink.hs:46`), refusing the promotion with a clear error otherwise. Before each step it re-checks that M is still a current member without a `group_revoked_owners` row and that the inviter is not revoked, and undoes otherwise (above); each chain `LSETH` attempt re-checks the fit and the inviter's revocation on the blob it replaces, and on a misfit undoes by dropping M's key. M's entry is added to the local chain only by that `LSETH`, so no unrelated write (a count publish, a relay ack) publishes it early. The creator's first promotion then rotates its link-queue key to its member key (Part 1.2). +- **`APIMembersRole`** whose target is a current owner, to any other role, is refused in channels — removal is the only way to drop an owner (today only `newRole == GROwner` is blocked, `Commands.hs:2995`); receivers likewise ignore `x.grp.mem.role` whose target is a current owner or a memberId in `group_revoked_owners`. +- Route owner **removal** (`APIRemoveMembers` of an owner) into the removal flow (overview §7): an async command that first writes its `group_revoked_owners` row with `removal_step` `revoking`, then writes the revocation (`LSETH` adding the target to `revokedOwners`, with the remover's roster gate as its `rosterVersion`), **then** drops the key (`RKEYH`; the target's key is its chain entry's `ownerKey`), **then** sends `x.grp.mem.del`. Each `RKEYH`/`LSETH` payload is built by applying the delta to the server's *current* set/blob (the believed one, or the one returned on a conflict) — never rebuilt from the chain, which could drop a concurrently-added owner's key whose chain entry is not yet published. Refuse removing/leaving the last *live* owner, checked on the blob each revocation `LSETH` attempt replaces — the believed one, then the one each conflict returns: the actor must not be in its `revokedOwners`, and an owner outside `revokedOwners` must remain after the change; otherwise stop before any key change and surface it (overview §3). A crash between the steps is resumed from `removal_step` at chat start; another owner's reconcile heals the key drop if this device never comes back (Part 6). +- **Leave** (`APILeaveGroup`, `Commands.hs:3233-3253`) by an owner while another live owner remains: the same record and precondition; roster changes are refused once its leave record exists, but re-sends it has pending still go out (they carry earlier changes); then, once its earlier messages to every current relay are on the relay's queue server (their `msg_deliveries` marked by `SENT`, `sentMsgDeliveryEvent`, `Subscriber.hs:1189-1192`, `:1835-1837` — accepted by the server, not yet processed by the relay; and no `pending_group_messages` remain for any current relay — in channels an owner's message to a relay whose connection is inactive (`QUOTA`, `Subscriber.hs:1749-1762`) is stored as pending, as in other groups (`MSAPending`, `Internal.hs:2851`), and sent when the connection continues (`Subscriber.hs:1197-1199`); today `memberSendAction` drops it (`Internal.hs:2840-2846`, `readyMemberConn` `:2877-2885`), so a role change made while the relay was backlogged never reached it; within a bound, as a relay's queue server may be down), revoke itself (`LSETH`, an entry with its own roster gate as `rosterVersion` and `leave` set, keeping its own chain entry — a non-creator's write is validated with its own key, which must still be a chain key, pinned `Agent/Protocol.hs:1996-2001`; `revokedOwners` marks it revoked, and the next other owner's write drops it), drop its own key (`RKEYH`), then `x.grp.leave`; after its own key is dropped, an `AUTH` error on that key counts as done, so a resumed leave goes on to the announcement. The creator then forgets its link connection without `DEL` (Part 1.2) instead of `deleteGroupLinkIfExists` (`Internal.hs:1739-1748`), which would `DEL` the shared queue. The same applies where an owner processes its own removal (`Subscriber.hs:3736`; a relay processing its own removal there keeps deleting its own relay link) and where an owner that is no longer current deletes the chat (`Commands.hs:1400`). Destroying the channel still deletes the queue: the creator through its link connection as today, and a non-creator owner, which has none, with `deleteForeignLinkQueue` (Part 1.2) — only in the branch where a current, non-revoked owner sends `x.grp.del` (`doSendDel`, `APIDeleteChat`, `Commands.hs:1394-1400`). `deleteGroupLinkIfExists` at `:1400` runs regardless of `doSendDel`, so the foreign `DEL` must not join it there: a removed owner whose key a crash left live would otherwise destroy the channel by deleting its chat. +- **`APICreateGroupLink`, `APIAddGroupShortLink` and `APIDeleteGroupLink`** are refused for channels (`Commands.hs:3349-3384`): a channel's link is created with the channel, the apps would otherwise create a direct group link for a promoted owner that opens the link screen (`GroupLinkView.swift:164-166`), and `APIDeleteGroupLink` sends `DEL` on the shared queue (`deleteGroupLink'`, `Internal.hs:1733-1737`) — destroying the channel goes through `APIDeleteChat`. + +The promotion, removal and leave chains are async commands resumable from their durable records (the `promote_*` columns / `group_revoked_owners.removal_step`) — no new durable-worker type. + +--- + +## Part 6 — key logic changes (anchors) + +- **One writer for every link write.** Each link-write site resolves a `GroupLinkWriter` once, from the keys (Part 3): the creator's own link connection and group link, or a promoted owner's `ForeignLinkKeys`. The `GroupLink` that `CRGroupRelaysAdded` and `CEvtGroupLinkDataUpdated` carry (`Controller.hs:862`, `:1035`) becomes optional, absent only for a promoted owner, and both apps decode it as optional — an older remote-desktop app fails to decode only those responses, which exist only for a promoted owner. Today every site needs the creator's group link row or link connection — `setGroupLinkData'` silently skips the write without one (`Internal.hs:1581-1586`, used by the profile edit at `Commands.hs:4190`), and `setGroupLinkData`/`setGroupLinkDataAsync` (`:1588-1604`), `updatePublicGroupData` (`:1624`), relay add (`Commands.hs:2790`) and relay connect/ack (`Subscriber.hs:946-947`, `:3663-3665`) throw. Foreign writes are synchronous (Part 1.2); a caller that is asynchronous today runs them in a thread, and its intent is already durable (relay rows, promotion records, `group_revoked_owners.removal_step`), so reconcile at chat start re-drives an interrupted one. The body of the `LINK` handler (`Subscriber.hs:1431-1481`: relay activation to `RSActive`, `XGrpRelayNew` to other relays, `CEvtGroupLinkDataUpdated`) becomes a function of the written data and the resolved writer — not a re-read of the group link (`Subscriber.hs:1439`), which a promoted owner has no row for — called from the `LINK` event and after a foreign write returns `OK`, which produces no `LINK` event. +- **`groupLinkData`** (`Internal.hs:1670`, self-only owners at `:1676-1682`) → build the write from local state via the **reconcile** routine; delete the `GRKPrivate` singleton / `[]` fallback; read the stored chain (insertion order). The creator's own entry, computed on every write today (`Internal.hs:1676-1682`), is stored on its membership row at channel creation and filled on first start after the migration (it holds the root key); a write whose chain lacks an entry for a current, non-revoked owner is refused. The relay list is `published_relays` (read from the link data first when it is still `NULL`) plus this owner's in-progress additions minus every relay whose member row on this device is removed or left (Part 2 keeps removed relays' rows), and a write whose relay list is empty is refused unless this owner is removing the last relay — not only its own `group_relays` rows (`getPublishableGroupRelays`, `Store/Groups.hs:1528-1545`), which reference its own chat relays (`chat_schema.sql:840`) and are empty for a promoted owner. The count fields are the published `(publicMemberCount, subscriberCountTs)`, unchanged, except in the gated count publish. +- **Reconcile** = merge published link data into local, by field kind: owners and revoked owners merge as an idempotent union keyed by memberId, minus revoked entries no current entry depends on (dropped, below), and a published entry fills `owner_auth_sig`/`owner_auth_index` on a local owner row that lacks them (rows created before the migration store only the key); relays: `published_relays` is replaced by the published list, and this owner's in-progress relay rows (being added and not yet published) and its removed or departed relays' member rows are kept; profile/prefs adopt the published value. A chain owner for which this device holds a `group_revoked_owners` row — a removal that raced that member's promotion — is revoked by this owner through the removal flow (Part 5): the removal wins. Subscribers' link-data refresh (`updateGroupFromLinkData`, `Internal.hs:1634-1654`, today profile and count only; called only from `Commands.hs:1966`, `:4624`, `:4651`) also applies the chain: validated entries as owners (keys from `ownerKey`), `revokedOwners` into `group_revoked_owners`. A revocation this device has not yet applied is applied as `x.grp.mem.del` of that owner would be (`xGrpMemDel`, `Subscriber.hs:3747-3781`): a relay drops the owner's connection, a member marks it removed; for this device's own memberId, it is this device's own removal (`Subscriber.hs:3734-3746`, forgetting the link connection without `DEL`, Part 5). Authority checks are role-based, so without this an owner whose removal was never announced would keep its powers; `x.grp.del` is also accepted only from a current, non-revoked owner (today a role check, `Subscriber.hs:3828-3831`). Relays never call it; a relay applies the chain the same way in its periodic check, which already fetches the link data every 15 minutes (`checkRelayServedGroups`, `Commands.hs:6001-6020`), and at once when an owner join or `x.grp.owner.add` names a memberId it does not hold as owner — relays don't forward to each other, so an owner event from an owner not connected to a relay never reaches it otherwise. Run it **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — remove the owner exclusion at `Commands.hs:1968` and the apps' owner branch, `ChatView.swift:764-779` and its Kotlin counterpart), **at chat start** for each channel the user owns (owners that never open the channel view — CLI, bots — still heal and publish the count), **on a CAS conflict** (adopt the returned blob, rebuild), and as the **missed-event backstop**. +- **Owner relay step** in reconcile, instead of `syncSubscriberRelays` (`Commands.hs:3992-4011`), which would delete this owner's relays that are not yet published and join other owners' relays as a subscriber: deactivate relays absent from the published data that this owner is not adding, and connect to relays other owners added by joining through the relay's link as itself, signed with its member key. +- **Relay accepts an owner's join.** In `memberJoinRequestViaRelay` (`Subscriber.hs:1690-1720`), an existing member is accepted onto its row only for roster roles (`:1702-1706`); any other existing member falls through to a fresh insert, which violates `UNIQUE(group_id, member_id)`. Add the owner case: an existing, non-revoked owner row plus a join signed with that owner's key, under the same `viaRelay` replay check as the roster branch (`verifyKey`, `:1711-1714`), is accepted via `acceptJoin` with the existing row and role owner; a memberId in `group_revoked_owners`, or whose row is removed or left, is refused — in this branch and in the roster-role one, which today checks the key but not the row's status (`:1702-1705`). Without it no owner can connect to a relay another owner added, so its posts never reach that relay's subscribers. On the owner's side, completing the join (the relay answers with `XGrpLinkInv`, handled for a `GCHostMember` at `Subscriber.hs:830-842`) updates only the relay member: `updatePreparedUserAndHostMembers'` (`Store/Groups.hs:821-844`) would replace the owner's group profile with the relay's copy and, unless the membership is `GSMemConnected` (the creator's is `GSMemCreator`), rewrite its membership role and status. Its `CON` (`Subscriber.hs:892-922`, a subscriber-side relay row is a `GCHostMember`, `Store/Groups.hs:1817`) likewise updates only the relay member: it would set the owner's membership to `GSMemConnected` and create the joined-channel items and event. +- **Profile edit** (`runUpdateGroupProfile`, `Commands.hs:4164-4204`) becomes write-then-commit: today it commits the profile locally (`:4172-4174`) before the synchronous link write (`:4190`). Keep the starting profile in memory; on a conflict, merge field by field against it — a field only this owner changed keeps its value, a field only the other owner changed takes theirs, a field both changed differently keeps the published value and is surfaced as "profile edit superseded" — then retry. +- **Removal heal** (runs during reconcile, and it is what makes the revoke-first ordering crash-safe): for each `group_revoked_owners` row with `key_drop_enforced` false and a known `member_key`, issue an `RKEYH` whose expected set is the believed set plus that key and whose new set is the believed set; on a conflict, drop the key from the returned set and retry while it is still there; then set `key_drop_enforced`. Merging `revokedOwners` into local state alone does *not* evict a still-live key; this explicit `RKEYH` is required. An owner that receives the removal or leave event sets `key_drop_enforced` directly — the event is sent only after the key drop — so only a revocation first seen in the link data (the crash case) costs a direct connection to the link server. A removal this device runs and that crashed is resumed from its `removal_step` at chat start, through the announcement. +- **Relay activation** stays on the `LINK` event (and, for a foreign write, its `OK`, through the extracted function above), which the agent emits only after the server accepted the write (`Agent.hs:2255-2258`); under CAS it follows the accepted `LSETH` and carries the data written after any re-merge. Relays another owner removed produce no `LINK` here (the TODO at `Subscriber.hs:1475-1477`); the owner relay step deactivates them. +- **Roster** (overview §4): the event gate stays **version-only, author-blind** (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`), so concurrent *different-member* deltas both apply. The `(version, authorMemberId)` tie-break is added **only at the completion gate** (`Subscriber.hs:3572-3578`): keep the existing downgrade reject (`pendingVer < roster_version` gate) and, at the equal-version case that today overwrites by arrival order, accept iff `pendingVer > stored_roster_version` **or** (`pendingVer == stored_roster_version` **and** (`stored_roster_owner_id` is no longer a current owner **or** `author > storedAuthor`)) — comparing the incoming blob's signer against `stored_roster_owner_id`, never the gate. Store the cross-device author with the blob in `setGroupLiveRoster` (`:3578`; `Store/Groups.hs:1716-1724`). A completed blob reverts privileged members absent from it (`processRosterEntries`, `Subscriber.hs:3610-3619`), so the losing owner's delta is reverted on relays until its retry. Applying any roster skips members who are currently owners (in `processRosterEntries`, `Subscriber.hs:3610-3630`, which today sets each listed member to its listed role, `:3601-3608`, `:3622-3628`): a member promoted to owner from a roster role stays in relays' stored blob, which a later joiner receives right after its owner intros (`Internal.hs:1330-1331`), and would otherwise be set back. **Relay setup under concurrent roster changes:** a roster ack below the owner's current version during relay setup re-sends the current roster to that relay (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`) instead of only warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`) — another owner's delta can advance the version between send and ack, and this owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would stay unpublished. **Owner-side ordering** (an owner's own blob goes to relays only, `broadcastRoster`, `Internal.hs:2649-2654`; a relay forwards nothing its gate drops, `Subscriber.hs:3423-3425`, `:1133-1144`, and can reorder one owner's events, `Store/Delivery.hs:178-202`): on an owner, a role or removal event — original, retry, passed on by a relay whose gate dropped it (Part 4), or a relay's catch-up (below) — carries a key: made-at version (`retryOf`'s, or the event's own) and author (`retryOf`'s, or the signer). The owner applies it, instead of through `applyAtRosterVersion`'s gate (`Subscriber.hs:3404-3446`), exactly when its key beats the member's stored key (`roster_key_version`/`roster_key_author`, or the `group_revoked_owners` row for a removed member): a removal beats a role change; an empty key loses only to an event whose wire version is above `roster_key_floor`; otherwise the higher made-at version, then the higher author. It then stores the key with the event's `prevKey` (a retry carries the original change's), raises its gate to the event's version if higher, clears `roster_key_resent` and `roster_key_reported`, and, when the beaten key was its own and differs from that `prevKey` (the replacing change was made without seeing it), surfaces "your role change to X was overridden". Re-delivered events are dropped by message id first (`saveGroupFwdRcvMsg`, `Internal.hs:2956-2959`), and an older event never beats the key that superseded it. **Re-send triggers**, coalesced — after the first, the owner waits a short quiet period with no further roster event, within a bound, since SMP delivers one message per queue per acknowledgement (pinned `Server.hs:2091-2092`) and a bulk change arrives over several messages — and, for triggers (1)–(3) and (6), skipping members whose current key it has already re-sent (`roster_key_resent`), so a bulk change costs one re-send per member however a relay splits or spaces it — safe because a relay forwards every group-scope channel event in the order it received it (Departures, below), so a later re-send never overtakes an earlier one; triggers (4), (5) and (7) report a value a relay or its members hold wrongly and always re-send it: (1) it applied another owner's event whose wire version is below its own gate — relays and members with a gate as high dropped it; (2) it applied another owner's event whose wire version is at or below `roster_blob_sent_version` — its own blob at that version may have won the relays' equal-version tie-break without the change (`Subscriber.hs:3573-3578`) and reverted it there (`:3614-3618`); (3) an event lost to a member's non-empty key with a different value, its wire version is at or above that key's made-at version, and the member has no `group_revoked_owners` row — devices may have applied the loser, while a replayed change below the change that superseded it triggers nothing, and a removed member needs no repair since every device ignores its role events; (4) a relay's `x.grp.roster.dropped`, received directly from a current relay member (never as a forward) and acted on at most once per relay per interval (later ones deferred, not discarded), names this owner's change at a version above the owner's gate whose `proof` is an owner-signed event or roster header at that version, verified against an owner key the owner holds — a departed owner's (its `group_revoked_owners.member_key`) only within the departed-owner margin above the owner's gate, 2^20 versions (`ChatConfig`) — each role-change call adds one version, so no remaining owner falls that far behind a departed one, while rate-limited notices one margin at a time never bring a gate near the `Int64` limit — so a relay cannot inflate the `Int64` gate (`Types.hs:2282`; changes are made at gate+1, `Internal.hs:2644`) and freeze every role change; the owner raises its gate to that version and re-sends that change; (5) a relay passes back a kept copy (below) whose key equals this owner's key for that member, for a member with no `group_revoked_owners` row — the relay lost a change this owner holds (no honest relay passes back a removed member's copy, as applying a roster skips such members, and a removed member's row may be cleaned up, leaving nowhere to record the re-send); this owner may hold that very message, so a pass-back is compared by key before the message-id dedup (`Internal.hs:2956-2959`); it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), and re-sends that member only — a relay keeps a genuine owner-signed copy for every member, so a replayed pass-back must not re-send the roster; (6) it applied another owner's departure (Departed owners' changes, below) — adoption: every member whose key that owner authored, since it can no longer re-send its own changes and devices that learned of its departure before receiving them dropped them; when the skip leaves no member, the blob alone goes out at the version this owner holds; (7) a relay's kept copy of a departed owner's role event, received after this owner applied that departure (in the relay's departure report, passed back, or served as catch-up), verified against `group_revoked_owners.member_key` and made at or below that owner's `revokedOwners` `rosterVersion` plus the departed-owner margin of trigger (4), whose key differs from this owner's key for that member, for a member with no `group_revoked_owners` row — undo: some devices applied a role change of the departed owner that this owner does not hold; it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), re-sends that member only, and the copy's wire version never counts toward the highest version seen; if another remaining owner holds the change, that owner receives the re-send as a losing different value, re-sends the change under trigger (3), and this owner adopts it by key. The owner then re-sends, as deltas with `retryOf` = each member's stored key and `prevKey` = that key's stored `prevKey` (a role event with the member's key for TOFU, a removal with its kept `with_messages`), the current value of the triggering events' members and of every member whose key was made at a version at or above the lowest triggering made-at version, with its blob, at one above the highest version seen, records that version in `roster_blob_sent_version` and sets `roster_key_resent` on the members it re-sent, and the bit of `roster_key_reported` for (5) or (7) on the members those triggers named. An event applied above both the gate and the last blob it sent, losing nothing, triggers nothing, so a change made after its owner saw the others never causes a re-send, and the exchange ends once every owner holds every change. Existing members converge from the re-sent deltas (`DJSGroup` → all members). **No blobs on owners:** an owner sends no `XGrpRosterRequest` on a gap — a change: today `requestRosterOnGap` (`Subscriber.hs:3438-3446`) skips only relays — and a roster blob served to an owner is not applied (it would revert members absent from it, `:3610-3619`, over the owner's keys). **Catch-up:** a relay keeps in `group_roster_latest_events` a copy of each owner's role or removal event (with a roster version) it applies, replacing that member's previous copy; at chat start an owner sends each relay `XGrpRosterRequest` carrying its `group_roster_catchup` version, and the relay answers an owner's request with the copies above it, forwarded as signed (then its departure notices, below); the owner applies them by key and records the highest version served. While an owner's connection on the relay is inactive (its queue at quota, `Subscriber.hs:1749-1762`) the relay drops every forward and direct send to it (`Subscriber.hs:4387`, `:4407`), that answer included; so when the connection resumes (`QCONT` and a resumed `SENT`, `Subscriber.hs:1291-1295`, `:1197-1199`, where a relay today calls `serveRoster` for the member ahead of the resumed backlog), a relay sends a current owner its catch-up instead — the copies above the version it last served that owner, then its departure notices. Both branches send to the member with the connection `continueSending` just reactivated (`m {activeConn = Just conn {quotaErrCounter = 0}}`): `continueSending` resets the counter only in the database (`Subscriber.hs:1765-1771`), so the event's `m` still holds the inactive connection that `readyMemberConn` rejects (`Internal.hs:2881`, `:2899-2901`) — today's blob serve there never leaves the relay — and a version counts as served to a member or owner only when `readyMemberConn` handed the send to the agent (today `serveRoster` records it after any attempt, `Internal.hs:1307-1313`, which also makes `xGrpRosterRequest` skip that member, `Subscriber.hs:3685`). An owner's own change that a relay dropped comes back as trigger (4), not through catch-up. When a blob that completes on a relay reverts a member (`:3614-3618`) whose kept copy is by another author than the blob's, the relay passes that copy, with its `broker_ts`, to every current owner: the blob's author applies it by key and re-sends under trigger (1) or (2), and an owner already holding that key re-sends it under trigger (5). **Departed owners' changes** (overview §4): every device ignores content from a member that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner keep changing roles through a single relay — and members and relays keep doing so; owners carry a leaver's in-flight changes instead. *Hold (leave only):* an owner that applies from link data (Reconcile, above) a `revokedOwners` entry with `leave` set, before processing that owner's `x.grp.leave`, stores as `roster_bound` the entry's `rosterVersion`, capped at its own gate plus the departed-owner margin of trigger (4) — the flag and the version are asserted by whoever wrote the entry, which during a removal's healable window can be the removed owner — and starts a hold with `hold_running_left` set to a fixed period of counted time (`ChatConfig`, 2 hours: above draining a full queue plus a relay's resume, about an hour, and short because it also bounds a forged leave, below). During the hold, an `x.grp.mem.role`, or an `x.grp.mem.del` of a member, by that owner — forwarded, passed on, in a relay's departure report, passed back or served as catch-up — that carries a roster version, has no `retryOf`, was made at or below `roster_bound`, and names neither a current owner nor this device's membership, is verified against `group_revoked_owners.member_key` before the author lookup (`xGrpMsgForward`, `Subscriber.hs:4009-4027`, which would recreate an author that member cleanup deleted as keyless, `Store/Groups.hs:1230-1240`) and applied by key with owner authority taken from that removal record, without looking up or recreating the author's row; its forwarded `x.grp.leave` is verified the same way, only to end the hold; anything else from that author stays ignored. The hold ends at the first of: that `x.grp.leave`; an owner's `x.grp.mem.del` of that owner (a removal contradicts the leave flag); an `x.grp.owner.departed` for that owner from every relay that was current and connected to this owner when the hold started and has not since been removed or left (`group_departure_notices`, recorded whenever a notice arrives, also before a hold starts); `hold_running_left` reaching zero, decremented by the periodic cleanup (`cleanupUser`, `Commands.hs:5922-5933`), and also on suspending (`APISuspendChat`, `Commands.hs:611-612`, on the transition from activated to suspended only) and on stopping (after `stopChatController`, `Commands.hs:363`, returns, as it cancels the cleanup loop at `:369`; both `APIStopChat`, `:596`, and the CLI's exit, `Core.hs:111`, call it), only while the chat is activated — a tick re-reads `chatActivated` at its decrement — by the time elapsed since the latest of the hold's start, the previous decrement, and the chat's last start or activation in this run, capped at one cleanup interval plus one pass of the loop (`cleanupManagerInterval` + (5 × users + 1) × `cleanupManagerStepDelay`, `Chat.hs:125-126`: five step delays per user, `Commands.hs:5922-5933`, and one as a margin for the steps' own running time, which a slower pass exceeds only to lengthen a hold; the decrement adds no step delay; the interval is in seconds and the step delay in microseconds, `Controller.hs:175-176`, so they are converted before adding, or the cap never binds), and only when this device received a message from at least one of the channel's current relays in that time — a decrement that fails this condition still moves the base forward, counting zero. A hold's decrements are serialized under one lock that also guards the in-memory base, which a decrement advances before writing `hold_running_left` (a write lost to a stop's cancellation then only lengthens a hold), the activation time is recorded in the same write that sets `chatActivated` (`Commands.hs:602`) so a tick that sees the chat activated also sees its new base, and a decrement error is caught, as the cleanup steps catch theirs. The base leaves out iOS suspension, when no tick runs, and counts the stretch after a session's last tick at its suspension; the cap bounds OS sleep to about one capped interval per stretch on desktop and Android, which never suspend the chat — a running process decrements at least once per interval plus one pass, so a longer gap is sleep or a frozen process. In Android's periodic mode, which runs the chat for up to a minute at most every 10 minutes without stopping it (`MessagesFetcherWorker.kt`), a tick counts the frozen time before it, up to the cap, and each run receives what the relays have queued, up to a minute's worth. A crash loses at most the last stretch, which only lengthens a hold. The relay condition is needed because the cleanup loop runs whenever the app is active (`Commands.hs:5895-5914`), with or without a network, and the device's own subscriptions stay up while a relay is down; with it, time when the device cannot hear from the relays counts at most about one capped interval at each end of such a stretch. A relay forwards every group-scope channel event in the order it received it (Departures, below), so each of the leaver's changes reaches the owner, through that relay, before its `x.grp.leave`; a relay's departure report is a direct send and may arrive before that relay's forwards, but it carries the same changes as kept copies, ahead of its `x.grp.owner.departed`. An honest leaver's bound is its own gate (roster changes are refused once its leave record exists, Part 5), so a leaver colluding with a relay can, during the hold, make only a change it could have made before leaving — never a retry, never above the capped bound, never on an owner. A removal starts no hold: the remover cannot vouch for the removed owner's last changes. A removed owner that rewrites its own entry as a leave before its key is dropped can, with a relay, have changes to non-owner members held by an owner that reads that entry first, and adopted when the hold ends — until its remover's announcement, or, if the remover never announces, until its own `x.grp.leave` or the hold period; a holder cannot tell it from a real leave, both being written with the leaver's key (overview §8). *Adoption and undo:* applying the departure — the end of the hold; the departure event, for an owner that learned of the departure from it; or, for the remover, its `removal_step` reaching `announced` — is trigger (6); from then on a copy of that owner's event is not applied: a differing role event is trigger (7), and a removal of a member that this owner has not removed is shown to it ("X was removed by on some devices") to finish with its own removal if it agrees — a removal record is permanent (Part 2) and a removed owner with a relay could fake one, so it is neither undone nor adopted. Every device then ends with one role per member: the departed owner's when some remaining owner holds it, else the remaining owners'. Absent everywhere: a change every relay lost — a relay that applies the revocation from link data (its periodic check, or the immediate read when an owner join or `x.grp.owner.add` names an owner it does not hold, Reconcile above) before processing a change already in its queue drops the owner's connection with it — a role change racing a removal that no remaining owner holds, and a leaver's changes still pending on its device for relays that stayed at quota past its wait (Part 5), never sent once its connections are deleted (`Commands.hs:3269`) and removed by the message cleanup (`Commands.hs:5967-5970`); a removal racing a removal is shown to the remaining owners as above. **Departures:** when an owner's leave or removal is applied, each remaining owner re-sends the members whose key that owner authored, with its blob (trigger (6)); when that owner authored no member's key, it re-sends its blob (no deltas) to relays at the version it holds — not a new version, which members would read as a gap (`Commands.hs:3015-3017`) — and the completion gate, at an equal version, accepts a blob whose stored predecessor's author is no longer a current owner; relays serve the stored blob under its author (`serveRoster`, `Internal.hs:1295-1315`), which a later joiner can't verify once that author has gone. A relay that applies the departure of its stored blob's author sends `XGrpRosterRequest` with its gate (the higher of its gate and stored version) and that gate's signed proof to each connected current owner, and an owner receiving it directly from a relay, with a proof verified as for trigger (4), raises its gate to that version if higher and re-sends its blob to that relay at its gate (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`; today `xGrpRosterRequest` acts only on relays, `Subscriber.hs:3680-3685`) — at its own lower version it would be refused, and joiners would keep getting a blob they cannot verify. A relay that applies an owner's departure — the event, the revocation in its periodic link check, or the revocation in its immediate link read (Reconcile, above) — passes its kept copies of that owner's events (`group_roster_latest_events` rows it authored), with their `broker_ts`, to every current owner, batched as it passes dropped events, and then `x.grp.owner.departed` naming that owner, its report that it applied the departure (Departed owners' changes, above); its answer to an owner's catch-up request likewise ends with `x.grp.owner.departed` for each departed owner whose copies it served. Applying a roster skips members with a `group_revoked_owners` row as well as current owners, so a departed owner listed in a stored blob under its former roster role is never restored to it. **Relays forward group-scope channel events in received order**, by two changes. First, in channels every group-scope (`DJSGroup`) `DJDeliveryJob` task gets the one job scope `DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}`, normalized where the relay creates delivery tasks (`createDeliveryTasks`); `DJRelayRemoved` tasks stay distinct, as their spec drives the relay's own removal (`Subscriber.hs:4241-4252`, `:4322-4329`), and creating one first deletes the group's pending tasks and jobs (`Subscriber.hs:1167-1173`), so it is always the oldest pending task. Second, the relay's packer `batchDeliveryTasks1` (`Messages/Batch.hs:84-104`) stops at the first task that does not fit the batch instead of accepting later, smaller ones, as `batchMessages` already does (`:65-68`); today the fold goes on despite its "stop adding further messages" comment (`:94-95`), the skipped task stays `DTSNew` and heads the next job, so a smaller later event overtakes it. Today `memberEventDeliveryScope` (`Delivery.hs:115-120`), and the `XGrpInfo`, `XGrpPrefs` and unknown-target removal tasks (`Subscriber.hs:3865`, `:3870`, `:3751`), give some events `includePending = True`, and a relay's batching takes the pending tasks of the oldest task's job scope (`getNextDeliveryTasks`, `Store/Delivery.hs:178-202`), so a later event can go out ahead of an earlier one of the other scope: a departure ahead of the departed owner's earlier events, which every device then ignores (`Subscriber.hs:4019-4020`), or another owner's later change ahead of part of a bulk change, which members then drop below their gate with no gap to show it (`Subscriber.hs:3414-3425`). Channel sends ignore `includePending` (`Subscriber.hs:4338-4340`; it is read only on the non-channel path, `:4457`), and one worker's jobs go out in id order (`Store/Delivery.hs:290-303`). No leader, no relay-to-owner blob forwarding. +- **Count** (overview §5): a relay recounts (`updatePublicMemberCount`, `Store/Groups.hs:2323-2340`) and sends `x.grp.relay.count` directly to owners whenever its subscriber count changes — from the relay branch of `updatePublicGroupData` (`Internal.hs:1628-1630`), which runs only on leave and removal (`Subscriber.hs:3773`, `:3820`), and from its subscriber-connected path (`Subscriber.hs:966-972`, beside `introduceInChannel`), which today never recounts; owners store `(count, ts)` per relay, with `ts` clamped to the broker timestamp of the message that carried it; the estimate is the **max** count over current relays, carrying **that max-holder's own `ts`**. Only the creator, while it is the sole live owner and no current relay supports `x.grp.relay.count`, uses its own tally with its own clock as `ts` (no other owner publishes then; promoting a second owner requires count-capable relays, Part 8); a promoted owner left alone never does — relays without the count event never sent it the subscribers who joined before its promotion (Part 4) — and keeps the published count. During reconcile (channel open, chat start) any owner publishes `(estimate, that ts)` iff the estimate differs from the published count **and** the max-holder's `ts` is newer than `subscriberCountTs` (a `subscriberCountTs` later than the owner's clock counts as stale). Every other write carries the published count fields unchanged, and the owner branch stops recounting its tally (`Internal.hs:1623`). The owner branch of `updatePublicGroupData` (`Internal.hs:1620-1627`) stops publishing for subscriber joins, leaves and removals — call sites `Subscriber.hs:3253`, `:3268`, `:3286`, `:3773`, `:3820`, `Commands.hs:3152` — and publishes only when the publishable relay set changed (a relay member removed or left). The `XGrpRelayNew` announcement threshold (`Subscriber.hs:1445-1449`, `publicMemberCount > 1`, which assumes one owner) uses the relay-sourced estimate. Clear a relay's `(count, ts)` on `GSMemRemoved`/`GSMemLeft` (`Types.hs:1430-1431`). +- **Owner materialisation** threads the full `OwnerAuth` (sig + append index): `createLinkOwnerMember` (`Store/Groups.hs:3575`; the key is `ownerKey`, as today), `updateRelayGroupKeys` (`Store/Groups.hs:2353`; create the owner row if missing — today it throws, `:2366-2368`). Joiners and relays skip entries listed in `revokedOwners` as owners and record them in `group_revoked_owners` (Part 2). Root-sign when the adder is the creator (holds the root private key), else delegate. A revoked entry is dropped from the written chain unless it is an ancestor, through any number of delegations, of a current entry, or it is the writer's own entry in its own leave write (Part 5) (every entry must be signed by root or an earlier one, `Agent/Protocol.hs:2012`; an orphan would make every owner's agent refuse every write); `revokedOwners` keeps its memberId, so the union merge cannot restore it (overview §7: the blob is fixed-size, each entry is about 124 bytes). +- **`x.grp.owner.add`** handling, for members and relays: validate and apply as in Part 4. +- **Promoted owner commit:** M completes its promotion — sets its own role to owner and stores `link_rcv_id` from `promote_link_rcv_id`, which completes its `ForeignLinkKeys` — on receiving `x.grp.owner.add` for itself, or when a link-data refresh shows a validated, non-revoked chain entry with its memberId; in both cases only while its own `promote_status` is `accepted`, and only if the entry's `ownerKey` equals its own member key. A commit for M without an accepted record is ignored on M's device (other members still apply it) with a service item — otherwise any owner could make a subscriber's device connect directly to the link server; a key mismatch means a relay-substituted key was signed into the chain (overview §8): M does not complete and alerts. Its agent can then issue foreign `LSETH`/`RKEYH`, signing with its member key; owner actions to a relay wait for that relay's count reading (Part 4). Completing from link data, M sends `x.grp.owner.sync` to each relay, since the catch-up and readings sent on the commit may have been lost with it or expired in M's queue. +- **Departed owners' history:** a relay serves an as-channel history item whose signed author is no longer a current owner as an unsigned `FwdChannel` element (`itemForwardMsgs`, `Internal.hs:1450-1460`, `1540-1548`, attributes signed items to their author), since a joiner holds no key or owner role for that author and would drop it (`Subscriber.hs:2215-2218`). +- **Owner-only paths that assume the creator:** `APIShareChatMsgContent` signs a shared channel link for any current owner, with its member key and memberId, not only under `GRKPrivate` (`Commands.hs:1238-1240`); the connect plan's `verifyLinkOwner` and `glOwners` exclude `revokedOwners` (`Commands.hs:4615-4616`, `4648-4649`, `4806-4815`), since a revoked entry kept as a delegation ancestor would otherwise still verify and list as an owner; `XGrpRelayCap` is stored on the relay's member row (today on its `group_relays` row, `Subscriber.hs:1103-1105`, which a promoted owner has only for relays it added). +- **Creator's root key is unrevocable:** "removing the creator" evicts its link-write key and marks its owner entry revoked, but it retains root signing authority (the immutable trust anchor in `FixedLinkData`); true creator eviction would need a root-key rotation, out of scope. + +--- + +## Part 7 — apps (iOS + Kotlin, mirror both) + +- `GroupInfo`: decode **`canManageLink`** (new backend field); gate link/relay *management* on it (an owner mid-handover cannot write yet), keep relay *status display* on `isOwner`. Also decode a new backend field saying another live owner exists. +- Channel link screen: an owner without a `GroupLink` (a promoted owner) is shown the published `publicGroup.groupLink`, as non-owners are today, and the screen never creates a link for a channel — today both apps load an owner's link only through `apiGetGroupLink` and `GroupLinkView` creates one when it appears without it (`GroupChatInfoView.swift:303-312`, `GroupLinkView.swift:164-166`; `GroupChatInfoView.kt:179-181`, `GroupLinkView.kt:119-123`). Decode `groupLink` in `CRGroupRelaysAdded`/`CEvtGroupLinkDataUpdated` as optional (Part 6; `AppAPITypes.swift:1001`, `:1210`; `SimpleXAPI.kt:6815`, `:6846`). +- `GroupMember`: decode `memberRoleProposal` + `promotionPending` (optional/nullable). +- **New `RcvGroupEvent` case** for the promotion service item (both enums are exhaustive — a missing case throws) + strings. +- `canChangeRoleTo` (`ChatTypes.swift:3382`, `ChatModel.kt:2944`): add `.owner` for channels; selecting it starts the async invite and shows the proposed-role row (proposed + Cancel / rejected + re-invite). Warn if the invitee's key is unverified and route to the verification screen (`verifyChannelMemberCode`, `Commands.hs:3826`, called at `:2074`, hashes both keys sorted) — advisory, matching the existing channel model; the consequence is the channel's trust chain, not one conversation. +- Invitee: a **new** support-chat accept/reject banner (the existing pending-member bar is gated on a non-null scope member; M's own support scope has none, so this is new code reading the proposal from `membership`) + the service-item unread; accept goes through a confirmation stating that any owner can delete the channel and that this device will connect directly to the channel's link server (overview §8). +- Conflict surfaces: "profile edit superseded", "your role change to X was overridden" — plain, re-apply via normal UI. +- Chat-list leave guard: the condition `!(useRelays && isOwner)` appears in two member-status branches per file (`ChatListNavLink.swift:247`, `:272`; `ChatListNavLinkView.kt:322`, `:344`); allow leaving when the new `GroupInfo` field says another live owner exists, at both occurrences. The chat list has no member list, so the answer comes from the backend (the Kotlin `hasOtherOwner` is a local in `GroupChatInfoView.kt:829-832`; iOS inlines the check at `GroupChatInfoView.swift:250-251`). + +--- + +## Part 8 — version gating and compatibility + +- **Link server:** detect the link queue's negotiated server version (the relay-handshake `thVersion`, through the agent read in Part 1.2); **require ≥ v24 to promote a second owner** (the hash-CAS `LSETH`/`RKEYH` all need it). On an older link server, block promotion with a clear error before sending `inv` rather than degrade — a blind `RKEY` there evicts other owners (replace with no CAS). A sole live owner on an older link server keeps writing with today's blind `LSET` when the CAS call fails with `TEVersion`; with two or more live owners (the server was ≥ v24 at promotion and has since been downgraded) writes, removal and leave fail closed and are surfaced. Because the negotiated version is the min of the two peers' relay ranges, both relay constants (Part 1.1) must reach 24 before a client perceives v24. +- **Relays:** a relay invited to a channel with two or more owners throws on the owner it has no row for (`updateRelayGroupKeys`, `Store/Groups.hs:2366-2368`, called from `Subscriber.hs:4604`), so adding a relay to such a channel fails. A relay that predates multi-owner also never forwards `x.grp.owner.add` (`isForwardedGroupMsg`, `Protocol.hs:528`, unknown events → `False`), keeps the promoted owner an observer and drops its posts (`memberCanSend`, `Subscriber.hs:1737-1738`), rejects that owner's join (Part 6), and sends no `x.grp.relay.count`. So the relay side — tolerant `updateRelayGroupKeys`, `x.grp.owner.add`, the owner join, `x.grp.relay.count` — ships in a release before promotion is enabled, and at runtime promotion requires every current relay to support `multiOwnerVersion` (Part 4; owners already check a relay's version, `supportsVersion`, `Subscriber.hs:933`), else it is refused with a clear error. Adding a relay to a channel with more than one owner cannot be checked up front — a relay's version is known only once it answers — so it fails closed: an older relay throws before answering (`Subscriber.hs:4604`, its error recorded only on the relay, `:4565-4567`), and the owner times out the unanswered invitation and shows an error — checked by the same `cleanupUser` step as the promotion TTLs (Part 5), since today nothing on the owner's side expires an `RSInvited` relay. A creator that is the sole owner and whose relays predate the count event falls back to its own tally (Part 6). +- **Older members** ignore `x.grp.owner.add`: they keep rejecting the new owner's admin messages until they upgrade — failing closed rather than trusting a relay-asserted key. Its unsigned posts as the channel reach them (relays forward those as `FwdChannel`, `Store/Delivery.hs:159`); its signed ones are forwarded under its identity and dropped like its admin messages, and the profile still reaches them through link-data refresh. **Older joiners** take every chain entry as an owner, including revoked ones; a revoked owner's key is gone and relays have dropped its connection, so its messages do not reach them. + +--- + +## Part 9 — tests (scope) + +Under `describe "channels"` (`tests/ChatTests/Groups.hs`). Named profiles run out at `frank` (alice…frank); relays use `chatRelayProfile`, as existing channel tests do, so a test has up to six owners and subscribers in total plus its relays, within `it`'s 180 s timeout (`tests/ChatTests/Utils.hs:96-100`). Priorities: +- promote O2 (verified-then-accepted); a subscriber that joined before the promotion accepts O2's admin message after `x.grp.owner.add`; a member that knew O2 from a relay intro with a different key ends with the chain key; +- owner-2 adds owner-3 (three owners): the delegated entry validates in insertion order; +- the creator's first promotion rotates its link-queue key; after removing the creator, a new joiner still resolves the link; +- concurrent link writes by two owners (hash-CAS: the loser re-merges onto the returned blob, both survive); an owner that missed an announcement does not adopt the next one's hash; +- concurrent roster edits — different members (relays' served blob and every member end with both), same member (tie-break winner on every member, loser surfaces conflict), removal vs role change of one member (removal wins); O3 promotes X to admin and O1 later demotes X (no re-send, X stays demoted); each of two owners makes two changes before seeing the other's, through two relays each delayed so that it drops one owner's first change (all four reach every member, both relays and the served blob); one owner makes two or three changes against the other's one through a single relay, including where the one is a removal of a moderator (the removal reaches the relay and every member); two and three owners make one change each from one version (every relay's stored blob and member roles hold all of them); an owner demotes X while another, which promoted X earlier, promotes Y at the same version (X stays demoted on relays and joiners); a bulk change arriving below another owner's gate in one message causes one re-send; a relay replays an owner's role event superseded by a later change, and one predating a promoted owner's promotion (no role changes, nothing re-sent); a relay sends an owner `x.grp.roster.dropped` or `XGrpRosterRequest` with a huge version and no valid proof, or a departed owner's proof beyond the margin (no gate changes, nothing re-sent); with three owners, a departed owner's change one remaining owner applied and the other's blob reverted on a relay (the holder re-sends it, and the relay ends with it); an owner away past message expiry makes two changes while another owner makes one (every relay, joiner and member ends with all three); an owner whose gate is stale makes a change that every relay drops, with no other owner left (it re-sends on the relays' `x.grp.roster.dropped` and the change reaches every relay and member); an owner is removed while its own role change is in flight (each relay ends serving a blob of a current owner that joiners verify); a bulk change of N members, dropped by a relay and passed on over several messages, makes each other owner re-send each member once; the creator demotes a moderator, promotes an admin and leaves, with a pending moderator-scope delivery on the relay, and the remaining owner reconciling only after the relay has delivered (the remaining owner and every member apply both changes); the same with the remaining owner, and separately a subscriber, reconciling the link data before the relay delivers the changes (the remaining owner applies both during its hold and adopts them when the hold ends; every member, both relays and a later joiner end with both); the same with the remaining owner going offline right after reconciling, for longer than the hold period, and separately offline past message expiry (it still applies both and adopts them); the same after member cleanup has deleted the creator's row on the remaining owner (the held events verify against the removal record's key); an owner removed while its role change is in flight through one relay and dropped by the other, with two and with three owners (every member and relay ends with one value: the change if a remaining owner applied it, else the remaining owners' value); an owner removed while its removal of a member is in flight (the remaining owner is shown it, and its own removal finishes it); a leaver, through a relay that has not applied its leave, sends during an owner's hold a role change above its published bound, a retry with a huge version, and a removal of an owner and of the holder (none is applied, and no gate moves); a removed owner sends role changes within its old bound (no owner holds them); replayed and duplicate departure copies, one from each relay (each member is re-sent at most once); three remaining owners adopting at once (nothing more is re-sent); owner A changes a moderator and a subscriber in one call while owner C's later change reaches the relay before the relay forwards A's, including where A's larger event does not fit the relay's batch and C's smaller one would (every member applies all three); a relay's departure report reaching the holder before that relay's forwards (the holder applies the reported changes); the holder's app running without a network past the hold period (the hold does not end); a relay replaying a pass-back, and a departed owner signing fresh copies for one member (each member is re-sent at most once per key); the removed owner rewriting its own `revokedOwners` entry as a leave with a huge version before the remover drops its key (the holder caps the bound, and the remover's `x.grp.mem.del` ends the hold); the only relay's connection at quota while the creator changes two roles and leaves (the changes are sent when it recovers, before the revocation); a promoted owner with no delegated descendants leaves while the creator stays (its revocation write keeps its own entry and is accepted); an owner that learns of a departure only from `x.grp.leave`, and separately only from `x.grp.mem.del`, republishes that entry unchanged from `revokedVersion` and bounds the undo by it; a pass-back re-send of member X followed by a departure undo of X under the same key (the undo still acts); a hold that ends by its period while the relays are heard, counted across an app suspend and a stop; the same-member winner had made a later change before seeing the conflict; an owner's role change made while it was offline survives the other owner's later changes, including after a chat-start roster request; +- a creator that made A an admin leaves (and, separately, is removed): a new joiner verifies the stored roster and sees A as admin, and receives the creator's as-channel history posts; +- an owner removes relay R2: its own write drops R2, for the creator and for a promoted owner; a relay that leaves on its own is dropped by a promoted owner's next write; the owner then adds the same chat relay again; +- a relay removed by one owner stays removed after another owner's write; a promoted owner's first write keeps every relay, and a relay O1 added survives O2's profile edit; +- O2 adds relay R3; O1 joins R3 as an owner; a subscriber connected only to R3 receives O1's post; +- promote admin A to owner; a later joiner and another owner both still see A as owner after the stored roster is served; +- a relay re-delivers an old `x.grp.owner.add` for a removed owner: a member who joined after the removal ignores it, and so does a member present at the removal after `cleanupRemovedMembers` has deleted the removed owner's row; a removed owner sending `x.grp.owner.add` for itself is ignored; +- a removal of M delivered before its `x.grp.owner.add` (forced interleaving): a subscriber that did not know M, and a relay, both end with M removed and refuse M's owner join; when the removal and the promotion both landed, an owner's reconcile revokes M; +- M promotes N, a subscriber that joined before M's promotion and never posted (M learned N from the relays on the commit), then M leaves: N alone runs the channel; +- an owner commits a subscriber that never accepted: that subscriber's device does not become an owner and makes no link-server connection; +- the promoter is stopped after its `LSETH`, before sending `x.grp.owner.add`, and never restarted: M completes its promotion on its next channel open, can write the link, removes a subscriber who joined before the promotion, and its post reaches every subscriber; likewise when the commit expired while M was offline; +- mutual removal (O1 and O2 remove each other concurrently) and the last two owners leaving at once: exactly one revocation lands, and a recognized owner with a key remains; +- an owner removed by a remover stopped before announcing, whose device never returns: relays drop the removed owner after their periodic link check, and its `x.grp.del` is ignored; +- a sole creator on a pre-v24 link server still edits the profile and relays (blind `LSET`); +- promoting a memberId in `revokedOwners` is refused before any `RKEYH`; +- delegation chain O2 → O3 → O4: revoke O3, then O2 — both stay in the chain while O4 is current, and link writes keep working; +- after the creator is removed, O2 deletes the channel and the link no longer resolves; a removed owner deleting its chat leaves the link resolving; +- promotion is refused when the chain would no longer fit in the link data, before any `RKEYH`; and refused while a current relay predates multi-owner; +- a promoted owner edits the profile, adds a relay (it becomes active and existing subscribers receive `XGrpRelayNew`) and publishes the count (foreign writes); its first write, before any reconcile, keeps every relay; two owners edit different profile fields concurrently and both edits survive; +- O1 joins relay R3 that O2 added: O1's profile and membership are unchanged afterwards; +- a relay O1 is setting up acks a roster version that O2's concurrent role change has superseded: O1 re-sends and the relay becomes active; +- a relay added by O2, to which O1 never connected, learns of O1's promotion of M and of a removal through its periodic link-data check; +- an invitation not answered by M expires on both sides, and M can then accept another owner's invitation; +- count across a newly-added relay (no crash; max ignores the catching-up relay) and publish-on-open updating a small channel 1 → 2 → 3; +- owner removal (revoke-first + key drop, a new joiner sees the removed owner as non-owner); a crash between `LSETH` and `RKEYH` — the remover's chain stopped after the `LSETH` by a test hook and the remover not restarted — heals on another owner's reconcile; +- cancel after accept: M's key is dropped and M cannot write the link; +- adversarial — replayed `acpt` (invitation-bound, no double-add), a moderator-injected `x.grp.promote.inv` (inert: no owner holds a matching pending record, M rejects a non-owner-signed inv), a chain published out of order, a pre-v24 link server (promotion fails closed), promotion on an unverified key (advisory warning only). + +The count/roster races need forced interleaving (`deliveryWorkerDelay`). simplexmq server and agent tests are listed in Part 1. diff --git a/plans/2026-07-21-channel-multi-owner-implementation-s1.md b/plans/2026-07-21-channel-multi-owner-implementation-s1.md new file mode 100644 index 0000000000..fd0830361f --- /dev/null +++ b/plans/2026-07-21-channel-multi-owner-implementation-s1.md @@ -0,0 +1,394 @@ +# Channel multi-owner — implementation plan + +Companion to the design overview (`2026-07-15-channel-multi-owner.md`); read that first for *why*. This covers *what to build*: agent APIs, data model, types, the protocol event, commands, logic, sequencing. It is a map for implementing agents, not an exhaustive edit list; trivial wiring (JSON instances, `CMEventTag` cases, view strings) is implied. + +In short: + +- **Owners.** A channel has several equal owners. Any owner can promote a member whose security code it has verified, and any owner can leave while another remains. +- **No SMP server change.** + - A promoted owner writes the link with today's `LSET`, resending the fixed data it read with `LGET`. + - It gets write access through today's `RKEY`. `RKEY` was added with short links (simplexmq `b7a95422`, SMP v15), so every server that hosts a channel link accepts it. +- **Link writes.** Every link write in a multi-owner channel reads the link data, changes only its own part, writes, and re-reads after owner and relay changes. +- **Link data is the durable source of owners and departed owners.** Every party applies it when it reads it. +- **The roster** keeps today's single-writer protocol on members and relays. + +## Sequencing + +Two repositories, in order: + +1. **simplexmq client and agent (Part 1).** No server or protocol change, and no agent schema change. +2. **simplex-chat (Parts 2–8).** + +The relay side ships in the same release as promotion. Promotion is refused while any current relay is below `multiOwnerVersion` (Part 8). + +--- + +## Part 1 — simplexmq (client and agent) + +- **SMP client.** + - Add `setSMPQueueRcvKeys`, which sends `RKEY` signed with a given recipient private key. Today the client has no `RKEY` call; the server handles it at `Server.hs:1574`. + - `LSET` (`addSMPQueueLink`, `Client.hs`) and `DEL` (`deleteSMPQueue`) already take an explicit key and queue ID. +- **`LinkOwnerCreds`**, a new agent type, holds what a non-creator owner needs to write a link: + - `SMPServer` and `LinkKey`, from the channel link; + - `linkRcvId`, from the link data read at the start of each write; + - the root public key; + - the owner's member private key, which both signs the user data and authorises the queue commands. + + Chat assembles it from stored values and the write's own read. The agent stores nothing. +- **Read for writing.** `getConnShortLinkForWrite` is `getConnShortLink'` (`Agent.hs:1321-1350`) with three differences: + - it also returns the encrypted fixed data from the `LGET` response (`getQueueLink`, `Agent/Client.hs:2015-2020`); + - it returns the raw user-data JSON, so the caller can keep fields it does not know; + - when the read is not proxied, it uses the same per-link transport session as the writes below. + + `LSET` requires the fixed data byte-identical to the stored copy (`QueueStore/STM.hs` `addQueueLinkData`; Postgres the same), and `LGET` returns it as stored (`Server.hs:1671-1672`). +- **Write with credentials.** `setLinkDataWithCreds creds encFixedData userContactData`: + 1. builds `ShortLinkCreds {shortLinkId, shortLinkKey, linkPrivSigKey = owner key, linkRootSigKey = Just root, linkEncFixedData}` in memory; + 2. runs `validateOwners`, which requires the owner key in the chain (`Agent/Protocol.hs:1991-2001`); + 3. signs and encrypts the user data as `prepareContactLinkData` does (`Agent.hs:1239-1272`), with encoding and size check separable, so a caller can check the size before any key change; + 4. sends `LSET` on `linkRcvId`, signed with the owner key. + + `setLinkRcvKeysWithCreds` (`RKEY`) and `deleteLinkQueueWithCreds` (`DEL`) take the same credentials. All three are synchronous and run on a transport session of their own per link: `TSMEntity` with the link's recipient ID, where the default session is shared per user and server (`mkTSession`, `Agent/Client.hs:1442-1443`). +- **Creator's own queue.** + - `getConnLinkQueue connId` returns the link queue's recipient ID and the public half of its `rcvPrivateKey`. It is a local read. + - `setConnLinkRcvKeys connId keys` sends `RKEY` on the creator's own link queue, signed with its stored key. + - The creator's link-data writes keep using `setConnShortLink` (`Agent.hs:1239-1250`), which already resends the stored encrypted fixed data. +- **Local delete.** `deleteConnectionLocal connId` deletes a connection and its queue records, and removes its subscriptions as `prepareDeleteConnections_` does (`Agent.hs:3031-3032`), without any server command. Every agent deletion path today sends `DEL` (`deleteConnectionsAsync_`, `Agent.hs:1022-1030`). +- **`Crypto/ShortLink.hs`.** Extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth`, define `newOwnerAuth` with it, and export it. Chat signs another member's existing key with it. +- **Forward compatibility.** The `UserContactData` parser drops trailing fields it does not know (`Agent/Protocol.hs:2075-2079`). A future agent-level link field therefore needs a version gate, or an older owner's write erases it. +- **Tests.** + - Link data written with a non-root owner key is accepted by a reader. + - `RKEY` with credentials lets the added key write and stops the removed key. + - `DEL` with credentials deletes the queue. + - `getConnShortLinkForWrite` followed by `setLinkDataWithCreds` keeps the fixed data. + - `deleteConnectionLocal` sends nothing to the server. + +--- + +## Part 2 — simplex-chat: data model + +- **No migration and no table changes.** + - Owners are member rows with role owner, and their keys are in `member_pub_key`. + - Departed owners are rows with status `GSMemLeft`, kept for good (Part 6.11). + - A promoted owner stores no link credential. Each of its link writes builds `LinkOwnerCreds` from four sources: the server and `LinkKey` from the channel link, the group's root public key, its member private key, and the `linkRcvId` of that write's own read. +- **Link data.** Chat's `GroupShortLinkData` (`Protocol.hs:1621`) gains three optional fields. simplexmq's `UserContactData` encoding is unchanged. + - `revokedOwners :: Maybe [MemberId]`: owners that left. + - `creatorQueueKey :: Maybe CreatorQueueKey {memberId, key}`: the creator's memberId and link-queue recipient public key. + - `linkRcvId`: the link queue's recipient ID. +- **Multi-owner channel.** A query that is true when this device holds an owner row other than its own membership, current or left. It selects the read-merge write path (Part 6.1). Owner rows are never deleted in channels (Part 6.11). + +--- + +## Part 3 — types + +- `LinkOwnerCreds` (simplexmq, Part 1). +- **`GroupLinkWriter = GLWCreator Connection | GLWOwner LinkOwnerCreds`.** It is resolved once per link write, from the keys: + - the root private key (`GRKPrivate`) selects the creator's own link connection; + - otherwise, if this device is a current owner, it selects the credentials, with `linkRcvId` from the write's own read; + - otherwise the device is not a link writer. + + It is never resolved from whether a group link row exists. +- **`CRGroupRelaysAdded` and `CEvtGroupLinkDataUpdated`** (`Controller.hs:862`, `:1035`) carry `Maybe GroupLink`. It is absent for a promoted owner, which has no group link row. +- **`GroupOwnerAdd {memberId, ownerAuth, profile}`** is the payload of `x.grp.owner.add`. `OwnerAuth` is reused from simplexmq. +- **"Current owner"** everywhere below means owner role, and neither left nor removed. + +--- + +## Part 4 — protocol + +- **`x.grp.owner.add`** carries `GroupOwnerAdd` and is in `requiresSignature`. It is sent owner → relays. Relays apply it and forward it to every member: + - add it to `isForwardedGroupMsg` (`Protocol.hs:528`); + - add it to the relay's dispatch (`processEvent`); + - add it to `processForwardedMsg` under `withAuthor` (`Subscriber.hs:4041-4064`), whose catch-all rejects unknown events. + + Older clients do not parse it and ignore it. +- **`multiOwnerVersion = VersionChat 23`.** `currentChatVersion` goes from 22 to 23 (`Protocol.hs:96-97`). +- **No other wire change.** Leaving uses `x.grp.leave`, and roster events and blobs are unchanged. + +--- + +## Part 5 — commands + +- **`APIMembersRole [M] GROwner` on a channel** starts the promotion (Part 6.3), replacing the ban at `Commands.hs:2994-2995`. It is refused when any of these holds: + - more than one target; + - a target that is not a current member with a key, is already an owner, or is in `revokedOwners`; + - the promoter has not verified the target's security code: `memberVerifiedCode` must equal `getChannelMemberCode` now (`Commands.hs:3811-3815`, stored by `setGroupMemberVerified`, `Store/Groups.hs:1754-1760`); + - any current relay below `multiOwnerVersion`. + + The target's version is not checked. The promoter only knows the range it recorded at the member's join (`Store/Groups.hs:2533-2551`). +- **`APIMembersRole` or `APIRemoveMembers` with a current owner as a target is refused in channels.** +- **`APIRemoveMembers` of a relay**, by any link writer, publishes the removal through Part 6.1 with that relay subtracted. Today it publishes only through the creator's `updatePublicGroupData` (`Commands.hs:3150-3152`). +- **`APILeaveGroup` by an owner of a multi-owner channel** runs the leave (Part 6.6) instead of `deleteGroupLinkIfExists` (`Commands.hs:3246`). + - It is refused when no other current owner remains. + - A sole owner keeps today's behaviour. +- **`APIDeleteChat` by a promoted owner**, in the branch that sends `x.grp.del` (`doSendDel`, `Commands.hs:1394-1400`), also deletes the link queue with `deleteLinkQueueWithCreds`, after reading the link data for its `linkRcvId`. `deleteGroupLinkIfExists` at `:1400` runs regardless of `doSendDel`, so this `DEL` is not added there. When this device created the channel and its membership is no longer a current owner (a leave stopped after step 5), `APIDeleteChat` deletes the link connection with `deleteConnectionLocal` instead of `deleteGroupLinkIfExists`. The key may still be authorised (overview §3), so it never sends `DEL`. +- **`APICreateGroupLink`, `APIAddGroupShortLink` and `APIDeleteGroupLink` are refused for channels** (`Commands.hs:3349-3384`). +- **`APIGetUpdatedGroupLinkData`.** Replace the owner branch at `Commands.hs:1968` with the owner reconcile (Part 6.2). For subscribers it also applies the owners chain (Part 6.5). +- **`APIAddGroupRelays`.** A promoted owner takes the channel link from `publicGroup.groupLink`; the creator takes it from its group link row (`Commands.hs:2790-2793`). Publication goes through Part 6.1. +- **`APIShareChatMsgContent`** signs with the member key of any current owner, not only under `GRKPrivate` (`Commands.hs:1238-1240`). +- **The connect plan** excludes `revokedOwners` from `glOwners`, from `verifyLinkOwner` and from owner-contact binding (`Commands.hs:4615-4618`, `:4648-4653`, `:4806-4815`, `:2327-2332`). + +--- + +## Part 6 — key logic changes (anchors) + +### 6.1 Link writes in a multi-owner channel + +One function, `writeChannelLink`, takes the change: profile, add owner, leave, relays added or removed, or count. All of a device's link writes for a channel run under the group lock. + +1. **Read.** The creator reads with `getShortLinkConnReq'` (`Internal.hs:1690-1693`), keeping the raw user-data JSON. A promoted owner reads with `getConnShortLinkForWrite`, which also gives it the `linkRcvId` for this write. +2. **Build the write** from the read JSON, so fields this version does not know are kept, applying only this change (overview §2): + - the profile as a whole value; + - a new owner's entry appended after its signer's entry; + - the writer's own memberId added to `revokedOwners`, keeping its own entry; + - relays: the read list, plus the relays this call adds, minus the relays this call removes, minus relays whose member row is left. A relay removal deletes its member row (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2195-2196`), so the removed relays are passed in. + + Then merge: + - owners this device holds as left go into `revokedOwners`; + - an owner held as current, missing from the chain and not in `revokedOwners` gets an entry signed by the writer; + - an entry in `revokedOwners` is dropped once no current entry was signed by it, transitively, except the writer's own entry in its leave write. Keep the chain in signing order, never a memberId sort: `validateLinkOwners` needs each entry signed by root or an earlier entry (`Agent/Protocol.hs:2003-2017`); + - the creator writes its own `creatorQueueKey` and `linkRcvId`, from `getConnLinkQueue`; every other writer carries the read ones; every writer carries `publicGroupData`. +3. **Write** through the resolved `GroupLinkWriter`: the creator uses `setConnShortLink`, a promoted owner uses `setLinkDataWithCreds`. +4. **Keys.** If the chain changed, send `RKEY` with the set computed from what was written. A promotion sets the keys first instead (Part 6.3). The set (overview §3) is: + - `creatorQueueKey.key`, unless `creatorQueueKey.memberId` is in `revokedOwners`; + - plus the `ownerKey` of every other entry not in `revokedOwners`. +5. **Verify.** After an owner, leave or relay change, read again. If the change is missing, repeat once from step 1. + +**Relay activation.** The body of the `LINK` handler (`Subscriber.hs:1431-1481`) becomes a function of the written relay list and the writer. It sets relays to `RSActive`, sends `XGrpRelayNew` to the other relays, and emits `CEvtGroupLinkDataUpdated`. It is called after step 5, because these writes are synchronous and produce no `LINK` event. Today the handler re-reads the group link row (`:1439`), which a promoted owner does not have. + +**Callers in a multi-owner channel:** + +- the profile edit, which today calls `setGroupLinkData'` (`Commands.hs:4190`, `Internal.hs:1581-1586`); +- the relay acknowledgement (`xGrpRosterAck`, `Subscriber.hs:3663-3665`) and the relay connect path for pre-roster relays (`Subscriber.hs:946-947`), which today use `setGroupLinkDataAsync`; +- relay removal (Part 5) and a relay's own leave (Part 6.10); +- promotion, leave, and the creator's count publish. + +Asynchronous callers run the synchronous write in a thread, still under the group lock. A failed write is not retried (overview §8). A single-owner channel keeps today's functions unchanged. + +### 6.2 Owner reconcile + +It runs in `APIGetUpdatedGroupLinkData` for owners, and once after chat start for each multi-owner channel the user owns. From the read link data: + +- **Profile.** Apply it as subscribers do (`updateGroupFromLinkData`, `Internal.hs:1634-1654`). +- **Owners chain.** Apply it as in Part 6.5. +- **Relays.** Join each listed relay this owner has no relay member for, through `connectToRelayAsync` (`Internal.hs:1606-1616`). Unlike `syncSubscriberRelays` (`Commands.hs:3992-4011`), this deletes nothing, so a relay this owner is still adding is kept. +- **Count.** The creator publishes its count if it differs (Part 6.10). +- **Roster.** At chat start only, and only when the newest item this device received in the channel is older than message expiry, or there is none, send each relay `XGrpRosterRequest` with this owner's complete frontier (Part 6.9 rule 7). + +### 6.3 Promotion + +Under the group lock, synchronously: + +1. **Read and prepare.** Read the link data and build the write with M's entry (Part 6.1 step 2). + - The creator signs the entry with the root key, as `Commands.hs:2757` does for itself; any other owner signs it with its member key. Both use `mkOwnerAuth` over M's stored key. + - On the creator's first promotion the write also adds `creatorQueueKey` and `linkRcvId`, both from `getConnLinkQueue`. + - Encode, sign and size-check the write now, for a promoted owner (`setLinkDataWithCreds`, Part 1). A write that would not fit in 13,784 bytes (`ShortLink.hs:46`) fails here. The creator's `setConnShortLink` checks the size only when it sends (`Agent.hs:1236-1270`), so the creator's oversize write fails at step 3, after its `RKEY`. +2. **Keys.** `RKEY` with the set computed from what was read, plus M's member key, plus the creator's own queue key on its first promotion. The creator uses `setConnLinkRcvKeys`; any other owner uses `setLinkRcvKeysWithCreds`. +3. **Write** the link data. +4. **Verify**, and repeat once from step 1 if M's entry is missing. A failure after step 2 fails the command without a revert. +5. **Commit.** Set M's role to owner locally, and send `x.grp.owner.add {memberId, ownerAuth, profile}` to the current relays. The profile is `redactedMemberProfile` (`Internal.hs:1361`), as member intros send it. + +### 6.4 Applying `x.grp.owner.add` + +On relays (`processEvent`) and on members (`processForwardedMsg`). Accept it only when all of these hold: + +- the sender is a current owner (signed; the event is in `requiresSignature`); +- `ownerAuth.ownerId == memberId`; +- `ownerAuth` verifies against the group's root public key (`groupRootPubKey`) or an owner key this device holds, current or left, because a delegated entry stays valid after its signer leaves; +- this device holds no row for `memberId` that left or was removed. + +Then: + +- set role owner and `member_pub_key = ownerAuth.ownerKey`, logging a warning when it replaces a different key; +- if the member is unknown, create it with `profile`; if its profile is a placeholder, update it; +- on a relay, return the group delivery scope so the event is forwarded. + +On M's own device, the commit completes M's promotion as in Part 6.5. + +### 6.5 Owners from link data + +`updateGroupFromLinkData` (`Internal.hs:1634-1654`; callers `Commands.hs:1966`, `:4624`, `:4651`) also receives the link's owners and `revokedOwners`, and applies them on every party that reads the link data. The agent has already validated the chain against the root key (`decryptLinkData`, `ShortLink.hs:114-116`). + +- **Departed owners.** For each memberId in `revokedOwners`, mark the member left, at whatever role this device holds it: a device that missed the commit holds it at its earlier role. A relay also drops that member's connection, as `xGrpLeave` does (`Subscriber.hs:3815-3818`). If the device holds no row, create a row of a member that left. This device's own membership is never changed. When it is listed, a leave of this device stopped after its link write, and leaving again continues it (Part 6.6). +- **New owners.** For each other chain entry whose member this device holds neither as owner nor as left or removed, set that member as owner with `ownerKey`, creating it with a placeholder profile if unknown. +- **This device's own entry.** If the chain lists this device's own memberId, not in `revokedOwners`, with its own member key, and its membership is current, it becomes owner: + - set the membership role to owner; + - create the "you are now an owner" service item. + + It stores nothing else: each of its link writes takes `linkRcvId` from its own read (Part 6.1). + + The commit (Part 6.4) does the same on receipt. An entry with any other key is ignored with an alert. + +**At join.** `APIConnectPreparedGroup` (`Commands.hs:2324-2332`) creates owners only for entries not in `revokedOwners`, and creates rows of members that left for memberIds in `revokedOwners`. + +### 6.6 Leave + +Under the group lock, synchronously: + +1. Read the link data and merge it as Part 6.1 step 2 does, which re-adds a current owner missing from the chain. Refuse unless the merged data shows another current owner: a chain entry other than its own, not in `revokedOwners`. If its own memberId is already in `revokedOwners`, this is a repeated leave: go to step 4. +2. Write with its own memberId in `revokedOwners`, keeping its own entry. +3. Verify, and repeat once if needed. +4. `RKEY` with the set computed from what was written, which no longer includes its own key. An `AUTH` error counts as done: its key is already gone. +5. Send `x.grp.leave` and delete member connections, as `APILeaveGroup` does today (`Commands.hs:3233-3253`). +6. Erase link credentials and keys: + - the creator deletes its link connection with `deleteConnectionLocal` and deletes its group link row; + - the creator replaces `root_priv_key` with `root_pub_key` in one update, so the group keeps its root public key; + - every leaver erases `member_priv_key`. A later load of the group generates an unrelated key (`mkGroupInfoKeys`, `Store/Shared.hs:750-755`), so the erased key is gone. + +**On receivers.** `xGrpLeave` (`Subscriber.hs:3815-3826`) is unchanged for members and relays. A remaining owner also re-sends its roster blob (Part 6.9 rule 3). + +### 6.7 Relays + +- **Invitation.** `updateRelayGroupKeys` (`Store/Groups.hs:2353-2373`, called from `getLinkDataCreateRelayLink`, `Subscriber.hs:4586-4612`) creates an owner row with `ownerKey` for every chain owner it has no row for, as `createLinkOwnerMember` does for joiners; today it throws at `:2366-2368`. It applies `revokedOwners` (Part 6.5). The inviting-owner check (`Subscriber.hs:4598-4599`) also refuses an owner in `revokedOwners`. +- **15-minute check.** `checkRelayServedGroups` (`Commands.hs:6001-6020`) already reads the link data; it also applies the owners chain and `revokedOwners` (Part 6.5). +- **Owner join.** In `memberJoinRequestViaRelay` (`Subscriber.hs:1690-1720`), an existing owner row is accepted like a roster-role row: + - the join is signed with that row's key; + - it passes the `viaRelay` check (`:1711-1714`); + - `acceptJoin` runs with the existing row and role owner. + + A join onto a row whose member left or was removed is refused in both branches. Today the roster branch checks the key but not the status (`:1702-1705`). The relay then treats the joining owner as any joiner, including sending it recent history. +- **History for joiners.** In `itemForwardMsgs` (`Internal.hs:1449-1460`, `:1540-1548`), a signed as-channel item whose author is no longer a current owner is sent as an unsigned channel item (`FwdChannel`), with its content re-encoded as for unsigned items. + +### 6.8 Owner joining a relay + +An owner joins a relay through `connectToRelayAsync`, from `xGrpRelayNew` (`Subscriber.hs:3881-3886`) and from the reconcile (Part 6.2). When the join completes, only the relay member is updated: + +- `updatePreparedUserAndHostMembers'` (`Store/Groups.hs:821-844`) skips the membership update and the group profile replacement when the membership role is owner; +- the `CON` branch for a host member (`Subscriber.hs:892-922`) skips the membership status update and the joined-channel items and event. + +### 6.9 Roster rules + +Members and relays keep `applyAtRosterVersion` and the completion gate. The rules from overview §4: + +1. **Tie-break.** In `rosterCompletion` (`Subscriber.hs:3572-3578`), when `pendingVer` equals `stored_roster_version`, keep the stored blob unless the incoming author's memberId is at least the stored author's (from `roster_sending_owner_gm_id`, `getStoredGroupRoster`, `Store/Groups.hs:1615-1630`). Below or above the stored version, today's gate decides. +2. **Concurrency re-send.** When an owner applies another owner's role or removal event whose version equals its gate (in `applyAtRosterVersion`, `Subscriber.hs:3404-3446`), then after the whole received message is processed it re-sends its current roster to every current relay at gate+1, as a blob only. It reserves that version as `broadcastRoster` does (`Internal.hs:2642-2645`) and sends with `sendRoster` (`Internal.hs:2665-2670`) and `buildGroupRoster` over the current roster members. +3. **Departure re-send.** When this device is an owner, it re-sends the same way, at gate+1, in two cases: + - it applies another owner's departure, either from `xGrpLeave` of an owner, or from Part 6.5 marking a held owner left from `revokedOwners`; + - it applies a served blob whose author it holds as left (rule 6). + + A departure from a link-data read can come before the departed owner's last forwarded messages, which `xGrpMsgForward` then drops (`Subscriber.hs:4019-4020`). So on that path the owner first sends `XGrpRosterRequest` with its complete frontier to every current relay, before its own re-send. The request then carries the frontier from before that re-send. +4. **Owners are outside the roster.** + - `processRosterEntries` (`Subscriber.hs:3610-3630`) skips entries for current owners and for members held as removed or left. + - `xGrpMemRole` (`Subscriber.hs:3448-3500`), including its own-membership branch (`:3451-3454`), ignores an event whose target is a current owner or a member held as removed or left, and any event that assigns the owner role. + - `xGrpMemDel` (`Subscriber.hs:3731-3795`), including its own-membership branch, ignores an event whose target is a current owner. +5. **Own versions advance the frontier.** `broadcastRoster` and the rule-2 and rule-3 re-sends set the complete frontier to the reserved version when it is the next one (`setCompleteRosterVersion`, as `Subscriber.hs:3419-3421` does on receipt). +6. **In sync before sending.** An owner sends a blob (own change, rule 2, rule 3) only when its complete frontier equals its gate. While it is out of sync: + - it sends `XGrpRosterRequest` with its complete frontier to every current relay, and again with each role change it makes; + - `broadcastRoster` reserves the version and sends the events without the blob; + - rules 2 and 3 wait until it is back in sync. + + Every owner, in sync or not, processes in `xGrpMsgForward` (`Subscriber.hs:4009-4027`) an `XGrpRoster` whose author it holds as left, verified against that author's kept key, instead of ignoring it. Completion still rejects a version below its gate. This is how an owner catches up both when it is out of sync and after a departure read from link data (rule 3). +7. **Absence request.** At chat start, when the newest item an owner received in the channel is older than message expiry, or there is none, it sends `XGrpRosterRequest` with its complete frontier to each relay. Relays already answer members this way (`xGrpRosterRequest`, `Subscriber.hs:3679-3685`). + +### 6.10 Count, and a relay's own leave + +In `updatePublicGroupData` (`Internal.hs:1620-1630`): + +- The owner branch runs only for the creator, recognised by `GRKPrivate`. For a promoted owner it does nothing; today it would throw on the missing group link row. +- In a multi-owner channel the creator's branch recounts but does not publish on joins, leaves and removals of subscribers. +- When the member that left is a relay, the creator publishes through Part 6.1 with that relay subtracted. +- The creator's reconcile publishes the count when it differs from the published one. +- Every other write carries the read count. + +### 6.11 Durable owner rows + +In channels: + +- **Rows are kept.** `getGroupMembersForExpiration` (`Store/Groups.hs:1357-1373`, used by item expiry `Commands.hs:6078-6079` and Clear chat `:1429-1430`) and `getRemovedMembersToCleanup` (`Store/Groups.hs:1375-1382`) exclude member rows whose role is owner, admin, moderator or member. +- **Intros don't revive.** `xGrpMemIntro` (`Subscriber.hs:3314-3322`) leaves the status of an existing member held as left or removed unchanged. Today `updatePreparedChannelMember` sets it to `GSMemIntroduced` (`Store/Groups.hs:3608-3623`). + +--- + +## Part 7 — apps (iOS and Kotlin, mirrored) + +- **Role picker.** Add Owner for channels (`canChangeRoleTo`, `ChatTypes.swift:3382`, `ChatModel.kt:2944`). + - For an unverified member it routes to the verification screen (`APIVerifyGroupMember`). + - The confirmation states that owners cannot be removed, that any owner can delete the channel, and that a member with an older app becomes an owner after it upgrades. + - Remove and role change are hidden for owner targets in channels (`ChatTypes.swift:3377-3390`, `ChatModel.kt:2938-2953`). +- **New `RcvGroupEvent` case** for "you are now an owner", with its strings. Both enums are exhaustive, so a missing case throws. +- **First link-changing action by a promoted owner** (profile, relays, promote, leave, delete) shows, once, that the device connects directly to the channel's link server. +- **Channel link screen.** + - An owner without a `GroupLink` sees `publicGroup.groupLink`, as subscribers do. + - The screen never creates a link for a channel: `GroupChatInfoView.swift:303-312`, `GroupLinkView.swift:164-166`, `GroupChatInfoView.kt:179-181`, `GroupLinkView.kt:119-123`. + - Decode `groupLink` in `CRGroupRelaysAdded` and `CEvtGroupLinkDataUpdated` as optional (`AppAPITypes.swift:1001`, `:1210`; `SimpleXAPI.kt:6815`, `:6846`). +- **Leave.** Allow owners to leave channels, in both member-status branches per file (`ChatListNavLink.swift:247`, `:272`; `ChatListNavLinkView.kt:322`, `:344`) and in the channel info view. The core's refusal for the last owner shows as an alert. +- **Relays screen.** For an owner without `group_relays` rows for some relays, it lists the channel's relay members. + +--- + +## Part 8 — version gating and compatibility + +- **Relays below `multiOwnerVersion`** throw on an owner they have no row for (`Store/Groups.hs:2366-2368`), never forward `x.grp.owner.add`, and refuse an owner's join. So: + - promotion is refused while any current relay is below it (owners know relays' versions, `supportsVersion`, `Subscriber.hs:933`); + - adding an older relay to a multi-owner channel leaves it invited and never active, and the owner removes it. +- **Members on older versions** ignore `x.grp.owner.add`. + - They keep rejecting the new owner's admin messages and signed posts until they upgrade. + - They see its unsigned as-channel posts as channel posts (`Store/Delivery.hs:159`). + - The profile reaches them through link-data refresh. +- **A target on an older version** ignores the commit. It becomes an owner after it upgrades and reads the link data (Part 6.5). +- **Joiners on older versions** take every chain entry as an owner, including those in `revokedOwners`. + - A departed owner's key no longer authorises the link queue, and relays have dropped its connection, so its messages do not reach them. + - They also apply a relay's stored blob made before an admin's promotion, and show that owner as admin. +- **Link servers.** Nothing changes: `LSET`, `LGET`, `RKEY` and `DEL` exist on every server that hosts channel links. + +--- + +## Part 9 — tests (scope) + +Under `describe "channels"` (`tests/ChatTests/Groups.hs`), with relays from `chatRelayProfile`: + +- **Promotion.** + - O1 promotes verified M. A subscriber that joined before accepts M's admin message after `x.grp.owner.add`. A member that knew M with a different relay-asserted key ends with the chain key. + - An unverified member's promotion is refused. + - A promotion is refused while a relay is below `multiOwnerVersion`. + - M's device ignores a commit or chain entry whose key is not its own. +- **Creator hand-over.** The creator promotes M and leaves. Then M: + - posts to every subscriber through every relay; + - edits the profile, with existing subscribers and a new joiner seeing it; + - adds a relay, which becomes active, existing subscribers get `XGrpRelayNew`, and a subscriber connected only to it receives M's post; + - removes a relay, which is dropped from the link and stays dropped after M's next write; + - promotes a verified subscriber; + - destroys the channel, after which the link no longer resolves. + + A joiner after the leave sees M as owner and the creator as left, verifies the stored roster, and receives the creator's as-channel history posts, served unsigned. The same holds after M clears the chat and after item expiry: M still edits the profile and adds a relay, and its leave as last owner is refused. +- **Three owners.** The creator promotes M (root-signed entry), and M promotes N (entry signed by M). + - M leaves: M's entry stays in the chain as N's signer, link writes by N and the creator keep validating, and joiners treat M as left. + - N then leaves: the next write drops both entries. +- **Admin promoted to owner.** It stays owner on relays, and on a later joiner after the stored roster is served. +- **Relays added by another owner.** O2 adds relay R3, and O1 joins R3 as an owner, both from `XGrpRelayNew` and from reconcile. O1's membership and profile are unchanged afterwards. +- **Concurrent writes.** + - Two owners edit the profile at the same moment: both writes return, and one profile is published. + - Two owners add relays at the same moment: both relays end published after the verify. +- **Concurrent roles.** + - Two owners change roles of different members at one version: after the gate+1 re-send, every relay serves the same blob holding both, every member applies both, and a joiner gets both. + - An owner with a gap whose blob author has left asks every relay, applies the served blob, and only then sends a blob, so a removal made just before the leave stays on relays. + - A relay withholds a delta from an out-of-sync owner, and no relay holds a newer blob. The owner still demotes an admin: the event reaches every relay and member, and no blob is sent. +- **Lost commit.** A promoter is stopped after its link write and before sending the commit, by a test hook. A member and a relay set M as owner from the link data (on open, and in the 15-minute check), and M becomes owner from its own entry and writes the link. +- **Stale single-owner write.** A single-owner link write queued before the creator's first promotion lands after it. M's writes fail until the creator's next write, which restores M's entry, `creatorQueueKey` and `linkRcvId`. Then M writes, and so does an owner M promotes after the creator leaves. +- **Departed owners.** + - A relay replays an old `x.grp.owner.add` for an owner that left, to a member that knew the leave, to one that never knew M, and to a later joiner: all ignore it once they have read the link data. + - An intro of a departed owner leaves it left. + - A relay that withholds the leave: the member marks the owner left on its next channel open. So does a member that held the departed owner as a subscriber, because it missed the commit. +- **Leave.** + - The last owner's leave is refused. + - The creator's leave sends no `DEL`, and the link still resolves. + - A leave stopped after step 2 and repeated continues at step 4. A reconcile before the repeat leaves the leaver's own membership current. A creator that deletes the chat after its leave stopped after step 5 sends no `DEL`. + - The creator changes roles while M is offline, then leaves. M reads the link data before its backlog: it asks every relay, applies the creator's served blob, and re-sends, and a later joiner gets a roster it can verify. + - After any leave, the leaver's stored member key no longer matches its chain entry's key. + +The concurrent cases use forced interleaving (`deliveryWorkerDelay`, a test hook between read and write). simplexmq tests are listed in Part 1. + +--- + +## Later + +Deferred parts, with the overview section that describes the limitation: + +- **Owner removal** (`RKEY` first, then `revokedOwners`, verify, `x.grp.mem.del`), with a server compare-and-swap on `LSET`/`RKEY` to close its race with a concurrent promotion and the races of §2 and §3 (§11). +- **Promotion over a direct contact**, with the member's consent (§6, §11). +- **Relays sending a new owner their member list** (§6). +- **A relay-sourced subscriber count** (§5). +- **Multi-writer roster convergence**, and carrying a departing owner's last changes (§4). +- **Field-level profile merge**, and a link-data version for stale profile events (§2). +- **A removed-relay tombstone** (§2). +- **In-order relay forwarding, and queueing messages to backlogged relays** (single-owner fixes, §11). +- **The creator retiring the root key** after its first promotion (§7). diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index fd0830361f..62ac598db9 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -1,3 +1,5 @@ +> Superseded by the S3-min plans: `2026-10-15-channel-multi-owner-s3min.md` (overview) and `2026-10-15-channel-multi-owner-s3min-implementation.md` (implementation). + # Channel multi-owner — implementation plan Companion to the design overview (`2026-07-15-channel-multi-owner.md`); read that first for *why*. This covers *what to build*: agent APIs, data model, types, the protocol event, commands, logic, sequencing. It is a map for implementing agents, not an exhaustive edit list; trivial wiring (JSON instances, `CMEventTag` cases, view strings) is implied.