From ca15b493e4a52daaea78a02af61d07216fe3fe11 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:43:50 +0000 Subject: [PATCH] docs: channel multi-owner plans: fix transfer, removal, relay, count, roster convergence and departed-owner changes from whole-plan review --- plans/2026-07-15-channel-multi-owner.md | 84 +++++++----- ...7-21-channel-multi-owner-implementation.md | 126 ++++++++++++------ 2 files changed, 135 insertions(+), 75 deletions(-) diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index 1ac1878e95..3f990486da 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -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. diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index c191050b61..5c58fc690d 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -31,6 +31,7 @@ Two repos, in order. **simplexmq first** (the relay version bump, hash-CAS on th - **An async variant** of the agent's `LSET` command (`setConnShortLinkAsync`, processed at `Agent.hs:2255-2258`), because the creator's link writes mostly run asynchronously: on success the `LINK` event carries the expected hash the command was issued with and the new hash; on conflict a new event returns the expected hash, the decrypted current data and its hash, and chat reconciles and re-enqueues. (The expected hash is the `baseHash` the announcing event needs, overview §2; reading it from chat's state when the event arrives would be wrong after an intervening reconcile.) - `updateRcvKeysCAS` — `RKEYH` on the own queue. - A local read of the own link queue's recipient id, which the creator puts in the promotion `inv` (today it is reachable only through `getConnectionQueueInfo`, a `QUE` round-trip to the server). + - A read of whether the link queue's server session supports v24 (connecting if needed; for the own queue and, with the server from the channel link, for a promoted owner), which the inviter checks before sending `inv`. Below v24 the CAS calls fail with `TEVersion`, as the client already gates version-dependent commands on `thVersion` (`directResolveName`, `Client.hs:1078-1084`); a sole live owner then writes with today's `setConnShortLink`/`setConnShortLinkAsync` (blind `LSET`), and with two or more live owners the write fails and is surfaced (Part 8). - **Foreign-link path** — a promoted owner does not own the link queue. `setForeignLinkData`, `updateForeignRcvKeys` and `deleteForeignLinkQueue` (`DEL`, for destroying the channel) take **explicit credentials**: `SMPServer`, the link-queue recipient id (`linkRcvId`, delivered in the promotion `inv`; it is *not* derivable from the public short link, which yields only the sender-side `linkId`), the `LinkKey`, `rootPubKey`, the owner's member private key (it signs the user data and authorises the commands), and the expected hash. Validation builds the link credentials in memory with the root key set, so `validateOwners` checks that the signing key is a chain key (`Agent/Protocol.hs:1991-2001`) — no `rcv_queues` row and no stored `linkRootSigKey` (`AgentStore.hs:2903` keeps `Nothing` for the creator, whose signing key is the root). Prefer this over materialising a fake `RcvQueue`/`ContactConnection`, which would need an `rcvDhSecret` the owner must not have and risks it subscribing to the creator's queue. Reads use the existing `getConnShortLink`. Foreign calls are **synchronous**: an agent async command delivers its result to a connection, and a promoted owner has no link connection to receive it. They run on a transport session of their own, one per link (the default session is shared per user and server, `mkTSession`, `Agent/Client.hs:1442-1443`), so the link server cannot tie them to the owner's other queues. - **Creator key rotation** — `setConnRcvAuthKey connId privKey` replaces the own queue's recipient key with the given one in three resumable steps: `RKEYH` adding the new key, persisting it as the queue's `rcv_private_key`, `RKEYH` dropping the old key. A rejected step returns the current set, from which the next step is decided. - **Forget a connection without `DEL`** — a new local-only deletion; today every agent deletion path sends `DEL` (`deleteConnectionsAsync_`, `Agent.hs:1022-1030`). @@ -48,41 +49,61 @@ One migration `M20260721_channel_multi_owner` (register in `Migrations.hs` + `.c |---|---|---| | `owner_auth_sig BLOB` | owners | chain entry signature (with the existing `member_pub_key` = the entry's `ownerKey`, this is the full `OwnerAuth`) | | `owner_auth_index INTEGER` | owners | **insertion (signer) order** in the chain — a new entry is appended after the entry that signed it; load-bearing for `validateLinkOwners`, *not* a memberId sort | +| `roster_key_version INTEGER` | any member (on an owner's device) | made-at version of the change that set the member's current role; `NULL` for an empty key, which only an event above `groups.roster_key_floor` beats (Part 6) | +| `roster_key_author BLOB` | ″ | that change's author `memberId` (with the version, the member's roster key; a removal's key lives on its `group_revoked_owners` row) | +| `roster_key_prev_version INTEGER`, `roster_key_prev_author BLOB` | ″ | that change's own `prevKey`, which a retry of it carries (Part 6) | +| `roster_key_resent INTEGER` | ″ | whether this owner has already re-sent the current key; cleared when the key changes (Part 6) | | `relay_subscriber_count INTEGER` | relays | latest absolute count that relay reported | | `relay_subscriber_count_ts TEXT` | relays | the relay's own timestamp for that reading (the freshness token; cleared with the count on `GSMemRemoved`/`GSMemLeft`) | | `promote_invitation_id BLOB` | the promotee (invitee row on O1; own membership on M) | pending promotion | | `promote_role TEXT` | ″ | offered role | -| `promote_status TEXT` | ″ | invited / accepted / key_added / entry_published / rejected / cancelled / expired — the step reached drives resume and the undo on cancel or expiry (Part 5) | +| `promote_status TEXT` | ″ | invited / accepted / key_added / entry_published / rejected / cancelled / expired — the step reached drives resume and the undo on cancel (Part 5); `entry_published` is set *before* the chain's `LSETH` is issued, so from it on the entry may be live | | `promote_link_rcv_id BLOB` | M's own row | link-queue recipient id (from `inv`) | -`MemberRoleProposal` (Part 3) is *derived* from `promote_*`, not a stored column. +`MemberRoleProposal` (Part 3) is *derived* from `promote_*`, not a stored column. Member cleanup (`getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`) skips a row whose `promote_status` is `accepted`, `key_added` or `entry_published`, so a chain interrupted by the member's removal can still be resumed and undone. + +In channels a relay's removal keeps its member row (status `GSMemRemoved`) and relay row (`RSInactive`), as a relay's own leave already does (`updateMemberRecordDeleted`, `Internal.hs:2218-2230`), instead of deleting both on either removal path (`deleteOrUpdateMemberRecordIO` and `fullyDeleteMemberRecordIO`, `Internal.hs:2192-2216`; `group_relays` cascades, `chat_schema.sql:839`): the next link write subtracts it (Part 6). `APIAddGroupRelays` then checks for duplicates only among relays whose member row is current (today any `group_relays` row, `Commands.hs:2782-2789`), so a removed relay can be added again; `group_relays` has no uniqueness on the chat relay, so the new invitation gets a row of its own. ### `groups` (columns) | column | purpose | |---|---| | `link_data_hash BLOB` | the hash this device holds for the published link data — the value it CASes on (from its accepted writes, rejections, and events whose base hash matched) | | `key_set_hash BLOB` | last known key-set hash | -| `published_relays BLOB` | the last published relay list this device knows (from reconcile, accepted writes, and adopted events); a write's relay list is this plus the owner's own in-progress additions minus its own removals (Part 6). `NULL` until first filled: a promoted owner reads the link data to fill it before its first write | +| `published_relays BLOB` | the last published relay list this device knows (from reconcile, accepted writes, and adopted events); a write's relay list is this plus the owner's own in-progress additions minus every relay whose member row on this device is removed or left (Part 6). `NULL` until first filled: a promoted owner reads the link data to fill it before its first write | +| `link_data_version INTEGER` | on every member and relay: the `linkDataVersion` it holds, from accepted writes, applied events and link-data refresh — on a relay only from an applied `XGrpInfo`, since a relay never applies the profile from link data (Part 6) and would otherwise drop the event that carries it; an `XGrpInfo` below it is ignored (Part 4) | +| `subscriber_count_ts TEXT` | the published `subscriberCountTs` this device holds, beside the existing `public_member_count`; non-count writes carry both unchanged (Part 6) | +| `roster_key_floor INTEGER` | on an owner: the roster version it held when it started keeping per-member keys (at the migration, or at its promotion); an empty key loses only to an event above it (Part 6) | +| `roster_blob_sent_version INTEGER` | on an owner: the version of the last roster blob it sent (a change, retry, relay setup or departure re-send); drives the equal-version re-send trigger (Part 6) | | `stored_roster_owner_id BLOB` | cross-device author `memberId` of the **stored roster blob**, written beside `stored_roster_version` only by `setGroupLiveRoster`; the tie-break key (supersedes the device-local `roster_sending_owner_gm_id` for comparison) | | `link_rcv_id BLOB` | for this user as a non-creator owner: the link queue's recipient id. With the server and `LinkKey` from the channel link and the existing `root_pub_key` and `member_priv_key` columns, it completes the foreign-link credentials (the creator keeps `GroupKeys` unchanged) | Do **not** add a `roster_version_owner_id` on the gate `roster_version` — the delta path (`setGroupRosterVersion`) must advance the gate *without* writing any blob author, or a delta would corrupt the blob tie-break (Part 6). -### `group_revoked_owners` (new table) -`(group_id, member_id, member_key, key_drop_enforced, removal_step)` — on every member's and relay's device, one row per revoked owner. It lives outside `group_members` because member cleanup deletes a removed channel member's row a day after removal when it has no chat items (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2192-2203`; `cleanupRemovedMembers`, `Commands.hs:5960-5966`; `getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`). Rows come from `revokedOwners` at join and at relay invite, from link-data refresh, and from removal and leave events; they are never deleted. -- `member_id` — the replay record: `x.grp.owner.add` for it is ignored, a relay refuses an owner join for it, and promotion refuses it (Parts 4–6). -- `member_key` — the key the heal drops (`NULL` when this device never knew it). -- `key_drop_enforced` — false when the revocation was first seen in the link data and the key drop is not yet confirmed; true once confirmed, or once the removal/leave event arrived (it is sent after the key drop). Drives the heal (Part 6). -- `removal_step` — for a removal this device runs: revoked / key_dropped / announced, so a crash is re-driven at chat start. +### `group_roster_catchup` (new table) +`(group_id, relay_group_member_id, roster_version)` — on an owner, per relay, the highest roster version that relay has served it as catch-up (Part 6). -### `group_pending_role_changes` (new table) -`(group_id, group_member_id, target_role, pre_role, roster_version, created_at)` — an owner's role changes not yet confirmed converged. Drives the roster auto-retry (overview §4): re-apply a row only while it is unsatisfied **and** the member still sits at `pre_role` in an *observed* roster (another owner's event or blob, or a relay-served blob — never this owner's own apply); **clear** the row as soon as an observed roster shows the member at `target_role` or at a role other than `pre_role` (this is what stops an old delta from resurrecting a value and ping-ponging with a legitimate later change). +### `group_roster_latest_events` (new table) +`(group_id, member_id, roster_version, author_member_id, shared_msg_id, msg_body, msg_chat_binding, msg_signatures, broker_ts)` — on a relay, for each member, a copy of the latest owner's `x.grp.mem.role` or `x.grp.mem.del` carrying a roster version that it applied, kept in the table itself as `groups.roster_msg_*` keeps the stored roster's signed header (`chat_schema.sql:205-210`), so neither message cleanup and chat-item TTL (`Store/Messages.hs:392-394`, `:3621-3623`) nor member cleanup removes it. Served to owners as catch-up and passed back to a blob's author (Part 6). + +### `group_revoked_owners` (new table) +`(group_id, member_id, member_key, key_drop_enforced, removal_step, roster_key_version, roster_key_author, roster_key_prev_version, roster_key_prev_author, with_messages, roster_bound, hold_running_left)` — on every member's and relay's device, one row per removed memberId: every revoked owner, and every member removed by an applied `x.grp.mem.del` or by an owner-signed one whose target this device does not know (a removal can arrive before the promotion it revokes, through a different relay — overview §6). It lives outside `group_members` because member cleanup deletes a removed channel member's row a day after removal when it has no chat items (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2192-2203`; `cleanupRemovedMembers`, `Commands.hs:5960-5966`; `getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`). Rows come from `revokedOwners` at join and at relay invite, from link-data refresh, and from removal and owner leave events; they are never deleted. +- `member_id` — the replay record: `x.grp.owner.add` and `x.grp.mem.role` for it are ignored, a relay refuses any join onto it, and promotion refuses it (Parts 4–6). +- `member_key` — the key the heal drops (`NULL` when this device never knew it). +- `key_drop_enforced` — false when the revocation was first seen in the link data and the key drop is not yet confirmed; true once confirmed, or for a row from an event (a removal or leave event is sent after the key drop). Drives the heal (Part 6). +- `removal_step` — for a removal or leave this device runs: revoking / revoked / key_dropped / announced. The row is written with `revoking` before the first `LSETH`, so a crash at any point is re-driven at chat start through the announcement (Part 5). +- `roster_key_version`, `roster_key_author`, `with_messages` — on an owner's device, the key of a roster removal (made-at version and author) and its `withMessages` flag, kept here because the removed member's row is cleaned up; a removal beats any role change of that member, and a re-sent removal carries the flag (Part 6). A removal that carries no roster version — every removal in a call that removes no roster member (`Commands.hs:3136-3139`; a call that removes one stamps its version on every removal it sends, `:3196`) — has no key and is never re-sent; its record makes it stick on every device that saw it. A joiner records only `revokedOwners`, so if such a removal of S races another owner's promotion of S, a later joiner sees S at the promoted role from the relay's stored blob until the promoter's next roster change (cosmetic: S's connections are gone). +- `roster_bound`, `hold_running_left` — on an owner's device, for an owner that left and whose leave this device learned of from link data before processing its `x.grp.leave`: the roster version published with the leave (`revokedOwners`, below), and the running time left in the hold during which this device still applies that owner's held role and removal events (Part 6); `NULL` once the hold ends, when adoption runs. + +### `group_departure_notices` (new table) +`(group_id, member_id, relay_group_member_id)` — on an owner, the relays that have reported applying a departed owner's departure (`x.grp.owner.departed`, Part 4), recorded whenever a report arrives; a hold ends when every relay that was current and connected to this owner at its start, and not since removed or left, has reported (Part 6). + +An owner's own roster changes are the member keys whose author is this owner — no separate record. ### No pending-edit record for whole-value fields -Profile/prefs edits are synchronous and write-then-commit (Part 6), so nothing is stored while they are in flight. Owners and revoked owners merge as a union keyed by memberId; a relay this owner is adding or removing is already durable in its relay row. +Profile/prefs edits are synchronous and write-then-commit (Part 6), so nothing is stored while they are in flight. Owners and revoked owners merge as a union keyed by memberId; a relay this owner is adding is durable in its relay row, and a removed or departed relay in its kept member row. ### Published link data (not DB — chat's `GroupShortLinkData` JSON) -`GroupShortLinkData` (`Protocol.hs:1621`), carried in `UserContactData.userData`, gains optional fields — simplexmq's `UserContactData` encoding is unchanged: `linkDataVersion` (advisory ordering — recipients drop older events; correctness rests on the server hash-CAS and reconcile, so a corrupted value only degrades live-event ordering, never stored state), `revokedOwners :: [MemberId]`, and `subscriberCountTs` beside `publicMemberCount` in `PublicGroupData` (`:1616`). `group_revoked_owners` locally mirrors `revokedOwners`. +`GroupShortLinkData` (`Protocol.hs:1621`), carried in `UserContactData.userData`, gains optional fields — simplexmq's `UserContactData` encoding is unchanged: `linkDataVersion` (advisory ordering — recipients drop an older profile event; correctness rests on the server hash-CAS and reconcile, so a corrupted value only degrades live-event ordering, never stored state), `revokedOwners :: [RevokedOwner]` (`{memberId, rosterVersion, leave}` — the roster version its writer held when writing the entry, and whether the entry is the owner's own leave, whose held changes that version bounds, Part 6; a memberId appears once, and the merge keeps the published entry), and `subscriberCountTs` beside `publicMemberCount` in `PublicGroupData` (`:1616`). `group_revoked_owners` locally holds `revokedOwners`, besides the other removed memberIds it records. --- @@ -91,7 +112,7 @@ Profile/prefs edits are synchronous and write-then-commit (Part 6), so nothing i - **`MemberRoleProposal = MRProposed GroupMemberRole | MRRejected GroupMemberRole`** (`Types.hs`); `GroupMember` gains `memberRoleProposal :: Maybe MemberRoleProposal` (derived from `promote_*`) and the app-facing `promotionPending :: Bool`. - **`PromotionRoleData`** for the wire: a role-tagged sum with an opaque fallback — `PRDOwnerInv { linkRcvId }` / `PRDUnknown` (decode an unrecognised tag to `PRDUnknown`, do **not** error — `omittedField` only covers an *absent* field, not a present unknown one). - **`ForeignLinkKeys`** — the input to the Part 1.2 foreign-link APIs: `SMPServer`/`linkRcvId`/`LinkKey`/`rootPubKey`/member private key, assembled from `groups` columns, plus the expected hash. -- **`GroupLinkWriter = GLWOwn Connection GroupLink | GLWForeign ForeignLinkKeys`** — resolved once per link write (Part 6). +- **`GroupLinkWriter = GLWOwn Connection GroupLink | GLWForeign ForeignLinkKeys`** — resolved once per link write (Part 6), from the keys: `GRKPrivate` (the creator, holding the root private key) → `GLWOwn`; otherwise a stored `link_rcv_id` → `GLWForeign`; neither → not a link writer. Never from whether a group link row exists (Part 5 refuses creating one for a channel). - Reuse: `OwnerAuth` (simplexmq), `MemberId`, `MemberKey`, `VersionRoster`. --- @@ -102,53 +123,64 @@ All `requiresSignature`. Wire is JSON with optional-field forward-compat (`.=?` | event | dir | fields | delivery | |---|---|---|---| -| `x.grp.promote.inv` | O1→M | `invitationId, memberRole, roleData?` (`roleData` = `PRDOwnerInv{linkRcvId}`) | M's support scope | -| `x.grp.promote.acpt` | M→O1 | `invitationId, memberKey, roleData?` (none for owner) | support scope | -| `x.grp.promote.reject` | M→O1 | `invitationId` | support scope | -| `x.grp.promote.cancel` | O1→M | `invitationId` | support scope | -| `x.grp.owner.add` | owner→all | `memberId, ownerAuth` (+ the link-data hash fields below) | `DJSGroup` — all members and relays | +| `x.grp.promote.inv` | O1→M | `invitationId, memberId, memberRole, roleData?` (`memberId` = M; `roleData` = `PRDOwnerInv{linkRcvId}`) | M's support scope (`DJSMemberSupport` of `memberId`) | +| `x.grp.promote.acpt` | M→O1 | `invitationId, memberKey, roleData?` (none for owner) | M's support scope (the sender's) | +| `x.grp.promote.reject` | M→O1 | `invitationId` | M's support scope (the sender's) | +| `x.grp.promote.cancel` | O1→M | `invitationId, memberId` | M's support scope (`DJSMemberSupport` of `memberId`) | +| `x.grp.owner.add` | owner→all | `memberId, ownerAuth, profile` (+ the link-data hash fields below) | `DJSGroup` — all members and relays | | `x.grp.relay.count` | relay→owners | `count, ts` (the relay's own clock) | sent directly to owners, as the relay sends the join announcement (`introduceInChannel`, `Internal.hs:1320-1326`) — not a `DJSGroup` job, which would fan out to every member | +| `x.grp.owner.sync` | owner→relay | — | sent directly to each relay by a promoted owner that completed from link data (Part 6); the relay answers a current owner with its member catch-up and count reading, below | +| `x.grp.roster.dropped` | relay→owner | `sharedMsgId, version, proof` (`proof` = the owner-signed role or removal event, or roster header, that set the relay's gate to `version`) | sent directly to the owner whose role or removal event the relay's gate dropped; never forwarded, and ignored when it arrives as a forward (Part 6) | +| `x.grp.owner.departed` | relay→owner | `memberId` (the departed owner) | sent directly to every current owner when the relay applies that owner's departure, after its kept copies of that owner's events; never forwarded (Part 6) | -`memberKey` in `acpt` is M's existing key; O1 checks it **equals the key it already holds for M** and adds the key it holds (a consistency check, distinct from the advisory OOB verification in Part 7). +`memberKey` in `acpt` is M's existing key; O1 checks it **equals the key it already holds for M** and adds the key it holds (a consistency check, distinct from the advisory OOB verification in Part 7). Support-scope delivery also reaches moderators, admins and owners (`Subscriber.hs:4424-4438`), so every recipient other than `memberId` ignores `inv` and `cancel`; M accepts them only signed by a current owner, and `cancel` only from the `inv`'s signer; O1 matches `acpt` and `reject` only from M's own member row. -**`x.grp.owner.add` is the promotion commit.** A member or relay applies it only if its sender (the message signer) is a current owner, `memberId` is not in `group_revoked_owners`, `ownerAuth` validates — signed by the stored root key (`root_pub_key`) or by a current, non-revoked owner's key — and `ownerAuth.ownerId` is `memberId`; it then sets the member's `member_pub_key` to `ownerAuth.ownerKey` (logging a warning when a relay-asserted key differs), creates the member if unknown, and sets its role to owner. M, receiving it for itself, becomes owner (Part 6). It is a new event rather than `x.grp.mem.role … GROwner`, which would apply the role to a relay-asserted key (`Subscriber.hs:3465-3468`) and never create an unknown owner (`:3458`); clients that predate it ignore it and keep rejecting the new owner's admin messages (Part 8). The sender check stops a removed owner reinstating itself with its own still-valid root-signed entry; the revoked check stops a relay re-delivering an old add. `group_revoked_owners` survives member cleanup (Part 2), and a revoked memberId is never promoted again (Part 5), so no version gate is needed — which would also drop adds that relays merely reordered. On receiving `x.grp.owner.add`, a relay sends the new owner its current `x.grp.relay.count` reading. +**`x.grp.owner.add` is the promotion commit.** A member or relay applies it only if its sender (the message signer) is a current owner, `memberId` is not in `group_revoked_owners` and its member row (if any) is not removed or left, `ownerAuth` validates — signed by the stored root key (`root_pub_key`) or by a current, non-revoked owner's key — and `ownerAuth.ownerId` is `memberId`; it then sets the member's `member_pub_key` to `ownerAuth.ownerKey` (logging a warning when a relay-asserted key differs), creates the member if unknown with `profile` (or updates a placeholder profile with it — relays introduce owners only to later joiners, `Internal.hs:1320-1332`), and sets its role to owner. M, receiving it for itself, completes its promotion only as Part 6 describes. It is a new event rather than `x.grp.mem.role … GROwner`, which would apply the role to a relay-asserted key (`Subscriber.hs:3465-3468`) and never create an unknown owner (`:3458`); clients that predate it ignore it and keep rejecting the new owner's admin messages (Part 8). The sender check stops a removed owner reinstating itself with its own still-valid root-signed entry; the removal record stops a relay re-delivering an old add, and a removal that arrived before the add it revokes (Part 2). `group_revoked_owners` survives member cleanup (Part 2), and a removed memberId is never promoted again (Part 5), so no version gate is needed — which would also drop adds that relays merely reordered. Whenever a relay starts treating a memberId as an owner — applying `x.grp.owner.add`, applying the chain in its periodic link check, or accepting that owner's join — and when a current owner sends it `x.grp.owner.sync`, it sends that owner `XGrpMemNew` for each of its current members, batched (relays announce joiners only to the owners and moderators of that moment, `Internal.hs:1323-1326`, so a promoted owner would otherwise hold no row or key for earlier subscribers; the keys are relay-asserted, as the creator's are today), and then its current `x.grp.relay.count` reading, which M also takes as that relay's confirmation that it treats M as an owner (overview §6). A device stores `x.grp.relay.count` from a relay member whatever its own role, since the reading can arrive before the forwarded commit. -Additive: `linkDataVersion`, `linkDataBaseHash` and `linkDataHash` become optional fields on the events that announce link-data writes — `XGrpInfo`, `XGrpRelayNew`, `x.grp.owner.add`, and `x.grp.mem.del` of an owner — present only when the event describes its write's entire change (a write that activates several relays at once carries none). Order: write first (`LSETH`), then broadcast. A receiving owner adopts `linkDataHash` into `link_data_hash` only when `linkDataBaseHash` equals the value it holds (overview §2); otherwise it applies the content and keeps its hash. +`x.grp.mem.role` and `x.grp.mem.del` gain an optional `retryOf :: {version :: VersionRoster, author :: MemberId}`, the key of the change a re-send carries (absent on an original, whose key is its own version and signer, Part 6), and an optional `prevKey` of the same shape, the key the change replaced on its author's device — the sender's own previous key on an original, the original change's stored `prevKey` on a retry (used only to decide whether to surface an override); members and relays apply a retry through the gate like any change, and owners order it by that key. A relay whose gate drops an owner's role or removal event sends it unchanged to its other current owners (directly, as it sends `x.grp.relay.count`, the events of one received message batched in one message, `encodeBinaryBatch`, `Internal.hs:2899-2903`), so every owner hears every owner's roster change, and sends the sending owner `x.grp.roster.dropped` with its gate and that gate's signed proof (table above; Part 6). `XGrpRosterRequest` from an owner asks a relay for catch-up — its kept latest role and removal events above the requested version — instead of the blob a member's request gets; from a relay, carrying the relay's gate and, as a new optional field, the same signed proof, it asks an owner to re-send its blob (Part 6). + +Additive: `linkDataVersion`, `linkDataBaseHash` and `linkDataHash` become optional fields on the events that announce link-data writes — `XGrpInfo`, `XGrpRelayNew`, `x.grp.owner.add`, and `x.grp.mem.del` of an owner — present only when the event describes its write's entire change (a write that activates several relays at once carries none). Order: write first (`LSETH`), then broadcast. A receiving owner adopts `linkDataHash` into `link_data_hash` only when `linkDataBaseHash` equals the value it holds (overview §2); otherwise it applies the content and keeps its hash. Every recipient ignores an `XGrpInfo` whose `linkDataVersion` is below its `link_data_version` — the profile is a whole value, so an older one arriving late through another relay would revert a newer one (`xGrpInfo` applies any differing profile, `Subscriber.hs:3848-3849`); the other announcing events compose (relay and owner additions, removals) and are not version-gated. + +The chat version these events and the relay behaviour gate on is `multiOwnerVersion = VersionChat 23`, with `currentChatVersion` raised from 22 (`Protocol.hs:96-97`). The roster tie-break needs **no wire field** — the author is the message signer (`withAuthor`), already available at the completion gate; store its cross-device `memberId` in `stored_roster_owner_id` at completion and compare there. -The new events are added to the relay forwarding set (`isForwardedGroupMsg`, `Protocol.hs:528`) and the relay's dispatch, or relays silently drop them. +The new owner events (`x.grp.promote.*`, `x.grp.owner.add`) are added to the relay forwarding set (`isForwardedGroupMsg`, `Protocol.hs:528`), the relay's dispatch (`processEvent`) and, since members and owners receive them as relay forwards, `processForwardedMsg` under `withAuthor` (`Subscriber.hs:4041-4064`, whose catch-all rejects unknown events) — or relays and members drop them. The direct-only events — `x.grp.relay.count`, `x.grp.owner.sync`, `x.grp.roster.dropped`, `x.grp.owner.departed` — are not forwarded and are ignored when they arrive as a forward. --- ## Part 5 — commands (`ChatCommand`, `Controller.hs`) -- **`APIAcceptRolePromotion GroupId`** — M sends `acpt`, marks accepted; the async chain (overview §6) then runs on O1. M holds at most one pending promotion: an `inv` received while one is pending is answered with `reject` automatically. M's record clears on `cancel`, when the inviter is revoked, and — only until M accepts — on its own TTL; after acceptance it holds `promote_link_rcv_id`, which the commit needs. +- **`APIAcceptRolePromotion GroupId`** — M marks accepted, then sends `acpt` (a crash between them leaves an accepted record, which the chain may still commit); the async chain (overview §6) then runs on O1. M holds at most one pending promotion: an `inv` received while one is pending is answered with `reject` automatically. Before acceptance M's record clears on `cancel`, when the inviter is revoked, and on its own TTL. After acceptance it holds `promote_link_rcv_id`, which completing needs (Part 6), so only `cancel` clears it; when the inviter is revoked, M settles it from the link data on its next refresh — an entry for M completes the promotion, none clears the record. - **`APIRejectRolePromotion GroupId`** — sends `reject`, clears. -- **`APICancelRolePromotion GroupId GroupMemberId`** — O1 sends `cancel`. If the chain already added M's key (`promote_status` `key_added`), it drops the key with `RKEYH`; if the entry was published (`entry_published`), it also revokes M (the removal chain below); then clears the proposal. The same undo, followed by `cancel` to M, runs when the inviter's record expires. Both sides' records (M's only before it accepts) expire after a fixed TTL in `ChatConfig`, checked at chat start (with the re-drive of pending records) and by a per-user step in the periodic cleanup manager (`cleanupUser`, `Commands.hs:5922-5933`) — not by the relay-request pattern, whose TTL is checked only on a temporary error inside its own worker (`retryTmpError`, `Subscriber.hs:4554-4559`), which an invitation waiting on a person never reaches. -- Route **`APIMembersRole … GROwner`** on a channel into the invite flow — single-target (reject a multi-member owner promotion, as the existing guards do: channel owner-role guard `Commands.hs:2994-2995`, multi-member admin guard `:2990-2991`). Before any key change the chain refuses a memberId in `revokedOwners` (its key would be added but its entry never written, since the merge excludes revoked memberIds) and checks that the link data still fits with M's entry added (user data is padded to 13,784 bytes, `ShortLink.hs:46`), refusing the promotion with a clear error otherwise. The creator's first promotion then rotates its link-queue key to its member key (Part 1.2). -- Route owner **removal** (`APIRemoveMembers` of an owner) into the removal flow (overview §7): an async command that writes the revocation **first** (`LSETH` adding the target to `revokedOwners`), **then** drops the key (`RKEYH`; the target's key is its chain entry's `ownerKey`), **then** sends `x.grp.mem.del`. Each `RKEYH`/`LSETH` payload is built by applying the delta to the server's *current* set/blob (the believed one, or the one returned on a conflict) — never rebuilt from the chain, which could drop a concurrently-added owner's key whose chain entry is not yet published. A crash between the steps heals via reconcile (Part 6). Refuse removing/leaving the last *live* owner (local `owners` minus `revokedOwners`); a stale view under concurrent leave+removal or mutual removal is an accepted, disclosed residual, not auto-healed. -- **Leave** (`APILeaveGroup`, `Commands.hs:3233-3253`) by an owner while another live owner remains: revoke itself (`LSETH`), drop its own key (`RKEYH`), then `x.grp.leave`. The creator then forgets its link connection without `DEL` (Part 1.2) instead of `deleteGroupLinkIfExists` (`Internal.hs:1739-1748`), which would `DEL` the shared queue. The same applies where the creator processes its own removal (`Subscriber.hs:3736`) and where an owner that is no longer current deletes the chat (`Commands.hs:1400`). Destroying the channel still deletes the queue: the creator through its link connection as today, and a non-creator owner, which has none, with `deleteForeignLinkQueue` (Part 1.2) — only in the branch where a current, non-revoked owner sends `x.grp.del` (`doSendDel`, `APIDeleteChat`, `Commands.hs:1394-1400`). `deleteGroupLinkIfExists` at `:1400` runs regardless of `doSendDel`, so the foreign `DEL` must not join it there: a removed owner whose key a crash left live would otherwise destroy the channel by deleting its chat. +- **`APICancelRolePromotion GroupId GroupMemberId`** — O1 sends `cancel`. If the chain already added M's key (`promote_status` `key_added`), it drops the key with `RKEYH`; from `entry_published` on — set before the chain's `LSETH` is issued, so the entry may be live or still queued in the agent — it revokes M instead (the removal chain below, revocation first), which also removes M from the channel — a revocation is applied as a removal everywhere (Part 6), and the Cancel confirmation says so from that step on; then clears the proposal. The same undo runs when the chain stops because M left or was removed (below). The inviter's record expires only while `invited` (its expiry sends `cancel` to M), and M's only before it accepts — after acceptance the chain runs to completion or to an explicit cancel. Both TTLs are fixed in `ChatConfig` and checked at chat start (with the re-drive of pending records, under the group lock) and by a per-user step in the periodic cleanup manager (`cleanupUser`, `Commands.hs:5922-5933`) — not by the relay-request pattern, whose TTL is checked only on a temporary error inside its own worker (`retryTmpError`, `Subscriber.hs:4554-4559`), which an invitation waiting on a person never reaches. +- Route **`APIMembersRole … GROwner`** on a channel into the invite flow — single-target (reject a multi-member owner promotion, as the existing guards do: channel owner-role guard `Commands.hs:2994-2995`, multi-member admin guard `:2990-2991`). Before sending `inv` the inviter checks that the link queue's server session supports v24 (Part 1.2). Before any key change the chain refuses a memberId in `revokedOwners` or in `group_revoked_owners` (its key would be added but its entry never written, since the merge excludes revoked memberIds) and checks that the link data still fits with M's entry added (user data is padded to 13,784 bytes, `ShortLink.hs:46`), refusing the promotion with a clear error otherwise. Before each step it re-checks that M is still a current member without a `group_revoked_owners` row and that the inviter is not revoked, and undoes otherwise (above); each chain `LSETH` attempt re-checks the fit and the inviter's revocation on the blob it replaces, and on a misfit undoes by dropping M's key. M's entry is added to the local chain only by that `LSETH`, so no unrelated write (a count publish, a relay ack) publishes it early. The creator's first promotion then rotates its link-queue key to its member key (Part 1.2). +- **`APIMembersRole`** whose target is a current owner, to any other role, is refused in channels — removal is the only way to drop an owner (today only `newRole == GROwner` is blocked, `Commands.hs:2995`); receivers likewise ignore `x.grp.mem.role` whose target is a current owner or a memberId in `group_revoked_owners`. +- Route owner **removal** (`APIRemoveMembers` of an owner) into the removal flow (overview §7): an async command that first writes its `group_revoked_owners` row with `removal_step` `revoking`, then writes the revocation (`LSETH` adding the target to `revokedOwners`, with the remover's roster gate as its `rosterVersion`), **then** drops the key (`RKEYH`; the target's key is its chain entry's `ownerKey`), **then** sends `x.grp.mem.del`. Each `RKEYH`/`LSETH` payload is built by applying the delta to the server's *current* set/blob (the believed one, or the one returned on a conflict) — never rebuilt from the chain, which could drop a concurrently-added owner's key whose chain entry is not yet published. Refuse removing/leaving the last *live* owner, checked on the blob each revocation `LSETH` attempt replaces — the believed one, then the one each conflict returns: the actor must not be in its `revokedOwners`, and an owner outside `revokedOwners` must remain after the change; otherwise stop before any key change and surface it (overview §3). A crash between the steps is resumed from `removal_step` at chat start; another owner's reconcile heals the key drop if this device never comes back (Part 6). +- **Leave** (`APILeaveGroup`, `Commands.hs:3233-3253`) by an owner while another live owner remains: the same record and precondition; roster changes are refused once its leave record exists, but re-sends it has pending still go out (they carry earlier changes); then, once its earlier messages to every current relay are on the relay's queue server (their `msg_deliveries` marked by `SENT`, `sentMsgDeliveryEvent`, `Subscriber.hs:1189-1192`, `:1835-1837` — accepted by the server, not yet processed by the relay; and no `pending_group_messages` remain for any current relay — in channels an owner's message to a relay whose connection is inactive (`QUOTA`, `Subscriber.hs:1749-1762`) is stored as pending, as in other groups (`MSAPending`, `Internal.hs:2851`), and sent when the connection continues (`Subscriber.hs:1197-1199`); today `memberSendAction` drops it (`Internal.hs:2840-2846`, `readyMemberConn` `:2877-2885`), so a role change made while the relay was backlogged never reached it; within a bound, as a relay's queue server may be down), revoke itself (`LSETH`, an entry with its own roster gate as `rosterVersion` and `leave` set, keeping its own chain entry — a non-creator's write is validated with its own key, which must still be a chain key, pinned `Agent/Protocol.hs:1996-2001`; `revokedOwners` marks it revoked, and the next other owner's write drops it), drop its own key (`RKEYH`), then `x.grp.leave`; after its own key is dropped, an `AUTH` error on that key counts as done, so a resumed leave goes on to the announcement. The creator then forgets its link connection without `DEL` (Part 1.2) instead of `deleteGroupLinkIfExists` (`Internal.hs:1739-1748`), which would `DEL` the shared queue. The same applies where an owner processes its own removal (`Subscriber.hs:3736`; a relay processing its own removal there keeps deleting its own relay link) and where an owner that is no longer current deletes the chat (`Commands.hs:1400`). Destroying the channel still deletes the queue: the creator through its link connection as today, and a non-creator owner, which has none, with `deleteForeignLinkQueue` (Part 1.2) — only in the branch where a current, non-revoked owner sends `x.grp.del` (`doSendDel`, `APIDeleteChat`, `Commands.hs:1394-1400`). `deleteGroupLinkIfExists` at `:1400` runs regardless of `doSendDel`, so the foreign `DEL` must not join it there: a removed owner whose key a crash left live would otherwise destroy the channel by deleting its chat. +- **`APICreateGroupLink`, `APIAddGroupShortLink` and `APIDeleteGroupLink`** are refused for channels (`Commands.hs:3349-3384`): a channel's link is created with the channel, the apps would otherwise create a direct group link for a promoted owner that opens the link screen (`GroupLinkView.swift:164-166`), and `APIDeleteGroupLink` sends `DEL` on the shared queue (`deleteGroupLink'`, `Internal.hs:1733-1737`) — destroying the channel goes through `APIDeleteChat`. -The promotion and removal chains are async commands resumable from their durable records (the `promote_*` columns / `group_revoked_owners.removal_step`) — no new durable-worker type. +The promotion, removal and leave chains are async commands resumable from their durable records (the `promote_*` columns / `group_revoked_owners.removal_step`) — no new durable-worker type. --- ## Part 6 — key logic changes (anchors) -- **One writer for every link write.** Each link-write site resolves a `GroupLinkWriter` once: the creator's own link connection and group link, or a promoted owner's `ForeignLinkKeys`. Today every site needs the creator's group link row or link connection — `setGroupLinkData'` silently skips the write without one (`Internal.hs:1581-1586`, used by the profile edit at `Commands.hs:4190`), and `setGroupLinkData`/`setGroupLinkDataAsync` (`:1588-1604`), `updatePublicGroupData` (`:1624`), relay add (`Commands.hs:2790`) and relay connect/ack (`Subscriber.hs:946-947`, `:3663-3665`) throw. Foreign writes are synchronous (Part 1.2); a caller that is asynchronous today runs them in a thread, and its intent is already durable (relay rows, promotion records, `group_revoked_owners.removal_step`), so reconcile at chat start re-drives an interrupted one. The body of the `LINK` handler (`Subscriber.hs:1431-1481`: relay activation to `RSActive`, `XGrpRelayNew` to other relays, `CEvtGroupLinkDataUpdated`) becomes a function of the written data and the resolved writer — not a re-read of the group link (`Subscriber.hs:1439`), which a promoted owner has no row for — called from the `LINK` event and after a foreign write returns `OK`, which produces no `LINK` event. -- **`groupLinkData`** (`Internal.hs:1670`, self-only owners at `:1676-1682`) → build the write from local state via the **reconcile** routine; delete the `GRKPrivate` singleton / `[]` fallback; read the stored chain (insertion order). The relay list is `published_relays` (read from the link data first when it is still `NULL`) plus this owner's in-progress additions minus its own removals, and a write whose relay list is empty is refused unless this owner is removing the last relay — not only its own `group_relays` rows (`getPublishableGroupRelays`, `Store/Groups.hs:1528-1545`), which reference its own chat relays (`chat_schema.sql:840`) and are empty for a promoted owner. The count fields are the published `(publicMemberCount, subscriberCountTs)`, unchanged, except in the gated count publish. -- **Reconcile** = merge published link data into local, by field kind: owners and revoked owners merge as an idempotent union keyed by memberId, minus revoked entries no current entry depends on (dropped, below); relays: `published_relays` is replaced by the published list, and this owner's in-progress relay rows (being added and not yet published, or being removed) are kept; profile/prefs adopt the published value. Subscribers' link-data refresh (`updateGroupFromLinkData`, `Internal.hs:1634-1654`, today profile and count only; called only from `Commands.hs:1966`, `:4624`, `:4651`) also applies the chain: validated entries as owners (keys from `ownerKey`), `revokedOwners` into `group_revoked_owners`. Relays never call it; a relay applies the chain the same way in its periodic check, which already fetches the link data every 15 minutes (`checkRelayServedGroups`, `Commands.hs:6001-6020`), and at once when an owner join or `x.grp.owner.add` names a memberId it does not hold as owner — relays don't forward to each other, so an owner event from an owner not connected to a relay never reaches it otherwise. Run it **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — remove the owner exclusion at `Commands.hs:1968` and the apps' owner branch, `ChatView.swift:764-779` and its Kotlin counterpart), **at chat start** for each channel the user owns (owners that never open the channel view — CLI, bots — still heal and publish the count), **on a CAS conflict** (adopt the returned blob, rebuild), and as the **missed-event backstop**. +- **One writer for every link write.** Each link-write site resolves a `GroupLinkWriter` once, from the keys (Part 3): the creator's own link connection and group link, or a promoted owner's `ForeignLinkKeys`. The `GroupLink` that `CRGroupRelaysAdded` and `CEvtGroupLinkDataUpdated` carry (`Controller.hs:862`, `:1035`) becomes optional, absent only for a promoted owner, and both apps decode it as optional — an older remote-desktop app fails to decode only those responses, which exist only for a promoted owner. Today every site needs the creator's group link row or link connection — `setGroupLinkData'` silently skips the write without one (`Internal.hs:1581-1586`, used by the profile edit at `Commands.hs:4190`), and `setGroupLinkData`/`setGroupLinkDataAsync` (`:1588-1604`), `updatePublicGroupData` (`:1624`), relay add (`Commands.hs:2790`) and relay connect/ack (`Subscriber.hs:946-947`, `:3663-3665`) throw. Foreign writes are synchronous (Part 1.2); a caller that is asynchronous today runs them in a thread, and its intent is already durable (relay rows, promotion records, `group_revoked_owners.removal_step`), so reconcile at chat start re-drives an interrupted one. The body of the `LINK` handler (`Subscriber.hs:1431-1481`: relay activation to `RSActive`, `XGrpRelayNew` to other relays, `CEvtGroupLinkDataUpdated`) becomes a function of the written data and the resolved writer — not a re-read of the group link (`Subscriber.hs:1439`), which a promoted owner has no row for — called from the `LINK` event and after a foreign write returns `OK`, which produces no `LINK` event. +- **`groupLinkData`** (`Internal.hs:1670`, self-only owners at `:1676-1682`) → build the write from local state via the **reconcile** routine; delete the `GRKPrivate` singleton / `[]` fallback; read the stored chain (insertion order). The creator's own entry, computed on every write today (`Internal.hs:1676-1682`), is stored on its membership row at channel creation and filled on first start after the migration (it holds the root key); a write whose chain lacks an entry for a current, non-revoked owner is refused. The relay list is `published_relays` (read from the link data first when it is still `NULL`) plus this owner's in-progress additions minus every relay whose member row on this device is removed or left (Part 2 keeps removed relays' rows), and a write whose relay list is empty is refused unless this owner is removing the last relay — not only its own `group_relays` rows (`getPublishableGroupRelays`, `Store/Groups.hs:1528-1545`), which reference its own chat relays (`chat_schema.sql:840`) and are empty for a promoted owner. The count fields are the published `(publicMemberCount, subscriberCountTs)`, unchanged, except in the gated count publish. +- **Reconcile** = merge published link data into local, by field kind: owners and revoked owners merge as an idempotent union keyed by memberId, minus revoked entries no current entry depends on (dropped, below), and a published entry fills `owner_auth_sig`/`owner_auth_index` on a local owner row that lacks them (rows created before the migration store only the key); relays: `published_relays` is replaced by the published list, and this owner's in-progress relay rows (being added and not yet published) and its removed or departed relays' member rows are kept; profile/prefs adopt the published value. A chain owner for which this device holds a `group_revoked_owners` row — a removal that raced that member's promotion — is revoked by this owner through the removal flow (Part 5): the removal wins. Subscribers' link-data refresh (`updateGroupFromLinkData`, `Internal.hs:1634-1654`, today profile and count only; called only from `Commands.hs:1966`, `:4624`, `:4651`) also applies the chain: validated entries as owners (keys from `ownerKey`), `revokedOwners` into `group_revoked_owners`. A revocation this device has not yet applied is applied as `x.grp.mem.del` of that owner would be (`xGrpMemDel`, `Subscriber.hs:3747-3781`): a relay drops the owner's connection, a member marks it removed; for this device's own memberId, it is this device's own removal (`Subscriber.hs:3734-3746`, forgetting the link connection without `DEL`, Part 5). Authority checks are role-based, so without this an owner whose removal was never announced would keep its powers; `x.grp.del` is also accepted only from a current, non-revoked owner (today a role check, `Subscriber.hs:3828-3831`). Relays never call it; a relay applies the chain the same way in its periodic check, which already fetches the link data every 15 minutes (`checkRelayServedGroups`, `Commands.hs:6001-6020`), and at once when an owner join or `x.grp.owner.add` names a memberId it does not hold as owner — relays don't forward to each other, so an owner event from an owner not connected to a relay never reaches it otherwise. Run it **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — remove the owner exclusion at `Commands.hs:1968` and the apps' owner branch, `ChatView.swift:764-779` and its Kotlin counterpart), **at chat start** for each channel the user owns (owners that never open the channel view — CLI, bots — still heal and publish the count), **on a CAS conflict** (adopt the returned blob, rebuild), and as the **missed-event backstop**. - **Owner relay step** in reconcile, instead of `syncSubscriberRelays` (`Commands.hs:3992-4011`), which would delete this owner's relays that are not yet published and join other owners' relays as a subscriber: deactivate relays absent from the published data that this owner is not adding, and connect to relays other owners added by joining through the relay's link as itself, signed with its member key. -- **Relay accepts an owner's join.** In `memberJoinRequestViaRelay` (`Subscriber.hs:1690-1720`), an existing member is accepted onto its row only for roster roles (`:1702-1706`); any other existing member falls through to a fresh insert, which violates `UNIQUE(group_id, member_id)`. Add the owner case: an existing, non-revoked owner row plus a join signed with that owner's key, under the same `viaRelay` replay check as the roster branch (`verifyKey`, `:1711-1714`), is accepted via `acceptJoin` with the existing row and role owner; a memberId in `group_revoked_owners` is refused. Without it no owner can connect to a relay another owner added, so its posts never reach that relay's subscribers. On the owner's side, completing the join (the relay answers with `XGrpLinkInv`, handled for a `GCHostMember` at `Subscriber.hs:830-842`) updates only the relay member: `updatePreparedUserAndHostMembers'` (`Store/Groups.hs:821-844`) would replace the owner's group profile with the relay's copy and, unless the membership is `GSMemConnected` (the creator's is `GSMemCreator`), rewrite its membership role and status. +- **Relay accepts an owner's join.** In `memberJoinRequestViaRelay` (`Subscriber.hs:1690-1720`), an existing member is accepted onto its row only for roster roles (`:1702-1706`); any other existing member falls through to a fresh insert, which violates `UNIQUE(group_id, member_id)`. Add the owner case: an existing, non-revoked owner row plus a join signed with that owner's key, under the same `viaRelay` replay check as the roster branch (`verifyKey`, `:1711-1714`), is accepted via `acceptJoin` with the existing row and role owner; a memberId in `group_revoked_owners`, or whose row is removed or left, is refused — in this branch and in the roster-role one, which today checks the key but not the row's status (`:1702-1705`). Without it no owner can connect to a relay another owner added, so its posts never reach that relay's subscribers. On the owner's side, completing the join (the relay answers with `XGrpLinkInv`, handled for a `GCHostMember` at `Subscriber.hs:830-842`) updates only the relay member: `updatePreparedUserAndHostMembers'` (`Store/Groups.hs:821-844`) would replace the owner's group profile with the relay's copy and, unless the membership is `GSMemConnected` (the creator's is `GSMemCreator`), rewrite its membership role and status. Its `CON` (`Subscriber.hs:892-922`, a subscriber-side relay row is a `GCHostMember`, `Store/Groups.hs:1817`) likewise updates only the relay member: it would set the owner's membership to `GSMemConnected` and create the joined-channel items and event. - **Profile edit** (`runUpdateGroupProfile`, `Commands.hs:4164-4204`) becomes write-then-commit: today it commits the profile locally (`:4172-4174`) before the synchronous link write (`:4190`). Keep the starting profile in memory; on a conflict, merge field by field against it — a field only this owner changed keeps its value, a field only the other owner changed takes theirs, a field both changed differently keeps the published value and is surfaced as "profile edit superseded" — then retry. -- **Removal heal** (runs during reconcile, and it is what makes the revoke-first ordering crash-safe): for each `group_revoked_owners` row with `key_drop_enforced` false and a known `member_key`, issue one `RKEYH` whose expected set is the believed set plus that key and whose new set is the believed set (on a conflict, the returned set shows whether the key is still there), then set `key_drop_enforced`. Merging `revokedOwners` into local state alone does *not* evict a still-live key; this explicit `RKEYH` is required. An owner that receives the removal or leave event sets `key_drop_enforced` directly — the event is sent only after the key drop — so only a revocation first seen in the link data (the crash case) costs a direct connection to the link server. A removal this device runs and that crashed is resumed from its `removal_step` at chat start, through the announcement. +- **Removal heal** (runs during reconcile, and it is what makes the revoke-first ordering crash-safe): for each `group_revoked_owners` row with `key_drop_enforced` false and a known `member_key`, issue an `RKEYH` whose expected set is the believed set plus that key and whose new set is the believed set; on a conflict, drop the key from the returned set and retry while it is still there; then set `key_drop_enforced`. Merging `revokedOwners` into local state alone does *not* evict a still-live key; this explicit `RKEYH` is required. An owner that receives the removal or leave event sets `key_drop_enforced` directly — the event is sent only after the key drop — so only a revocation first seen in the link data (the crash case) costs a direct connection to the link server. A removal this device runs and that crashed is resumed from its `removal_step` at chat start, through the announcement. - **Relay activation** stays on the `LINK` event (and, for a foreign write, its `OK`, through the extracted function above), which the agent emits only after the server accepted the write (`Agent.hs:2255-2258`); under CAS it follows the accepted `LSETH` and carries the data written after any re-merge. Relays another owner removed produce no `LINK` here (the TODO at `Subscriber.hs:1475-1477`); the owner relay step deactivates them. -- **Roster** (overview §4): the event gate stays **version-only, author-blind** (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`), so concurrent *different-member* deltas both apply. The `(version, authorMemberId)` tie-break is added **only at the completion gate** (`Subscriber.hs:3572-3578`): keep the existing downgrade reject (`pendingVer < roster_version` gate) and, at the equal-version case that today overwrites by arrival order, accept iff `pendingVer > stored_roster_version` **or** (`pendingVer == stored_roster_version` **and** `author > storedAuthor`) — comparing the incoming blob's signer against `stored_roster_owner_id`, never the gate. Store the cross-device author with the blob in `setGroupLiveRoster` (`:3578`; `Store/Groups.hs:1716-1724`). A completed blob reverts privileged members absent from it (`processRosterEntries`, `Subscriber.hs:3610-3619`), so the losing owner's delta is reverted on relays until its retry. Applying any roster skips members who are currently owners (in `processRosterEntries`, `Subscriber.hs:3610-3630`, which today sets each listed member to its listed role, `:3601-3608`, `:3622-3628`): a member promoted to owner from a roster role stays in relays' stored blob, which a later joiner receives right after its owner intros (`Internal.hs:1330-1331`), and would otherwise be set back. **Relay setup under concurrent roster changes:** a roster ack below the owner's current version during relay setup re-sends the current roster to that relay (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`) instead of only warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`) — another owner's delta can advance the version between send and ack, and this owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would stay unpublished. **Retry**: when an observed roster shows a pending delta's member still at its pre-role, the owner re-applies its unsatisfied deltas to its local roster and sends blob + deltas at one above the highest version seen; both owners do this, so the served blob converges without waiting on any one owner. Existing members converge from the re-sent deltas (`DJSGroup` → all members). No leader, no relay-to-owner blob forwarding. -- **Count** (overview §5): `x.grp.relay.count` is produced by the relay branch of `updatePublicGroupData` (`Internal.hs:1628-1630`) and sent directly to owners; owners store `(count, ts)` per relay, with `ts` clamped to the broker timestamp of the message that carried it; the estimate is the **max** count over current relays, carrying **that max-holder's own `ts`**. Only a sole live owner, while no current relay supports `x.grp.relay.count`, uses its own tally with its own clock as `ts` (no other owner publishes then; promoting a second owner requires count-capable relays, Part 8). During reconcile (channel open, chat start) any owner publishes `(estimate, that ts)` iff the estimate differs from the published count **and** the max-holder's `ts` is newer than `subscriberCountTs` (a `subscriberCountTs` later than the owner's clock counts as stale). Every other write carries the published count fields unchanged, and the owner branch stops recounting its tally (`Internal.hs:1623`). The owner branch of `updatePublicGroupData` (`Internal.hs:1620-1627`) stops publishing for subscriber joins, leaves and removals — call sites `Subscriber.hs:3253`, `:3268`, `:3286`, `:3773`, `:3820`, `Commands.hs:3152` — and publishes only when the publishable relay set changed (a relay member removed or left). The `XGrpRelayNew` announcement threshold (`Subscriber.hs:1445-1449`, `publicMemberCount > 1`, which assumes one owner) uses the relay-sourced estimate. Clear a relay's `(count, ts)` on `GSMemRemoved`/`GSMemLeft` (`Types.hs:1430-1431`). -- **Owner materialisation** threads the full `OwnerAuth` (sig + append index): `createLinkOwnerMember` (`Store/Groups.hs:3575`; the key is `ownerKey`, as today), `updateRelayGroupKeys` (`Store/Groups.hs:2353`; create the owner row if missing — today it throws, `:2366-2368`). Joiners and relays skip entries listed in `revokedOwners` as owners and record them in `group_revoked_owners` (Part 2). Root-sign when the adder is the creator (holds the root private key), else delegate. A revoked entry is dropped from the written chain unless it is an ancestor, through any number of delegations, of a current entry (every entry must be signed by root or an earlier one, `Agent/Protocol.hs:2012`; an orphan would make every owner's agent refuse every write); `revokedOwners` keeps its memberId, so the union merge cannot restore it (overview §7: the blob is fixed-size, each entry is about 124 bytes). +- **Roster** (overview §4): the event gate stays **version-only, author-blind** (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`), so concurrent *different-member* deltas both apply. The `(version, authorMemberId)` tie-break is added **only at the completion gate** (`Subscriber.hs:3572-3578`): keep the existing downgrade reject (`pendingVer < roster_version` gate) and, at the equal-version case that today overwrites by arrival order, accept iff `pendingVer > stored_roster_version` **or** (`pendingVer == stored_roster_version` **and** (`stored_roster_owner_id` is no longer a current owner **or** `author > storedAuthor`)) — comparing the incoming blob's signer against `stored_roster_owner_id`, never the gate. Store the cross-device author with the blob in `setGroupLiveRoster` (`:3578`; `Store/Groups.hs:1716-1724`). A completed blob reverts privileged members absent from it (`processRosterEntries`, `Subscriber.hs:3610-3619`), so the losing owner's delta is reverted on relays until its retry. Applying any roster skips members who are currently owners (in `processRosterEntries`, `Subscriber.hs:3610-3630`, which today sets each listed member to its listed role, `:3601-3608`, `:3622-3628`): a member promoted to owner from a roster role stays in relays' stored blob, which a later joiner receives right after its owner intros (`Internal.hs:1330-1331`), and would otherwise be set back. **Relay setup under concurrent roster changes:** a roster ack below the owner's current version during relay setup re-sends the current roster to that relay (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`) instead of only warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`) — another owner's delta can advance the version between send and ack, and this owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would stay unpublished. **Owner-side ordering** (an owner's own blob goes to relays only, `broadcastRoster`, `Internal.hs:2649-2654`; a relay forwards nothing its gate drops, `Subscriber.hs:3423-3425`, `:1133-1144`, and can reorder one owner's events, `Store/Delivery.hs:178-202`): on an owner, a role or removal event — original, retry, passed on by a relay whose gate dropped it (Part 4), or a relay's catch-up (below) — carries a key: made-at version (`retryOf`'s, or the event's own) and author (`retryOf`'s, or the signer). The owner applies it, instead of through `applyAtRosterVersion`'s gate (`Subscriber.hs:3404-3446`), exactly when its key beats the member's stored key (`roster_key_version`/`roster_key_author`, or the `group_revoked_owners` row for a removed member): a removal beats a role change; an empty key loses only to an event whose wire version is above `roster_key_floor`; otherwise the higher made-at version, then the higher author. It then stores the key with the event's `prevKey` (a retry carries the original change's), raises its gate to the event's version if higher, clears `roster_key_resent`, and, when the beaten key was its own and differs from that `prevKey` (the replacing change was made without seeing it), surfaces "your role change to X was overridden". Re-delivered events are dropped by message id first (`saveGroupFwdRcvMsg`, `Internal.hs:2956-2959`), and an older event never beats the key that superseded it. **Re-send triggers**, coalesced — after the first, the owner waits a short quiet period with no further roster event, within a bound, since SMP delivers one message per queue per acknowledgement (pinned `Server.hs:2091-2092`) and a bulk change arrives over several messages — and, for triggers (1)–(3) and (6), skipping members whose current key it has already re-sent (`roster_key_resent`), so a bulk change costs one re-send per member however a relay splits or spaces it — safe because a relay forwards every channel event in the order it received it (Departures, below), so a later re-send never overtakes an earlier one; triggers (4), (5) and (7) report a value a relay or its members hold wrongly and always re-send it: (1) it applied another owner's event whose wire version is below its own gate — relays and members with a gate as high dropped it; (2) it applied another owner's event whose wire version is at or below `roster_blob_sent_version` — its own blob at that version may have won the relays' equal-version tie-break without the change (`Subscriber.hs:3573-3578`) and reverted it there (`:3614-3618`); (3) an event lost to a member's non-empty key with a different value, its wire version is at or above that key's made-at version, and the member has no `group_revoked_owners` row — devices may have applied the loser, while a replayed change below the change that superseded it triggers nothing, and a removed member needs no repair since every device ignores its role events; (4) a relay's `x.grp.roster.dropped`, received directly from a current relay member (never as a forward) and acted on at most once per relay per interval (later ones deferred, not discarded), names this owner's change at a version above the owner's gate whose `proof` is an owner-signed event or roster header at that version, verified against an owner key the owner holds — a departed owner's (its `group_revoked_owners.member_key`) only within the departed-owner margin above the owner's gate, 2^20 versions (`ChatConfig`) — each role-change call adds one version, so no remaining owner falls that far behind a departed one, while rate-limited notices one margin at a time never bring a gate near the `Int64` limit — so a relay cannot inflate the `Int64` gate (`Types.hs:2282`; changes are made at gate+1, `Internal.hs:2644`) and freeze every role change; the owner raises its gate to that version and re-sends that change; (5) a relay passes back a kept copy (below) whose key equals this owner's key for that member — the relay lost a change this owner holds; this owner may hold that very message, so a pass-back is compared by key before the message-id dedup (`Internal.hs:2956-2959`); it is acted on at most once per member and this owner's current key, at most once per relay per interval (deferred work kept as a set of members per relay), and re-sends that member only — a relay keeps a genuine owner-signed copy for every member, so a replayed pass-back must not re-send the roster; (6) it applied another owner's departure (Departed owners' changes, below) — adoption: every member whose key that owner authored, since it can no longer re-send its own changes and devices that learned of its departure before receiving them dropped them; when the skip leaves no member, the blob alone goes out at the version this owner holds; (7) a relay's kept copy of a departed owner's role event, received after this owner applied that departure (in the relay's departure report, passed back, or served as catch-up), verified against `group_revoked_owners.member_key` and made at or below that owner's `revokedOwners` `rosterVersion` plus the departed-owner margin of trigger (4), whose key differs from this owner's key for that member, for a member with no `group_revoked_owners` row — undo: some devices applied a role change of the departed owner that this owner does not hold; it is acted on at most once per member and this owner's current key, at most once per relay per interval (deferred work kept as a set of members per relay), re-sends that member only, and the copy's wire version never counts toward the highest version seen; if another remaining owner holds the change, that owner receives the re-send as a losing different value, re-sends the change under trigger (3), and this owner adopts it by key. The owner then re-sends, as deltas with `retryOf` = each member's stored key and `prevKey` = that key's stored `prevKey` (a role event with the member's key for TOFU, a removal with its kept `with_messages`), the current value of the triggering events' members and of every member whose key was made at a version at or above the lowest triggering made-at version, with its blob, at one above the highest version seen, records that version in `roster_blob_sent_version` and sets `roster_key_resent` on the members it re-sent. An event applied above both the gate and the last blob it sent, losing nothing, triggers nothing, so a change made after its owner saw the others never causes a re-send, and the exchange ends once every owner holds every change. Existing members converge from the re-sent deltas (`DJSGroup` → all members). **No blobs on owners:** an owner sends no `XGrpRosterRequest` on a gap — a change: today `requestRosterOnGap` (`Subscriber.hs:3438-3446`) skips only relays — and a roster blob served to an owner is not applied (it would revert members absent from it, `:3610-3619`, over the owner's keys). **Catch-up:** a relay keeps in `group_roster_latest_events` a copy of each owner's role or removal event (with a roster version) it applies, replacing that member's previous copy; at chat start an owner sends each relay `XGrpRosterRequest` carrying its `group_roster_catchup` version, and the relay answers an owner's request with the copies above it, forwarded as signed (then its departure notices, below); the owner applies them by key and records the highest version served. While an owner's connection on the relay is inactive (its queue at quota, `Subscriber.hs:1749-1762`) the relay drops every forward and direct send to it (`Subscriber.hs:4387`, `:4407`), that answer included; so when the connection resumes (`QCONT` and a resumed `SENT`, `Subscriber.hs:1291-1295`, `:1197-1199`, where a relay today calls `serveRoster` for the member ahead of the resumed backlog), a relay sends a current owner its catch-up instead — the copies above the version it last served that owner, then its departure notices. Both branches send to the member with the connection `continueSending` just reactivated (`m {activeConn = Just conn {quotaErrCounter = 0}}`): `continueSending` resets the counter only in the database (`Subscriber.hs:1765-1771`), so the event's `m` still holds the inactive connection that `readyMemberConn` rejects (`Internal.hs:2881`, `:2899-2901`) — today's blob serve there never leaves the relay — and a version counts as served to a member or owner only when `readyMemberConn` handed the send to the agent (today `serveRoster` records it after any attempt, `Internal.hs:1307-1313`, which also makes `xGrpRosterRequest` skip that member, `Subscriber.hs:3685`). An owner's own change that a relay dropped comes back as trigger (4), not through catch-up. When a blob that completes on a relay reverts a member (`:3614-3618`) whose kept copy is by another author than the blob's, the relay passes that copy, with its `broker_ts`, to every current owner: the blob's author applies it by key and re-sends under trigger (1) or (2), and an owner already holding that key re-sends it under trigger (5). **Departed owners' changes** (overview §4): every device ignores content from a member that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner keep changing roles through a single relay — and members and relays keep doing so; owners carry a leaver's in-flight changes instead. *Hold (leave only):* an owner that applies from link data (Reconcile, above) a `revokedOwners` entry with `leave` set, before processing that owner's `x.grp.leave`, stores as `roster_bound` the entry's `rosterVersion`, capped at its own gate plus the departed-owner margin of trigger (4) — the flag and the version are asserted by whoever wrote the entry, which during a removal's healable window can be the removed owner — and starts a hold with `hold_running_left` set to a fixed period (`ChatConfig`). During the hold, an `x.grp.mem.role`, or an `x.grp.mem.del` of a member, by that owner — forwarded, passed on, in a relay's departure report, passed back or served as catch-up — that carries a roster version, has no `retryOf`, was made at or below `roster_bound`, and names neither a current owner nor this device's membership, is verified against `group_revoked_owners.member_key` before the author lookup (`xGrpMsgForward`, `Subscriber.hs:4009-4027`, which would recreate an author that member cleanup deleted as keyless, `Store/Groups.hs:1230-1240`) and applied by key with owner authority taken from that removal record, without looking up or recreating the author's row; its forwarded `x.grp.leave` is verified the same way, only to end the hold; anything else from that author stays ignored. The hold ends at the first of: that `x.grp.leave`; an owner's `x.grp.mem.del` of that owner (a removal contradicts the leave flag); an `x.grp.owner.departed` for that owner from every relay that was current and connected to this owner when the hold started and has not since been removed or left (`group_departure_notices`, recorded whenever a notice arrives, also before a hold starts); `hold_running_left` reaching zero, decremented by the periodic cleanup (`cleanupUser`, `Commands.hs:5922-5933`) by the time elapsed since its last tick, and only for ticks during which this device received a message from at least one of the channel's current relays — the cleanup loop runs whenever the app is active (`Commands.hs:5895-5914`), with or without a network, and the device's own subscriptions stay up while a relay is down, so a hold never lapses while the device cannot hear from the relays. A relay forwards every channel event in the order it received it (Departures, below), so each of the leaver's changes reaches the owner, through that relay, before its `x.grp.leave`; a relay's departure report is a direct send and may arrive before that relay's forwards, but it carries the same changes as kept copies, ahead of its `x.grp.owner.departed`. An honest leaver's bound is its own gate (roster changes are refused once its leave record exists, Part 5), so a leaver colluding with a relay can, during the hold, make only a change it could have made before leaving — never a retry, never above the capped bound, never on an owner. A removal starts no hold: the remover cannot vouch for the removed owner's last changes. *Adoption and undo:* applying the departure — the end of the hold; the departure event, for an owner that learned of the departure from it; or, for the remover, its `removal_step` reaching `announced` — is trigger (6); from then on a copy of that owner's event is not applied: a differing role event is trigger (7), and a removal of a member that this owner has not removed is shown to it ("X was removed by on some devices") to finish with its own removal if it agrees — a removal record is permanent (Part 2) and a removed owner with a relay could fake one, so it is neither undone nor adopted. Every device then ends with one role per member: the departed owner's when some remaining owner holds it, else the remaining owners'. Absent everywhere: a change every relay lost — a relay that applies the revocation from link data (its periodic check, or the immediate read when an owner join or `x.grp.owner.add` names an owner it does not hold, Reconcile above) before processing a change already in its queue drops the owner's connection with it — a role change racing a removal that no remaining owner holds, and a leaver's changes still pending on its device for relays that stayed at quota past its wait (Part 5), never sent once its connections are deleted (`Commands.hs:3269`) and removed by the message cleanup (`Commands.hs:5967-5970`); a removal racing a removal is shown to the remaining owners as above. **Departures:** when an owner's leave or removal is applied, each remaining owner re-sends the members whose key that owner authored, with its blob (trigger (6)); when that owner authored no member's key, it re-sends its blob (no deltas) to relays at the version it holds — not a new version, which members would read as a gap (`Commands.hs:3015-3017`) — and the completion gate, at an equal version, accepts a blob whose stored predecessor's author is no longer a current owner; relays serve the stored blob under its author (`serveRoster`, `Internal.hs:1295-1315`), which a later joiner can't verify once that author has gone. A relay that applies the departure of its stored blob's author sends `XGrpRosterRequest` with its gate (the higher of its gate and stored version) and that gate's signed proof to each connected current owner, and an owner receiving it directly from a relay, with a proof verified as for trigger (4), raises its gate to that version if higher and re-sends its blob to that relay at its gate (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`; today `xGrpRosterRequest` acts only on relays, `Subscriber.hs:3680-3685`) — at its own lower version it would be refused, and joiners would keep getting a blob they cannot verify. A relay that applies an owner's departure — the event, the revocation in its periodic link check, or the revocation in its immediate link read (Reconcile, above) — passes its kept copies of that owner's events (`group_roster_latest_events` rows it authored), with their `broker_ts`, to every current owner, batched as it passes dropped events, and then `x.grp.owner.departed` naming that owner, its report that it applied the departure (Departed owners' changes, above); its answer to an owner's catch-up request likewise ends with `x.grp.owner.departed` for each departed owner whose copies it served. Applying a roster skips members with a `group_revoked_owners` row as well as current owners, so a departed owner listed in a stored blob under its former roster role is never restored to it. **Relays forward channel events in received order**, by two changes. First, in channels every `DJDeliveryJob` task gets the one job scope `DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}`, normalized where the relay creates delivery tasks (`createDeliveryTasks`); `DJRelayRemoved` tasks stay distinct, as their spec drives the relay's own removal (`Subscriber.hs:4241-4252`, `:4322-4329`). Second, the relay's packer `batchDeliveryTasks1` (`Messages/Batch.hs:84-104`) stops at the first task that does not fit the batch instead of accepting later, smaller ones, as `batchMessages` already does (`:65-68`); today the fold goes on despite its "stop adding further messages" comment (`:94-95`), the skipped task stays `DTSNew` and heads the next job, so a smaller later event overtakes it. Today `memberEventDeliveryScope` (`Delivery.hs:115-120`), and the `XGrpInfo`, `XGrpPrefs` and unknown-target removal tasks (`Subscriber.hs:3865`, `:3870`, `:3751`), give some events `includePending = True`, and a relay's batching takes the pending tasks of the oldest task's job scope (`getNextDeliveryTasks`, `Store/Delivery.hs:178-202`), so a later event can go out ahead of an earlier one of the other scope: a departure ahead of the departed owner's earlier events, which every device then ignores (`Subscriber.hs:4019-4020`), or another owner's later change ahead of part of a bulk change, which members then drop below their gate with no gap to show it (`Subscriber.hs:3414-3425`). Channel sends ignore `includePending` (`Subscriber.hs:4338-4340`; it is read only on the non-channel path, `:4457`), and one worker's jobs go out in id order (`Store/Delivery.hs:290-303`). No leader, no relay-to-owner blob forwarding. +- **Count** (overview §5): a relay recounts (`updatePublicMemberCount`, `Store/Groups.hs:2323-2340`) and sends `x.grp.relay.count` directly to owners whenever its subscriber count changes — from the relay branch of `updatePublicGroupData` (`Internal.hs:1628-1630`), which runs only on leave and removal (`Subscriber.hs:3773`, `:3820`), and from its subscriber-connected path (`Subscriber.hs:966-972`, beside `introduceInChannel`), which today never recounts; owners store `(count, ts)` per relay, with `ts` clamped to the broker timestamp of the message that carried it; the estimate is the **max** count over current relays, carrying **that max-holder's own `ts`**. Only the creator, while it is the sole live owner and no current relay supports `x.grp.relay.count`, uses its own tally with its own clock as `ts` (no other owner publishes then; promoting a second owner requires count-capable relays, Part 8); a promoted owner left alone never does — relays without the count event never sent it the subscribers who joined before its promotion (Part 4) — and keeps the published count. During reconcile (channel open, chat start) any owner publishes `(estimate, that ts)` iff the estimate differs from the published count **and** the max-holder's `ts` is newer than `subscriberCountTs` (a `subscriberCountTs` later than the owner's clock counts as stale). Every other write carries the published count fields unchanged, and the owner branch stops recounting its tally (`Internal.hs:1623`). The owner branch of `updatePublicGroupData` (`Internal.hs:1620-1627`) stops publishing for subscriber joins, leaves and removals — call sites `Subscriber.hs:3253`, `:3268`, `:3286`, `:3773`, `:3820`, `Commands.hs:3152` — and publishes only when the publishable relay set changed (a relay member removed or left). The `XGrpRelayNew` announcement threshold (`Subscriber.hs:1445-1449`, `publicMemberCount > 1`, which assumes one owner) uses the relay-sourced estimate. Clear a relay's `(count, ts)` on `GSMemRemoved`/`GSMemLeft` (`Types.hs:1430-1431`). +- **Owner materialisation** threads the full `OwnerAuth` (sig + append index): `createLinkOwnerMember` (`Store/Groups.hs:3575`; the key is `ownerKey`, as today), `updateRelayGroupKeys` (`Store/Groups.hs:2353`; create the owner row if missing — today it throws, `:2366-2368`). Joiners and relays skip entries listed in `revokedOwners` as owners and record them in `group_revoked_owners` (Part 2). Root-sign when the adder is the creator (holds the root private key), else delegate. A revoked entry is dropped from the written chain unless it is an ancestor, through any number of delegations, of a current entry, or it is the writer's own entry in its own leave write (Part 5) (every entry must be signed by root or an earlier one, `Agent/Protocol.hs:2012`; an orphan would make every owner's agent refuse every write); `revokedOwners` keeps its memberId, so the union merge cannot restore it (overview §7: the blob is fixed-size, each entry is about 124 bytes). - **`x.grp.owner.add`** handling, for members and relays: validate and apply as in Part 4. -- **Promoted owner commit:** on receiving `x.grp.owner.add` for itself, M sets its own role to owner and stores `link_rcv_id` (from the inv), which completes its `ForeignLinkKeys`; its agent can then issue foreign `LSETH`/`RKEYH`, signing with its member key. +- **Promoted owner commit:** M completes its promotion — sets its own role to owner and stores `link_rcv_id` from `promote_link_rcv_id`, which completes its `ForeignLinkKeys` — on receiving `x.grp.owner.add` for itself, or when a link-data refresh shows a validated, non-revoked chain entry with its memberId; in both cases only while its own `promote_status` is `accepted`, and only if the entry's `ownerKey` equals its own member key. A commit for M without an accepted record is ignored on M's device (other members still apply it) with a service item — otherwise any owner could make a subscriber's device connect directly to the link server; a key mismatch means a relay-substituted key was signed into the chain (overview §8): M does not complete and alerts. Its agent can then issue foreign `LSETH`/`RKEYH`, signing with its member key; owner actions to a relay wait for that relay's count reading (Part 4). Completing from link data, M sends `x.grp.owner.sync` to each relay, since the catch-up and readings sent on the commit may have been lost with it or expired in M's queue. +- **Departed owners' history:** a relay serves an as-channel history item whose signed author is no longer a current owner as an unsigned `FwdChannel` element (`itemForwardMsgs`, `Internal.hs:1450-1460`, `1540-1548`, attributes signed items to their author), since a joiner holds no key or owner role for that author and would drop it (`Subscriber.hs:2215-2218`). +- **Owner-only paths that assume the creator:** `APIShareChatMsgContent` signs a shared channel link for any current owner, with its member key and memberId, not only under `GRKPrivate` (`Commands.hs:1238-1240`); the connect plan's `verifyLinkOwner` and `glOwners` exclude `revokedOwners` (`Commands.hs:4615-4616`, `4648-4649`, `4806-4815`), since a revoked entry kept as a delegation ancestor would otherwise still verify and list as an owner; `XGrpRelayCap` is stored on the relay's member row (today on its `group_relays` row, `Subscriber.hs:1103-1105`, which a promoted owner has only for relays it added). - **Creator's root key is unrevocable:** "removing the creator" evicts its link-write key and marks its owner entry revoked, but it retains root signing authority (the immutable trust anchor in `FixedLinkData`); true creator eviction would need a root-key rotation, out of scope. --- @@ -156,6 +188,7 @@ The promotion and removal chains are async commands resumable from their durable ## Part 7 — apps (iOS + Kotlin, mirror both) - `GroupInfo`: decode **`canManageLink`** (new backend field); gate link/relay *management* on it (an owner mid-handover cannot write yet), keep relay *status display* on `isOwner`. Also decode a new backend field saying another live owner exists. +- Channel link screen: an owner without a `GroupLink` (a promoted owner) is shown the published `publicGroup.groupLink`, as non-owners are today, and the screen never creates a link for a channel — today both apps load an owner's link only through `apiGetGroupLink` and `GroupLinkView` creates one when it appears without it (`GroupChatInfoView.swift:303-312`, `GroupLinkView.swift:164-166`; `GroupChatInfoView.kt:179-181`, `GroupLinkView.kt:119-123`). Decode `groupLink` in `CRGroupRelaysAdded`/`CEvtGroupLinkDataUpdated` as optional (Part 6; `AppAPITypes.swift:1001`, `:1210`; `SimpleXAPI.kt:6815`, `:6846`). - `GroupMember`: decode `memberRoleProposal` + `promotionPending` (optional/nullable). - **New `RcvGroupEvent` case** for the promotion service item (both enums are exhaustive — a missing case throws) + strings. - `canChangeRoleTo` (`ChatTypes.swift:3382`, `ChatModel.kt:2944`): add `.owner` for channels; selecting it starts the async invite and shows the proposed-role row (proposed + Cancel / rejected + re-invite). Warn if the invitee's key is unverified and route to the verification screen (`verifyChannelMemberCode`, `Commands.hs:3826`, called at `:2074`, hashes both keys sorted) — advisory, matching the existing channel model; the consequence is the channel's trust chain, not one conversation. @@ -167,9 +200,9 @@ The promotion and removal chains are async commands resumable from their durable ## Part 8 — version gating and compatibility -- **Link server:** detect the link queue's negotiated server version (the relay-handshake `thVersion`); **require ≥ v24 to promote a second owner** (the hash-CAS `LSETH`/`RKEYH` all need it). On an older link server, block promotion with a clear error rather than degrade — a blind `RKEY` there evicts other owners (replace with no CAS). Because the negotiated version is the min of the two peers' relay ranges, both relay constants (Part 1.1) must reach 24 before a client perceives v24. -- **Relays:** a relay invited to a channel with two or more owners throws on the owner it has no row for (`updateRelayGroupKeys`, `Store/Groups.hs:2366-2368`, called from `Subscriber.hs:4604`), so adding a relay to such a channel fails. A relay that predates multi-owner also never forwards `x.grp.owner.add` (`isForwardedGroupMsg`, `Protocol.hs:528`, unknown events → `False`), keeps the promoted owner an observer and drops its posts (`memberCanSend`, `Subscriber.hs:1737-1738`), rejects that owner's join (Part 6), and sends no `x.grp.relay.count`. So the relay side — tolerant `updateRelayGroupKeys`, `x.grp.owner.add`, the owner join, `x.grp.relay.count` — ships in a release before promotion is enabled, and at runtime promotion requires every current relay to support the multi-owner chat version (owners already check a relay's version, `supportsVersion`, `Subscriber.hs:933`), else it is refused with a clear error. Adding a relay to a channel with more than one owner cannot be checked up front — a relay's version is known only once it answers — so it fails closed: an older relay throws before answering (`Subscriber.hs:4604`, its error recorded only on the relay, `:4565-4567`), and the owner times out the unanswered invitation and shows an error — checked by the same `cleanupUser` step as the promotion TTLs (Part 5), since today nothing on the owner's side expires an `RSInvited` relay. A sole owner whose relays predate the count event falls back to its own tally (Part 6). -- **Older members** ignore `x.grp.owner.add`: they keep rejecting the new owner's admin messages until they upgrade — failing closed rather than trusting a relay-asserted key. **Older joiners** take every chain entry as an owner, including revoked ones; a revoked owner's key is gone and relays have dropped its connection, so its messages do not reach them. +- **Link server:** detect the link queue's negotiated server version (the relay-handshake `thVersion`, through the agent read in Part 1.2); **require ≥ v24 to promote a second owner** (the hash-CAS `LSETH`/`RKEYH` all need it). On an older link server, block promotion with a clear error before sending `inv` rather than degrade — a blind `RKEY` there evicts other owners (replace with no CAS). A sole live owner on an older link server keeps writing with today's blind `LSET` when the CAS call fails with `TEVersion`; with two or more live owners (the server was ≥ v24 at promotion and has since been downgraded) writes, removal and leave fail closed and are surfaced. Because the negotiated version is the min of the two peers' relay ranges, both relay constants (Part 1.1) must reach 24 before a client perceives v24. +- **Relays:** a relay invited to a channel with two or more owners throws on the owner it has no row for (`updateRelayGroupKeys`, `Store/Groups.hs:2366-2368`, called from `Subscriber.hs:4604`), so adding a relay to such a channel fails. A relay that predates multi-owner also never forwards `x.grp.owner.add` (`isForwardedGroupMsg`, `Protocol.hs:528`, unknown events → `False`), keeps the promoted owner an observer and drops its posts (`memberCanSend`, `Subscriber.hs:1737-1738`), rejects that owner's join (Part 6), and sends no `x.grp.relay.count`. So the relay side — tolerant `updateRelayGroupKeys`, `x.grp.owner.add`, the owner join, `x.grp.relay.count` — ships in a release before promotion is enabled, and at runtime promotion requires every current relay to support `multiOwnerVersion` (Part 4; owners already check a relay's version, `supportsVersion`, `Subscriber.hs:933`), else it is refused with a clear error. Adding a relay to a channel with more than one owner cannot be checked up front — a relay's version is known only once it answers — so it fails closed: an older relay throws before answering (`Subscriber.hs:4604`, its error recorded only on the relay, `:4565-4567`), and the owner times out the unanswered invitation and shows an error — checked by the same `cleanupUser` step as the promotion TTLs (Part 5), since today nothing on the owner's side expires an `RSInvited` relay. A creator that is the sole owner and whose relays predate the count event falls back to its own tally (Part 6). +- **Older members** ignore `x.grp.owner.add`: they keep rejecting the new owner's admin messages until they upgrade — failing closed rather than trusting a relay-asserted key. Its unsigned posts as the channel reach them (relays forward those as `FwdChannel`, `Store/Delivery.hs:159`); its signed ones are forwarded under its identity and dropped like its admin messages, and the profile still reaches them through link-data refresh. **Older joiners** take every chain entry as an owner, including revoked ones; a revoked owner's key is gone and relays have dropped its connection, so its messages do not reach them. --- @@ -180,11 +213,20 @@ Under `describe "channels"` (`tests/ChatTests/Groups.hs`). Named profiles run ou - owner-2 adds owner-3 (three owners): the delegated entry validates in insertion order; - the creator's first promotion rotates its link-queue key; after removing the creator, a new joiner still resolves the link; - concurrent link writes by two owners (hash-CAS: the loser re-merges onto the returned blob, both survive); an owner that missed an announcement does not adopt the next one's hash; -- concurrent roster edits — different members (both converge after the retry) and same member (tie-break winner, loser surfaces conflict); +- concurrent roster edits — different members (relays' served blob and every member end with both), same member (tie-break winner on every member, loser surfaces conflict), removal vs role change of one member (removal wins); O3 promotes X to admin and O1 later demotes X (no re-send, X stays demoted); each of two owners makes two changes before seeing the other's, through two relays each delayed so that it drops one owner's first change (all four reach every member, both relays and the served blob); one owner makes two or three changes against the other's one through a single relay, including where the one is a removal of a moderator (the removal reaches the relay and every member); two and three owners make one change each from one version (every relay's stored blob and member roles hold all of them); an owner demotes X while another, which promoted X earlier, promotes Y at the same version (X stays demoted on relays and joiners); a bulk change arriving below another owner's gate in one message causes one re-send; a relay replays an owner's role event superseded by a later change, and one predating a promoted owner's promotion (no role changes, nothing re-sent); a relay sends an owner `x.grp.roster.dropped` or `XGrpRosterRequest` with a huge version and no valid proof, or a departed owner's proof beyond the margin (no gate changes, nothing re-sent); with three owners, a departed owner's change one remaining owner applied and the other's blob reverted on a relay (the holder re-sends it, and the relay ends with it); an owner away past message expiry makes two changes while another owner makes one (every relay, joiner and member ends with all three); an owner whose gate is stale makes a change that every relay drops, with no other owner left (it re-sends on the relays' `x.grp.roster.dropped` and the change reaches every relay and member); an owner is removed while its own role change is in flight (each relay ends serving a blob of a current owner that joiners verify); a bulk change of N members, dropped by a relay and passed on over several messages, makes each other owner re-send each member once; the creator demotes a moderator, promotes an admin and leaves, with a pending moderator-scope delivery on the relay, and the remaining owner reconciling only after the relay has delivered (the remaining owner and every member apply both changes); the same with the remaining owner, and separately a subscriber, reconciling the link data before the relay delivers the changes (the remaining owner applies both during its hold and adopts them when the hold ends; every member, both relays and a later joiner end with both); the same with the remaining owner going offline right after reconciling, for longer than the hold period, and separately offline past message expiry (it still applies both and adopts them); the same after member cleanup has deleted the creator's row on the remaining owner (the held events verify against the removal record's key); an owner removed while its role change is in flight through one relay and dropped by the other, with two and with three owners (every member and relay ends with one value: the change if a remaining owner applied it, else the remaining owners' value); an owner removed while its removal of a member is in flight (the remaining owner is shown it, and its own removal finishes it); a leaver, through a relay that has not applied its leave, sends during an owner's hold a role change above its published bound, a retry with a huge version, and a removal of an owner and of the holder (none is applied, and no gate moves); a removed owner sends role changes within its old bound (no owner holds them); replayed and duplicate departure copies, one from each relay (each member is re-sent at most once); three remaining owners adopting at once (nothing more is re-sent); owner A changes a moderator and a subscriber in one call while owner C's later change reaches the relay before the relay forwards A's, including where A's larger event does not fit the relay's batch and C's smaller one would (every member applies all three); a relay's departure report reaching the holder before that relay's forwards (the holder applies the reported changes); the holder's app running without a network past the hold period (the hold does not end); a relay replaying a pass-back, and a departed owner signing fresh copies for one member (each member is re-sent at most once per key); the removed owner rewriting its own `revokedOwners` entry as a leave with a huge version before the remover drops its key (the holder caps the bound, and the remover's `x.grp.mem.del` ends the hold); the only relay's connection at quota while the creator changes two roles and leaves (the changes are sent when it recovers, before the revocation); a promoted owner with no delegated descendants leaves while the creator stays (its revocation write keeps its own entry and is accepted); the same-member winner had made a later change before seeing the conflict; an owner's role change made while it was offline survives the other owner's later changes, including after a chat-start roster request; +- a creator that made A an admin leaves (and, separately, is removed): a new joiner verifies the stored roster and sees A as admin, and receives the creator's as-channel history posts; +- an owner removes relay R2: its own write drops R2, for the creator and for a promoted owner; a relay that leaves on its own is dropped by a promoted owner's next write; the owner then adds the same chat relay again; - a relay removed by one owner stays removed after another owner's write; a promoted owner's first write keeps every relay, and a relay O1 added survives O2's profile edit; - O2 adds relay R3; O1 joins R3 as an owner; a subscriber connected only to R3 receives O1's post; - promote admin A to owner; a later joiner and another owner both still see A as owner after the stored roster is served; - a relay re-delivers an old `x.grp.owner.add` for a removed owner: a member who joined after the removal ignores it, and so does a member present at the removal after `cleanupRemovedMembers` has deleted the removed owner's row; a removed owner sending `x.grp.owner.add` for itself is ignored; +- a removal of M delivered before its `x.grp.owner.add` (forced interleaving): a subscriber that did not know M, and a relay, both end with M removed and refuse M's owner join; when the removal and the promotion both landed, an owner's reconcile revokes M; +- M promotes N, a subscriber that joined before M's promotion and never posted (M learned N from the relays on the commit), then M leaves: N alone runs the channel; +- an owner commits a subscriber that never accepted: that subscriber's device does not become an owner and makes no link-server connection; +- the promoter is stopped after its `LSETH`, before sending `x.grp.owner.add`, and never restarted: M completes its promotion on its next channel open, can write the link, removes a subscriber who joined before the promotion, and its post reaches every subscriber; likewise when the commit expired while M was offline; +- mutual removal (O1 and O2 remove each other concurrently) and the last two owners leaving at once: exactly one revocation lands, and a recognized owner with a key remains; +- an owner removed by a remover stopped before announcing, whose device never returns: relays drop the removed owner after their periodic link check, and its `x.grp.del` is ignored; +- a sole creator on a pre-v24 link server still edits the profile and relays (blind `LSET`); - promoting a memberId in `revokedOwners` is refused before any `RKEYH`; - delegation chain O2 → O3 → O4: revoke O3, then O2 — both stay in the chain while O4 is current, and link writes keep working; - after the creator is removed, O2 deletes the channel and the link no longer resolves; a removed owner deleting its chat leaves the link resolving;