diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index 2e3873744c..b7955b913d 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -33,7 +33,7 @@ Link data and keys already *have* a sequencer — the server — so they need on - **Relays:** the published set is authoritative, except this owner's own in-progress relay changes and the relays it knows are gone. A relay it is adding and has not published yet lives in its relay row; a relay it removed, or that left, lives in its relay member row with status removed or left — in channels a relay's removal keeps that row, as a relay's own leave already does (`updateMemberRecordDeleted`, `Internal.hs:2218-2230`), instead of deleting it together with its relay row before the write (`deleteOrUpdateMemberRecordIO` and `fullyDeleteMemberRecordIO`, `Internal.hs:2192-2216`; `group_relays` cascades, `chat_schema.sql:839`), which would leave nothing to subtract — and adding a relay checks for duplicates only among current relays (`APIAddGroupRelays`, `Commands.hs:2782-2789`), so a removed relay can be added again. Each owner caches the last published relay list it knows — a promoted owner reads the link data to fill it before its first write — and a write's relay list is that cache plus its own in-progress additions minus every relay whose member row on this device is removed or left; a write with an empty relay list is refused unless the owner is removing the last relay — today the written list comes only from the writer's own `group_relays` rows (`Store/Groups.hs:1528-1545`), which reference its own chat relays (`chat_schema.sql:840`), so a promoted owner would publish no relays at all. A relay another owner removed is therefore not resurrected by this owner's next write (a write on a stale cache is rejected by the CAS and rebuilt from the returned blob), and a relay another owner added is not dropped. - **Profile and preferences** (whole values): the published value is adopted. An owner's own profile edit is synchronous and write-then-commit: it keeps the profile it started from in memory, writes, and commits locally only on an accepted `LSETH`. On a rejection it merges field by field against the profile it started from — a field only it changed keeps its value, a field only the other owner changed takes theirs, and a field both changed differently is a conflict, surfaced ("profile edit superseded") with the published value kept — then retries. Today `runUpdateGroupProfile` commits locally (`Commands.hs:4172-4174`) before the link write (`:4190`); the order is reversed. -Because local state thus stays "published + my in-progress changes" (an owner I am adding, relays I am adding or removing), `groupLinkData` builds the write from local state — no separate delta step. Reconcile runs in four places, all the same code: **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — the owner exclusion is removed, in core at `Commands.hs:1968` and in the apps — with an owner relay step in place of `syncSubscriberRelays`, which would delete this owner's not-yet-published relays and join other owners' relays as a subscriber; the owner step deactivates relays absent from the published data that this owner is not adding, and connects to relays other owners added by joining through the relay's link as itself — which needs a relay change: today a relay accepts a join onto an existing member only for roster roles (`memberJoinRequestViaRelay`, `Subscriber.hs:1696-1706`), so an owner falls through to a fresh member insert; the relay instead accepts a join from a current, non-revoked owner that is signed with that owner's key, with the same `viaRelay` replay check, onto the existing owner row with role owner; on the owner's side, completing that join updates only the relay member — the subscriber path it reuses would overwrite the owner's group profile with the relay's copy and, for the creator, its membership role and status (`updatePreparedUserAndHostMembers'`, `Store/Groups.hs:821-844`), and its connection handler would mark the owner's own membership connected and create the joined-channel items (`Subscriber.hs:892-922`)), **when the owner's chat starts** (so owners that never open the channel view — CLI, bots — also heal revocations and publish the count), **on a write rejection** (adopt the returned blob, then rebuild), and as the missed-event backstop. The `owners` list keeps **insertion (signer) order** — a new entry is appended after the entry that signed it, so `validateLinkOwners` ("signed by root or an earlier entry") holds as-is, with no ordering change; the union merge dedups by memberId. (The key-set hash in §3 *is* recomputed by every owner and so genuinely needs a canonical sort; the owners blob is hashed as ciphertext and never recomputed, so it does not.) `revokedOwners` is a list in the mutable data, append-only (the remover writes it on removal, the leaver on leave, any owner corrects an inconsistency it sees on reconcile); each entry is a `memberId` with the roster version its writer held and whether it is a leave, which bound a leaver's in-flight roster changes (§4) — both asserted by the entry's writer, so a reader caps the version (§4) — and the merge keeps the first published entry for a `memberId`. 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 entry this device saw for a `memberId`. The count comes from relays (§5). **Relay activation keys off the agent's `LINK` event** — or, for a promoted owner's foreign write, which produces none, off its `OK` (§9) — and `LINK` fires only after the server accepted the write (`Agent.hs:2255-2258`); under CAS it fires after the accepted `LSETH` and carries the data actually written, after any re-merge. A relay that another owner removed never produces a `LINK` on this device (the TODO at `Subscriber.hs:1475-1477`); reconcile deactivates it. @@ -130,7 +130,7 @@ 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. -- **A removed owner can forge a leave while its key is live.** Between its remover's revocation and key drop — indefinitely if the remover stops there for good — it can rewrite its own `revokedOwners` entry as a leave, and with a colluding relay have changes to non-owner members (roles, and removals with their messages) held by an owner that reads that entry first and re-sent under that owner's signature when the hold ends (§4). A holder cannot tell this from a real leave, both being written with the leaver's key; the hold's version cap keeps it from raising any gate, its period bounds the window, and the remover's announcement ends it. Accepted as a residual of an interrupted removal. +- **A removed owner can forge a leave while its key is live.** Between its remover's revocation and the key drop — by the remover, or by the first other owner's reconcile (§3) — it can rewrite its own `revokedOwners` entry as a leave, and with a colluding relay have changes to non-owner members (roles, and removals with their messages) held by an owner that reads that entry first and re-sent under that owner's signature when the hold ends (§4). A holder cannot tell this from a real leave, both being written with the leaver's key, and a remover that never announces leaves the forged entry published, so each owner that reads it first holds in turn. The hold's version cap keeps any gate it raises within the departed-owner margin (§4), each holder's hold period bounds its window, and the remover's announcement ends it. Accepted as a residual of an interrupted removal. - **Link writes are attributable to owners, and a promoted owner's network address is visible to the link server.** Any link holder can tell which owner signed each version of the link data (§3). Recipient commands are not proxied — the proxy forwards only `SEND`, `SKEY`, `LKEY`, `LGET` and `RSLV` (`Server.hs:2205-2211`) — so a promoted owner's device connects directly to the link server the creator chose, and that server's operator, holding the public link, can tie the owner's network address to its channel identity. The invitee's confirmation says so (§10), and a commit makes M an owner on its own device only after M accepted (§6) — otherwise any owner could make a subscriber's device connect directly to the link server; Tor or SOCKS avoids the exposure. Foreign-link commands use a transport session of their own, one per link (the agent shares a session per user and server by default, `mkTSession`, `Agent/Client.hs:1442-1443`), so the link server cannot also tie them to the owner's other queues on that server. - **The creator is identifiable among owners**, contrary to the overview's claim that root-signing several owners conceals it (`channels-overview.md:159`): its chain entry is first, the link data it writes is signed with the root key (`Agent.hs:1076`), and every owner it promotes appears root-signed in a promotion it announces. Ordering the chain by memberId would not hide it while the creator root-signs the owners it adds (§6). Qualify the overview. diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index 81438eb453..a36af217df 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -53,7 +53,7 @@ One migration `M20260721_channel_multi_owner` (register in `Migrations.hs` + `.c | `roster_key_author BLOB` | ″ | that change's author `memberId` (with the version, the member's roster key; a removal's key lives on its `group_revoked_owners` row) | | `roster_key_prev_version INTEGER`, `roster_key_prev_author BLOB` | ″ | that change's own `prevKey`, which a retry of it carries (Part 6) | | `roster_key_resent INTEGER` | ″ | whether this owner has already re-sent the current key; cleared when the key changes (Part 6) | -| `roster_key_reported INTEGER` | ″ | whether a relay-reported trigger, (5) or (7), has already re-sent the current key; separate from `roster_key_resent`, which (1)–(3) and (6) skip on; cleared when the key changes (Part 6) | +| `roster_key_reported INTEGER` | ″ | one bit per relay-reported trigger: whether (5), and whether (7), has already re-sent the current key — separate bits, so one trigger's re-send never uses up the other's; separate from `roster_key_resent`, which (1)–(3) and (6) skip on; cleared when the key changes (Part 6) | | `relay_subscriber_count INTEGER` | relays | latest absolute count that relay reported | | `relay_subscriber_count_ts TEXT` | relays | the relay's own timestamp for that reading (the freshness token; cleared with the count on `GSMemRemoved`/`GSMemLeft`) | | `promote_invitation_id BLOB` | the promotee (invitee row on O1; own membership on M) | pending promotion | @@ -87,12 +87,13 @@ Do **not** add a `roster_version_owner_id` on the gate `roster_version` — the `(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. +`(group_id, member_id, member_key, key_drop_enforced, removal_step, roster_key_version, roster_key_author, roster_key_prev_version, roster_key_prev_author, with_messages, revoked_version, revoked_leave, roster_bound, hold_running_left)` — on every member's and relay's device, one row per removed memberId: every revoked owner, and every member removed by an applied `x.grp.mem.del` or by an owner-signed one whose target this device does not know (a removal can arrive before the promotion it revokes, through a different relay — overview §6). It lives outside `group_members` because member cleanup deletes a removed channel member's row a day after removal when it has no chat items (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2192-2203`; `cleanupRemovedMembers`, `Commands.hs:5960-5966`; `getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`). Rows come from `revokedOwners` at join and at relay invite, from link-data refresh, and from removal and owner leave events; they are never deleted. - `member_id` — the replay record: `x.grp.owner.add` and `x.grp.mem.role` for it are ignored, a relay refuses any join onto it, and promotion refuses it (Parts 4–6). - `member_key` — the key the heal drops (`NULL` when this device never knew it). - `key_drop_enforced` — false when the revocation was first seen in the link data and the key drop is not yet confirmed; true once confirmed, or for a row from an event (a removal or leave event is sent after the key drop). Drives the heal (Part 6). - `removal_step` — for a removal or leave this device runs: revoking / revoked / key_dropped / announced. The row is written with `revoking` before the first `LSETH`, so a crash at any point is re-driven at chat start through the announcement (Part 5). - `roster_key_version`, `roster_key_author`, `with_messages` — on an owner's device, the key of a roster removal (made-at version and author) and its `withMessages` flag, kept here because the removed member's row is cleaned up; a removal beats any role change of that member, and a re-sent removal carries the flag (Part 6). A removal that carries no roster version — every removal in a call that removes no roster member (`Commands.hs:3136-3139`; a call that removes one stamps its version on every removal it sends, `:3196`) — has no key and is never re-sent; its record makes it stick on every device that saw it. A joiner records only `revokedOwners`, so if such a removal of S races another owner's promotion of S, a later joiner sees S at the promoted role from the relay's stored blob until the promoter's next roster change (cosmetic: S's connections are gone). +- `revoked_version`, `revoked_leave` — for a revoked owner, its `revokedOwners` entry's `rosterVersion` and `leave` as this device first saw or wrote them, republished unchanged in its link writes (`NULL` for a removal this device knows only from an event). - `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) @@ -175,7 +176,7 @@ The promotion, removal and leave chains are async commands resumable from their - **Profile edit** (`runUpdateGroupProfile`, `Commands.hs:4164-4204`) becomes write-then-commit: today it commits the profile locally (`:4172-4174`) before the synchronous link write (`:4190`). Keep the starting profile in memory; on a conflict, merge field by field against it — a field only this owner changed keeps its value, a field only the other owner changed takes theirs, a field both changed differently keeps the published value and is surfaced as "profile edit superseded" — then retry. - **Removal heal** (runs during reconcile, and it is what makes the revoke-first ordering crash-safe): for each `group_revoked_owners` row with `key_drop_enforced` false and a known `member_key`, issue an `RKEYH` whose expected set is the believed set plus that key and whose new set is the believed set; on a conflict, drop the key from the returned set and retry while it is still there; then set `key_drop_enforced`. Merging `revokedOwners` into local state alone does *not* evict a still-live key; this explicit `RKEYH` is required. An owner that receives the removal or leave event sets `key_drop_enforced` directly — the event is sent only after the key drop — so only a revocation first seen in the link data (the crash case) costs a direct connection to the link server. A removal this device runs and that crashed is resumed from its `removal_step` at chat start, through the announcement. - **Relay activation** stays on the `LINK` event (and, for a foreign write, its `OK`, through the extracted function above), which the agent emits only after the server accepted the write (`Agent.hs:2255-2258`); under CAS it follows the accepted `LSETH` and carries the data written after any re-merge. Relays another owner removed produce no `LINK` here (the TODO at `Subscriber.hs:1475-1477`); the owner relay step deactivates them. -- **Roster** (overview §4): the event gate stays **version-only, author-blind** (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`), so concurrent *different-member* deltas both apply. The `(version, authorMemberId)` tie-break is added **only at the completion gate** (`Subscriber.hs:3572-3578`): keep the existing downgrade reject (`pendingVer < roster_version` gate) and, at the equal-version case that today overwrites by arrival order, accept iff `pendingVer > stored_roster_version` **or** (`pendingVer == stored_roster_version` **and** (`stored_roster_owner_id` is no longer a current owner **or** `author > storedAuthor`)) — comparing the incoming blob's signer against `stored_roster_owner_id`, never the gate. Store the cross-device author with the blob in `setGroupLiveRoster` (`:3578`; `Store/Groups.hs:1716-1724`). A completed blob reverts privileged members absent from it (`processRosterEntries`, `Subscriber.hs:3610-3619`), so the losing owner's delta is reverted on relays until its retry. Applying any roster skips members who are currently owners (in `processRosterEntries`, `Subscriber.hs:3610-3630`, which today sets each listed member to its listed role, `:3601-3608`, `:3622-3628`): a member promoted to owner from a roster role stays in relays' stored blob, which a later joiner receives right after its owner intros (`Internal.hs:1330-1331`), and would otherwise be set back. **Relay setup under concurrent roster changes:** a roster ack below the owner's current version during relay setup re-sends the current roster to that relay (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`) instead of only warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`) — another owner's delta can advance the version between send and ack, and this owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would stay unpublished. **Owner-side ordering** (an owner's own blob goes to relays only, `broadcastRoster`, `Internal.hs:2649-2654`; a relay forwards nothing its gate drops, `Subscriber.hs:3423-3425`, `:1133-1144`, and can reorder one owner's events, `Store/Delivery.hs:178-202`): on an owner, a role or removal event — original, retry, passed on by a relay whose gate dropped it (Part 4), or a relay's catch-up (below) — carries a key: made-at version (`retryOf`'s, or the event's own) and author (`retryOf`'s, or the signer). The owner applies it, instead of through `applyAtRosterVersion`'s gate (`Subscriber.hs:3404-3446`), exactly when its key beats the member's stored key (`roster_key_version`/`roster_key_author`, or the `group_revoked_owners` row for a removed member): a removal beats a role change; an empty key loses only to an event whose wire version is above `roster_key_floor`; otherwise the higher made-at version, then the higher author. It then stores the key with the event's `prevKey` (a retry carries the original change's), raises its gate to the event's version if higher, clears `roster_key_resent`, and, 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 (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), and re-sends that member only — a relay keeps a genuine owner-signed copy for every member, so a replayed pass-back must not re-send the roster; (6) it applied another owner's departure (Departed owners' changes, below) — adoption: every member whose key that owner authored, since it can no longer re-send its own changes and devices that learned of its departure before receiving them dropped them; when the skip leaves no member, the blob alone goes out at the version this owner holds; (7) a relay's kept copy of a departed owner's role event, received after this owner applied that departure (in the relay's departure report, passed back, or served as catch-up), verified against `group_revoked_owners.member_key` and made at or below that owner's `revokedOwners` `rosterVersion` plus the departed-owner margin of trigger (4), whose key differs from this owner's key for that member, for a member with no `group_revoked_owners` row — undo: some devices applied a role change of the departed owner that this owner does not hold; it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), re-sends that member only, and the copy's wire version never counts toward the highest version seen; if another remaining owner holds the change, that owner receives the re-send as a losing different value, re-sends the change under trigger (3), and this owner adopts it by key. The owner then re-sends, as deltas with `retryOf` = each member's stored key and `prevKey` = that key's stored `prevKey` (a role event with the member's key for TOFU, a removal with its kept `with_messages`), the current value of the triggering events' members and of every member whose key was made at a version at or above the lowest triggering made-at version, with its blob, at one above the highest version seen, records that version in `roster_blob_sent_version` and sets `roster_key_resent` on the members it re-sent. An event applied above both the gate and the last blob it sent, losing nothing, triggers nothing, so a change made after its owner saw the others never causes a re-send, and the exchange ends once every owner holds every change. Existing members converge from the re-sent deltas (`DJSGroup` → all members). **No blobs on owners:** an owner sends no `XGrpRosterRequest` on a gap — a change: today `requestRosterOnGap` (`Subscriber.hs:3438-3446`) skips only relays — and a roster blob served to an owner is not applied (it would revert members absent from it, `:3610-3619`, over the owner's keys). **Catch-up:** a relay keeps in `group_roster_latest_events` a copy of each owner's role or removal event (with a roster version) it applies, replacing that member's previous copy; at chat start an owner sends each relay `XGrpRosterRequest` carrying its `group_roster_catchup` version, and the relay answers an owner's request with the copies above it, forwarded as signed (then its departure notices, below); the owner applies them by key and records the highest version served. While an owner's connection on the relay is inactive (its queue at quota, `Subscriber.hs:1749-1762`) the relay drops every forward and direct send to it (`Subscriber.hs:4387`, `:4407`), that answer included; so when the connection resumes (`QCONT` and a resumed `SENT`, `Subscriber.hs:1291-1295`, `:1197-1199`, where a relay today calls `serveRoster` for the member ahead of the resumed backlog), a relay sends a current owner its catch-up instead — the copies above the version it last served that owner, then its departure notices. Both branches send to the member with the connection `continueSending` just reactivated (`m {activeConn = Just conn {quotaErrCounter = 0}}`): `continueSending` resets the counter only in the database (`Subscriber.hs:1765-1771`), so the event's `m` still holds the inactive connection that `readyMemberConn` rejects (`Internal.hs:2881`, `:2899-2901`) — today's blob serve there never leaves the relay — and a version counts as served to a member or owner only when `readyMemberConn` handed the send to the agent (today `serveRoster` records it after any attempt, `Internal.hs:1307-1313`, which also makes `xGrpRosterRequest` skip that member, `Subscriber.hs:3685`). An owner's own change that a relay dropped comes back as trigger (4), not through catch-up. When a blob that completes on a relay reverts a member (`:3614-3618`) whose kept copy is by another author than the blob's, the relay passes that copy, with its `broker_ts`, to every current owner: the blob's author applies it by key and re-sends under trigger (1) or (2), and an owner already holding that key re-sends it under trigger (5). **Departed owners' changes** (overview §4): every device ignores content from a member that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner keep changing roles through a single relay — and members and relays keep doing so; owners carry a leaver's in-flight changes instead. *Hold (leave only):* an owner that applies from link data (Reconcile, above) a `revokedOwners` entry with `leave` set, before processing that owner's `x.grp.leave`, stores as `roster_bound` the entry's `rosterVersion`, capped at its own gate plus the departed-owner margin of trigger (4) — the flag and the version are asserted by whoever wrote the entry, which during a removal's healable window can be the removed owner — and starts a hold with `hold_running_left` set to a fixed period of counted time (`ChatConfig`, 2 hours: above draining a full queue plus a relay's resume, about an hour, and short because it also bounds a forged leave, below). During the hold, an `x.grp.mem.role`, or an `x.grp.mem.del` of a member, by that owner — forwarded, passed on, in a relay's departure report, passed back or served as catch-up — that carries a roster version, has no `retryOf`, was made at or below `roster_bound`, and names neither a current owner nor this device's membership, is verified against `group_revoked_owners.member_key` before the author lookup (`xGrpMsgForward`, `Subscriber.hs:4009-4027`, which would recreate an author that member cleanup deleted as keyless, `Store/Groups.hs:1230-1240`) and applied by key with owner authority taken from that removal record, without looking up or recreating the author's row; its forwarded `x.grp.leave` is verified the same way, only to end the hold; anything else from that author stays ignored. The hold ends at the first of: that `x.grp.leave`; an owner's `x.grp.mem.del` of that owner (a removal contradicts the leave flag); an `x.grp.owner.departed` for that owner from every relay that was current and connected to this owner when the hold started and has not since been removed or left (`group_departure_notices`, recorded whenever a notice arrives, also before a hold starts); `hold_running_left` reaching zero, decremented by the periodic cleanup (`cleanupUser`, `Commands.hs:5922-5933`) 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. A removed owner that rewrites its own entry as a leave before its key is dropped can, with a relay, have changes to non-owner members held by an owner that reads that entry first, and adopted when the hold ends — until its remover's announcement, or, if the remover never announces, until its own `x.grp.leave` or the hold period; a holder cannot tell it from a real leave, both being written with the leaver's key (overview §8). *Adoption and undo:* applying the departure — the end of the hold; the departure event, for an owner that learned of the departure from it; or, for the remover, its `removal_step` reaching `announced` — is trigger (6); from then on a copy of that owner's event is not applied: a differing role event is trigger (7), and a removal of a member that this owner has not removed is shown to it ("X was removed by on some devices") to finish with its own removal if it agrees — a removal record is permanent (Part 2) and a removed owner with a relay could fake one, so it is neither undone nor adopted. Every device then ends with one role per member: the departed owner's when some remaining owner holds it, else the remaining owners'. Absent everywhere: a change every relay lost — a relay that applies the revocation from link data (its periodic check, or the immediate read when an owner join or `x.grp.owner.add` names an owner it does not hold, Reconcile above) before processing a change already in its queue drops the owner's connection with it — a role change racing a removal that no remaining owner holds, and a leaver's changes still pending on its device for relays that stayed at quota past its wait (Part 5), never sent once its connections are deleted (`Commands.hs:3269`) and removed by the message cleanup (`Commands.hs:5967-5970`); a removal racing a removal is shown to the remaining owners as above. **Departures:** when an owner's leave or removal is applied, each remaining owner re-sends the members whose key that owner authored, with its blob (trigger (6)); when that owner authored no member's key, it re-sends its blob (no deltas) to relays at the version it holds — not a new version, which members would read as a gap (`Commands.hs:3015-3017`) — and the completion gate, at an equal version, accepts a blob whose stored predecessor's author is no longer a current owner; relays serve the stored blob under its author (`serveRoster`, `Internal.hs:1295-1315`), which a later joiner can't verify once that author has gone. A relay that applies the departure of its stored blob's author sends `XGrpRosterRequest` with its gate (the higher of its gate and stored version) and that gate's signed proof to each connected current owner, and an owner receiving it directly from a relay, with a proof verified as for trigger (4), raises its gate to that version if higher and re-sends its blob to that relay at its gate (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`; today `xGrpRosterRequest` acts only on relays, `Subscriber.hs:3680-3685`) — at its own lower version it would be refused, and joiners would keep getting a blob they cannot verify. A relay that applies an owner's departure — the event, the revocation in its periodic link check, or the revocation in its immediate link read (Reconcile, above) — passes its kept copies of that owner's events (`group_roster_latest_events` rows it authored), with their `broker_ts`, to every current owner, batched as it passes dropped events, and then `x.grp.owner.departed` naming that owner, its report that it applied the departure (Departed owners' changes, above); its answer to an owner's catch-up request likewise ends with `x.grp.owner.departed` for each departed owner whose copies it served. Applying a roster skips members with a `group_revoked_owners` row as well as current owners, so a departed owner listed in a stored blob under its former roster role is never restored to it. **Relays forward 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. +- **Roster** (overview §4): the event gate stays **version-only, author-blind** (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`), so concurrent *different-member* deltas both apply. The `(version, authorMemberId)` tie-break is added **only at the completion gate** (`Subscriber.hs:3572-3578`): keep the existing downgrade reject (`pendingVer < roster_version` gate) and, at the equal-version case that today overwrites by arrival order, accept iff `pendingVer > stored_roster_version` **or** (`pendingVer == stored_roster_version` **and** (`stored_roster_owner_id` is no longer a current owner **or** `author > storedAuthor`)) — comparing the incoming blob's signer against `stored_roster_owner_id`, never the gate. Store the cross-device author with the blob in `setGroupLiveRoster` (`:3578`; `Store/Groups.hs:1716-1724`). A completed blob reverts privileged members absent from it (`processRosterEntries`, `Subscriber.hs:3610-3619`), so the losing owner's delta is reverted on relays until its retry. Applying any roster skips members who are currently owners (in `processRosterEntries`, `Subscriber.hs:3610-3630`, which today sets each listed member to its listed role, `:3601-3608`, `:3622-3628`): a member promoted to owner from a roster role stays in relays' stored blob, which a later joiner receives right after its owner intros (`Internal.hs:1330-1331`), and would otherwise be set back. **Relay setup under concurrent roster changes:** a roster ack below the owner's current version during relay setup re-sends the current roster to that relay (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`) instead of only warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`) — another owner's delta can advance the version between send and ack, and this owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would stay unpublished. **Owner-side ordering** (an owner's own blob goes to relays only, `broadcastRoster`, `Internal.hs:2649-2654`; a relay forwards nothing its gate drops, `Subscriber.hs:3423-3425`, `:1133-1144`, and can reorder one owner's events, `Store/Delivery.hs:178-202`): on an owner, a role or removal event — original, retry, passed on by a relay whose gate dropped it (Part 4), or a relay's catch-up (below) — carries a key: made-at version (`retryOf`'s, or the event's own) and author (`retryOf`'s, or the signer). The owner applies it, instead of through `applyAtRosterVersion`'s gate (`Subscriber.hs:3404-3446`), exactly when its key beats the member's stored key (`roster_key_version`/`roster_key_author`, or the `group_revoked_owners` row for a removed member): a removal beats a role change; an empty key loses only to an event whose wire version is above `roster_key_floor`; otherwise the higher made-at version, then the higher author. It then stores the key with the event's `prevKey` (a retry carries the original change's), raises its gate to the event's version if higher, clears `roster_key_resent` and `roster_key_reported`, and, when the beaten key was its own and differs from that `prevKey` (the replacing change was made without seeing it), surfaces "your role change to X was overridden". Re-delivered events are dropped by message id first (`saveGroupFwdRcvMsg`, `Internal.hs:2956-2959`), and an older event never beats the key that superseded it. **Re-send triggers**, coalesced — after the first, the owner waits a short quiet period with no further roster event, within a bound, since SMP delivers one message per queue per acknowledgement (pinned `Server.hs:2091-2092`) and a bulk change arrives over several messages — and, for triggers (1)–(3) and (6), skipping members whose current key it has already re-sent (`roster_key_resent`), so a bulk change costs one re-send per member however a relay splits or spaces it — safe because a relay forwards every 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 (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), and re-sends that member only — a relay keeps a genuine owner-signed copy for every member, so a replayed pass-back must not re-send the roster; (6) it applied another owner's departure (Departed owners' changes, below) — adoption: every member whose key that owner authored, since it can no longer re-send its own changes and devices that learned of its departure before receiving them dropped them; when the skip leaves no member, the blob alone goes out at the version this owner holds; (7) a relay's kept copy of a departed owner's role event, received after this owner applied that departure (in the relay's departure report, passed back, or served as catch-up), verified against `group_revoked_owners.member_key` and made at or below that owner's `revokedOwners` `rosterVersion` plus the departed-owner margin of trigger (4), whose key differs from this owner's key for that member, for a member with no `group_revoked_owners` row — undo: some devices applied a role change of the departed owner that this owner does not hold; it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), re-sends that member only, and the copy's wire version never counts toward the highest version seen; if another remaining owner holds the change, that owner receives the re-send as a losing different value, re-sends the change under trigger (3), and this owner adopts it by key. The owner then re-sends, as deltas with `retryOf` = each member's stored key and `prevKey` = that key's stored `prevKey` (a role event with the member's key for TOFU, a removal with its kept `with_messages`), the current value of the triggering events' members and of every member whose key was made at a version at or above the lowest triggering made-at version, with its blob, at one above the highest version seen, records that version in `roster_blob_sent_version` and sets `roster_key_resent` on the members it re-sent, and the bit of `roster_key_reported` for (5) or (7) on the members those triggers named. An event applied above both the gate and the last blob it sent, losing nothing, triggers nothing, so a change made after its owner saw the others never causes a re-send, and the exchange ends once every owner holds every change. Existing members converge from the re-sent deltas (`DJSGroup` → all members). **No blobs on owners:** an owner sends no `XGrpRosterRequest` on a gap — a change: today `requestRosterOnGap` (`Subscriber.hs:3438-3446`) skips only relays — and a roster blob served to an owner is not applied (it would revert members absent from it, `:3610-3619`, over the owner's keys). **Catch-up:** a relay keeps in `group_roster_latest_events` a copy of each owner's role or removal event (with a roster version) it applies, replacing that member's previous copy; at chat start an owner sends each relay `XGrpRosterRequest` carrying its `group_roster_catchup` version, and the relay answers an owner's request with the copies above it, forwarded as signed (then its departure notices, below); the owner applies them by key and records the highest version served. While an owner's connection on the relay is inactive (its queue at quota, `Subscriber.hs:1749-1762`) the relay drops every forward and direct send to it (`Subscriber.hs:4387`, `:4407`), that answer included; so when the connection resumes (`QCONT` and a resumed `SENT`, `Subscriber.hs:1291-1295`, `:1197-1199`, where a relay today calls `serveRoster` for the member ahead of the resumed backlog), a relay sends a current owner its catch-up instead — the copies above the version it last served that owner, then its departure notices. Both branches send to the member with the connection `continueSending` just reactivated (`m {activeConn = Just conn {quotaErrCounter = 0}}`): `continueSending` resets the counter only in the database (`Subscriber.hs:1765-1771`), so the event's `m` still holds the inactive connection that `readyMemberConn` rejects (`Internal.hs:2881`, `:2899-2901`) — today's blob serve there never leaves the relay — and a version counts as served to a member or owner only when `readyMemberConn` handed the send to the agent (today `serveRoster` records it after any attempt, `Internal.hs:1307-1313`, which also makes `xGrpRosterRequest` skip that member, `Subscriber.hs:3685`). An owner's own change that a relay dropped comes back as trigger (4), not through catch-up. When a blob that completes on a relay reverts a member (`:3614-3618`) whose kept copy is by another author than the blob's, the relay passes that copy, with its `broker_ts`, to every current owner: the blob's author applies it by key and re-sends under trigger (1) or (2), and an owner already holding that key re-sends it under trigger (5). **Departed owners' changes** (overview §4): every device ignores content from a member that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner keep changing roles through a single relay — and members and relays keep doing so; owners carry a leaver's in-flight changes instead. *Hold (leave only):* an owner that applies from link data (Reconcile, above) a `revokedOwners` entry with `leave` set, before processing that owner's `x.grp.leave`, stores as `roster_bound` the entry's `rosterVersion`, capped at its own gate plus the departed-owner margin of trigger (4) — the flag and the version are asserted by whoever wrote the entry, which during a removal's healable window can be the removed owner — and starts a hold with `hold_running_left` set to a fixed period of counted time (`ChatConfig`, 2 hours: above draining a full queue plus a relay's resume, about an hour, and short because it also bounds a forged leave, below). During the hold, an `x.grp.mem.role`, or an `x.grp.mem.del` of a member, by that owner — forwarded, passed on, in a relay's departure report, passed back or served as catch-up — that carries a roster version, has no `retryOf`, was made at or below `roster_bound`, and names neither a current owner nor this device's membership, is verified against `group_revoked_owners.member_key` before the author lookup (`xGrpMsgForward`, `Subscriber.hs:4009-4027`, which would recreate an author that member cleanup deleted as keyless, `Store/Groups.hs:1230-1240`) and applied by key with owner authority taken from that removal record, without looking up or recreating the author's row; its forwarded `x.grp.leave` is verified the same way, only to end the hold; anything else from that author stays ignored. The hold ends at the first of: that `x.grp.leave`; an owner's `x.grp.mem.del` of that owner (a removal contradicts the leave flag); an `x.grp.owner.departed` for that owner from every relay that was current and connected to this owner when the hold started and has not since been removed or left (`group_departure_notices`, recorded whenever a notice arrives, also before a hold starts); `hold_running_left` reaching zero, decremented by the periodic cleanup (`cleanupUser`, `Commands.hs:5922-5933`) by the time elapsed since the hold's start or the previous tick in this run, at most one cleanup interval per tick — iOS suspends the chat in the background (`Commands.hs:611-612`) and runs no tick then, so an uncapped first tick after activation would count the whole suspension, 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. A removed owner that rewrites its own entry as a leave before its key is dropped can, with a relay, have changes to non-owner members held by an owner that reads that entry first, and adopted when the hold ends — until its remover's announcement, or, if the remover never announces, until its own `x.grp.leave` or the hold period; a holder cannot tell it from a real leave, both being written with the leaver's key (overview §8). *Adoption and undo:* applying the departure — the end of the hold; the departure event, for an owner that learned of the departure from it; or, for the remover, its `removal_step` reaching `announced` — is trigger (6); from then on a copy of that owner's event is not applied: a differing role event is trigger (7), and a removal of a member that this owner has not removed is shown to it ("X was removed by on some devices") to finish with its own removal if it agrees — a removal record is permanent (Part 2) and a removed owner with a relay could fake one, so it is neither undone nor adopted. Every device then ends with one role per member: the departed owner's when some remaining owner holds it, else the remaining owners'. Absent everywhere: a change every relay lost — a relay that applies the revocation from link data (its periodic check, or the immediate read when an owner join or `x.grp.owner.add` names an owner it does not hold, Reconcile above) before processing a change already in its queue drops the owner's connection with it — a role change racing a removal that no remaining owner holds, and a leaver's changes still pending on its device for relays that stayed at quota past its wait (Part 5), never sent once its connections are deleted (`Commands.hs:3269`) and removed by the message cleanup (`Commands.hs:5967-5970`); a removal racing a removal is shown to the remaining owners as above. **Departures:** when an owner's leave or removal is applied, each remaining owner re-sends the members whose key that owner authored, with its blob (trigger (6)); when that owner authored no member's key, it re-sends its blob (no deltas) to relays at the version it holds — not a new version, which members would read as a gap (`Commands.hs:3015-3017`) — and the completion gate, at an equal version, accepts a blob whose stored predecessor's author is no longer a current owner; relays serve the stored blob under its author (`serveRoster`, `Internal.hs:1295-1315`), which a later joiner can't verify once that author has gone. A relay that applies the departure of its stored blob's author sends `XGrpRosterRequest` with its gate (the higher of its gate and stored version) and that gate's signed proof to each connected current owner, and an owner receiving it directly from a relay, with a proof verified as for trigger (4), raises its gate to that version if higher and re-sends its blob to that relay at its gate (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`; today `xGrpRosterRequest` acts only on relays, `Subscriber.hs:3680-3685`) — at its own lower version it would be refused, and joiners would keep getting a blob they cannot verify. A relay that applies an owner's departure — the event, the revocation in its periodic link check, or the revocation in its immediate link read (Reconcile, above) — passes its kept copies of that owner's events (`group_roster_latest_events` rows it authored), with their `broker_ts`, to every current owner, batched as it passes dropped events, and then `x.grp.owner.departed` naming that owner, its report that it applied the departure (Departed owners' changes, above); its answer to an owner's catch-up request likewise ends with `x.grp.owner.departed` for each departed owner whose copies it served. Applying a roster skips members with a `group_revoked_owners` row as well as current owners, so a departed owner listed in a stored blob under its former roster role is never restored to it. **Relays forward 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.