docs: channel multi-owner plans: fix transfer, removal, relay, count, roster convergence and departed-owner changes from whole-plan review

This commit is contained in:
spaced4ndy
2026-10-14 09:43:50 +00:00
parent f368c6c0b7
commit ca15b493e4
2 changed files with 135 additions and 75 deletions
+51 -33
View File
@@ -23,17 +23,17 @@ Link data and keys already *have* a sequencer — the server — so they need on
**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** — an event whose version is below what a recipient already holds is ignored. 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.
**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.
- **Relays:** the published set is authoritative, except this owner's own in-progress relay changes, which already live durably in its relay rows (a relay it is adding and has not published yet, a relay it is removing). 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 its own removals; 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, and a relay another owner added is not dropped.
- **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`)), **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 of `memberId` 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). The count comes from relays (§5).
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 published entry 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.
@@ -47,7 +47,7 @@ Because local state thus stays "published + my in-progress changes" (an owner I
**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 one `RKEYH` whose expected set still contains that key and whose new set drops it (a CAS mismatch returns the current set, which shows whether the key is still there; an already-absent key needs nothing more) and 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** (local `owners` minus `revokedOwners`); a stale view under a concurrent leave + removal, or **concurrent mutual removal** (O1 and O2 each remove the other), can leave the channel with no recognized owner — both marked in `revokedOwners`, one key still live. This is deliberately **not auto-healed**: an un-revoke gated on the un-revoke `LSET` succeeding proves only momentary key-liveness (all recipient commands authorize against `recipientKeys`), not that the peer's `RKEY` has landed, so it could clear the mark and then lose its key — a ghost owner listed as live with no key, which nothing detects, strictly worse than the *detectable* bricking (both revoked, readable). Mutual removal is accepted as a rare residual, no worse than "any owner can destroy the channel" (§8), rather than healed with a fragile rule.
**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
@@ -55,45 +55,63 @@ Because local state thus stays "published + my in-progress changes" (an owner I
**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 `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. Without that, a member promoted to owner from a roster role 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.
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, so the served snapshot must be repaired — and without waiting on any one owner to return. Each owner keeps its **pending role-deltas** durably (member, pre-role, target-role, version) and receives every other owner's role events via `DJSGroup` fan-out (all members, not just relays/joiners), so the repair is event-driven with no new relay behavior. An *observed* roster is another owner's role event or blob, or a blob a relay served — never the owner's own local apply. When an observed roster shows a pending delta's member still at the delta's pre-role, the owner re-applies its unsatisfied deltas to its local roster and sends blob + deltas at one above the highest version it has seen. **Both** owners do this, each for its own member, so O1 (X→admin) and O2 (Y→admin) converge the served blob to *both*. A delta is **cleared** once an observed roster shows its member at the target role, or at a role other than the recorded pre-role — a later change superseded it (clear, do not re-apply), which is what stops an old delta from resurrecting a value and ping-ponging with a legitimate later change.
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.** If another owner emitted a *same-member* concurrent role event with a different role, that is a real conflict, not a dropped delta: the `(version, author)` tie-break value stands and is re-broadcast so the member converges, and the losing owner surfaces "your change to X was overridden" instead of re-applying. No silent revert.
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.
**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. Bulk changes (one call, one version bump, N events) re-broadcast the owner's union local state on retry — not the original bulk — so a concurrent single-member change by another owner is preserved, not regressed.
**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 event gets the one job scope; 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 events in the order it received them (above), so a leaver's events 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, or with the relays down, never ends a hold. 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.
- **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 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 never learns the subscribers who joined before it, so its own tally stays low. The count must come from relays.
**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. 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.
**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 a sole live owner, while no current relay supports the count event, falls back to its own tally, with its own clock as `ts` — no other owner publishes then, and promoting a second owner requires every relay to support it (§9). 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.
**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).
**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, 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 } -- O1 -> M
x.grp.owner.add { memberId, ownerAuth } -- O1 -> all members and relays (the commit)
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)
```
`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.
`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. 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. Both records expire after a fixed TTL, checked at chat start and by the periodic cleanup — M's only until it accepts, since after acceptance it holds the `linkRcvId` the commit needs: the inviter's expiry undoes its chain (below) and sends `cancel`; M's record also clears on `cancel` and when the inviter is revoked, 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): it first 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) → `x.grp.owner.add` (the **commit point** — M treats itself as owner only on receiving this).
**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`. 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 creating M if it was unknown. 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 revoked memberId. Revoked memberIds live in a group-level record on every member's device and relay — seeded from `revokedOwners` at join and at relay invite, added on removal and leave events — 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 revoked memberId is never promoted again (above), so the record only grows. Members that miss an event converge on their next link-data refresh, which applies the chain — validated entries as owners, `revokedOwners` as revoked — 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.
**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`).
On receiving the commit for itself, **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.
**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`).
**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; cancelling (or the invitation expiring) after the `RKEYH` drops M's key with `RKEYH`, and after the `LSETH` also revokes M (§7), before the record is cleared. A residual remains: a promoter whose device is lost between its `RKEYH` and `LSETH` 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.
**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.
@@ -101,9 +119,9 @@ On receiving the commit for itself, **M has its link-write credentials** — the
## 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 keep their own copies of owners' keys, so dropping an entry loses nothing they verify against, and `revokedOwners` stays as the tombstone that stops the union merge (§2) bringing it 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. **Leave** is the same, self-sequenced by the leaver: revoke itself, drop its own key, then send `x.grp.leave`; if it crashes, a remaining owner finishes (§3).
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. 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`. 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.
**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).
@@ -112,23 +130,23 @@ Add the target's `memberId` to **`revokedOwners`** in the mutable link data (`LS
- **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.
- **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); Tor or SOCKS avoids it. 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.
- **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.
- **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), and forgetting a connection without `DEL` (§7) are new agent operations.
- **Chat (owner device), one writer for every link write:** each link-write site resolves the writer once — the creator's own link connection, or a promoted owner's foreign-link credentials. 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 revoked-owner record on every member's device — memberId, key, whether this device confirmed the key drop, and for a removal this device runs the step reached, so a crashed removal is re-driven at chat start — in a group-level table that member cleanup does not touch (§6); the last published relay list (§2); pending-promotion record (member-row columns, with the step reached); pending role-deltas (member, pre-role, target-role, version); 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`) and `subscriberCountTs` beside the count; owners cache them 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 revoked-owner record; applies `x.grp.owner.add`; applies the chain from the link data in its periodic check (§6); sends `x.grp.relay.count`; and accepts a signed join from a current owner onto its existing row (§2). 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 — 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.
- **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 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). 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.
- **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`.
- **`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.
File diff suppressed because one or more lines are too long