diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index 0eebddb6c5..b3f4264fd9 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -71,7 +71,7 @@ Because local state thus stays "published + my in-progress changes" (an owner I **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. A leave entry cannot be told from one a removed owner forged with its own key before its key was dropped, so such an owner can, with a relay, have changes to non-owner members held by an owner that reads that entry first, and adopted when the hold ends — until its remover's announcement, or, if the remover never announces, until its own `x.grp.leave` or the hold period (§8). +- **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, asleep, 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. A leave entry cannot be told from one a removed owner forged with its own key before its key was dropped, so such an owner can, with a relay, have changes to non-owner members held by an owner that reads that entry first, and adopted when the hold ends — until its remover's announcement, or, if the remover never announces, until its own `x.grp.leave` or the hold period (§8). - **Adoption.** When an owner applies another owner's departure — the end of its hold, the departure event when it learned of the departure from it, or the announcement of its own removal of that owner — it re-sends, as retries, every member whose key the departed owner authored and that it has not already re-sent, with its blob (part 2's re-send; when none is left, the blob alone at the version it holds, which reads as no gap): the departed owner can no longer re-send its own changes, and devices that learned of the departure first dropped them. Retries carry the departed owner's key, so every owner compares them alike, and a member that already holds the value sees no change. - **Undo.** A role change the departed owner made that no remaining owner holds — one racing its removal, or one a device received after the departure event through a slower relay — may still have been applied by some members and relays. A relay that applies an owner's departure passes its kept copies of that owner's events to every current owner and then reports that it applied the departure; catch-up does the same for an owner that was away. An owner that has applied the departure and holds a different key for that member 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. diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index 526aab034e..c00c096188 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -93,7 +93,7 @@ Do **not** add a `roster_version_owner_id` on the gate `roster_version` — the - `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 — a row first created by the removal of a non-owner (a removal that raced the promotion it revokes) takes the entry's values when the entry is first seen — republished unchanged in its link writes — from the link data, from this device's own revocation write, or from the departure event's `revokedVersion` (Part 4); `NULL` only for a removed non-owner, which `revokedOwners` does not list. +- `revoked_version`, `revoked_leave` — for a revoked owner, its `revokedOwners` entry's `rosterVersion` and `leave` as this device first saw or wrote them — from the link data, from this device's own revocation write, or from the departure event's `revokedVersion` (Part 4) — republished unchanged in its link writes; `NULL` only for a removed non-owner, which `revokedOwners` does not list. A row first created by the removal of a non-owner that raced the promotion it revokes takes the entry's values when the entry is first seen. - `roster_bound`, `hold_running_left` — on an owner's device, for an owner that left and whose leave this device learned of from link data before processing its `x.grp.leave`: the roster version published with the leave (`revokedOwners`, below), and the running time left in the hold during which this device still applies that owner's held role and removal events (Part 6); `NULL` once the hold ends, when adoption runs. ### `group_departure_notices` (new table) @@ -176,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 `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, for a member with no `group_revoked_owners` row — the relay lost a change this owner holds (no honest relay passes back a removed member's copy, as applying a roster skips such members, and a removed member's row may be cleaned up, leaving nowhere to record the re-send); this owner may hold that very message, so a pass-back is compared by key before the message-id dedup (`Internal.hs:2956-2959`); it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), and re-sends that member only — a relay keeps a genuine owner-signed copy for every member, so a replayed pass-back must not re-send the roster; (6) it applied another owner's departure (Departed owners' changes, below) — adoption: every member whose key that owner authored, since it can no longer re-send its own changes and devices that learned of its departure before receiving them dropped them; when the skip leaves no member, the blob alone goes out at the version this owner holds; (7) a relay's kept copy of a departed owner's role event, received after this owner applied that departure (in the relay's departure report, passed back, or served as catch-up), verified against `group_revoked_owners.member_key` and made at or below that owner's `revokedOwners` `rosterVersion` plus the departed-owner margin of trigger (4), whose key differs from this owner's key for that member, for a member with no `group_revoked_owners` row — undo: some devices applied a role change of the departed owner that this owner does not hold; it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), re-sends that member only, and the copy's wire version never counts toward the highest version seen; if another remaining owner holds the change, that owner receives the re-send as a losing different value, re-sends the change under trigger (3), and this owner adopts it by key. The owner then re-sends, as deltas with `retryOf` = each member's stored key and `prevKey` = that key's stored `prevKey` (a role event with the member's key for TOFU, a removal with its kept `with_messages`), the current value of the triggering events' members and of every member whose key was made at a version at or above the lowest triggering made-at version, with its blob, at one above the highest version seen, records that version in `roster_blob_sent_version` and sets `roster_key_resent` on the members it re-sent, and the bit of `roster_key_reported` for (5) or (7) on the members those triggers named. An event applied above both the gate and the last blob it sent, losing nothing, triggers nothing, so a change made after its owner saw the others never causes a re-send, and the exchange ends once every owner holds every change. Existing members converge from the re-sent deltas (`DJSGroup` → all members). **No blobs on owners:** an owner sends no `XGrpRosterRequest` on a gap — a change: today `requestRosterOnGap` (`Subscriber.hs:3438-3446`) skips only relays — and a roster blob served to an owner is not applied (it would revert members absent from it, `:3610-3619`, over the owner's keys). **Catch-up:** a relay keeps in `group_roster_latest_events` a copy of each owner's role or removal event (with a roster version) it applies, replacing that member's previous copy; at chat start an owner sends each relay `XGrpRosterRequest` carrying its `group_roster_catchup` version, and the relay answers an owner's request with the copies above it, forwarded as signed (then its departure notices, below); the owner applies them by key and records the highest version served. While an owner's connection on the relay is inactive (its queue at quota, `Subscriber.hs:1749-1762`) the relay drops every forward and direct send to it (`Subscriber.hs:4387`, `:4407`), that answer included; so when the connection resumes (`QCONT` and a resumed `SENT`, `Subscriber.hs:1291-1295`, `:1197-1199`, where a relay today calls `serveRoster` for the member ahead of the resumed backlog), a relay sends a current owner its catch-up instead — the copies above the version it last served that owner, then its departure notices. Both branches send to the member with the connection `continueSending` just reactivated (`m {activeConn = Just conn {quotaErrCounter = 0}}`): `continueSending` resets the counter only in the database (`Subscriber.hs:1765-1771`), so the event's `m` still holds the inactive connection that `readyMemberConn` rejects (`Internal.hs:2881`, `:2899-2901`) — today's blob serve there never leaves the relay — and a version counts as served to a member or owner only when `readyMemberConn` handed the send to the agent (today `serveRoster` records it after any attempt, `Internal.hs:1307-1313`, which also makes `xGrpRosterRequest` skip that member, `Subscriber.hs:3685`). An owner's own change that a relay dropped comes back as trigger (4), not through catch-up. When a blob that completes on a relay reverts a member (`:3614-3618`) whose kept copy is by another author than the blob's, the relay passes that copy, with its `broker_ts`, to every current owner: the blob's author applies it by key and re-sends under trigger (1) or (2), and an owner already holding that key re-sends it under trigger (5). **Departed owners' changes** (overview §4): every device ignores content from a member that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner keep changing roles through a single relay — and members and relays keep doing so; owners carry a leaver's in-flight changes instead. *Hold (leave only):* an owner that applies from link data (Reconcile, above) a `revokedOwners` entry with `leave` set, before processing that owner's `x.grp.leave`, stores as `roster_bound` the entry's `rosterVersion`, capped at its own gate plus the departed-owner margin of trigger (4) — the flag and the version are asserted by whoever wrote the entry, which during a removal's healable window can be the removed owner — and starts a hold with `hold_running_left` set to a fixed period of counted time (`ChatConfig`, 2 hours: above draining a full queue plus a relay's resume, about an hour, and short because it also bounds a forged leave, below). During the hold, an `x.grp.mem.role`, or an `x.grp.mem.del` of a member, by that owner — forwarded, passed on, in a relay's departure report, passed back or served as catch-up — that carries a roster version, has no `retryOf`, was made at or below `roster_bound`, and names neither a current owner nor this device's membership, is verified against `group_revoked_owners.member_key` before the author lookup (`xGrpMsgForward`, `Subscriber.hs:4009-4027`, which would recreate an author that member cleanup deleted as keyless, `Store/Groups.hs:1230-1240`) and applied by key with owner authority taken from that removal record, without looking up or recreating the author's row; its forwarded `x.grp.leave` is verified the same way, only to end the hold; anything else from that author stays ignored. The hold ends at the first of: that `x.grp.leave`; an owner's `x.grp.mem.del` of that owner (a removal contradicts the leave flag); an `x.grp.owner.departed` for that owner from every relay that was current and connected to this owner when the hold started and has not since been removed or left (`group_departure_notices`, recorded whenever a notice arrives, also before a hold starts); `hold_running_left` reaching zero, decremented by the periodic cleanup (`cleanupUser`, `Commands.hs:5922-5933`), and also when the chat is suspended or stopped (`APISuspendChat`, `APIStopChat`, `Commands.hs:596`, `:611-612`), by the time elapsed since the latest of the hold's start, the previous decrement, and the chat's last start or activation in this run, and only when this device received a message from at least one of the channel's current relays in that time. So every active stretch counts once and suspended time never does: iOS suspends the chat whenever the app goes to the background, and a session shorter than the cleanup interval gets no tick. The relay condition is needed because the cleanup loop runs whenever the app is active (`Commands.hs:5895-5914`), with or without a network, and the device's own subscriptions stay up while a relay is down; with it, 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, for a member with no `group_revoked_owners` row — the relay lost a change this owner holds (no honest relay passes back a removed member's copy, as applying a roster skips such members, and a removed member's row may be cleaned up, leaving nowhere to record the re-send); this owner may hold that very message, so a pass-back is compared by key before the message-id dedup (`Internal.hs:2956-2959`); it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), and re-sends that member only — a relay keeps a genuine owner-signed copy for every member, so a replayed pass-back must not re-send the roster; (6) it applied another owner's departure (Departed owners' changes, below) — adoption: every member whose key that owner authored, since it can no longer re-send its own changes and devices that learned of its departure before receiving them dropped them; when the skip leaves no member, the blob alone goes out at the version this owner holds; (7) a relay's kept copy of a departed owner's role event, received after this owner applied that departure (in the relay's departure report, passed back, or served as catch-up), verified against `group_revoked_owners.member_key` and made at or below that owner's `revokedOwners` `rosterVersion` plus the departed-owner margin of trigger (4), whose key differs from this owner's key for that member, for a member with no `group_revoked_owners` row — undo: some devices applied a role change of the departed owner that this owner does not hold; it is acted on at most once per member and this owner's current key (`roster_key_reported`), at most once per relay per interval (deferred work kept as a set of members per relay), re-sends that member only, and the copy's wire version never counts toward the highest version seen; if another remaining owner holds the change, that owner receives the re-send as a losing different value, re-sends the change under trigger (3), and this owner adopts it by key. The owner then re-sends, as deltas with `retryOf` = each member's stored key and `prevKey` = that key's stored `prevKey` (a role event with the member's key for TOFU, a removal with its kept `with_messages`), the current value of the triggering events' members and of every member whose key was made at a version at or above the lowest triggering made-at version, with its blob, at one above the highest version seen, records that version in `roster_blob_sent_version` and sets `roster_key_resent` on the members it re-sent, and the bit of `roster_key_reported` for (5) or (7) on the members those triggers named. An event applied above both the gate and the last blob it sent, losing nothing, triggers nothing, so a change made after its owner saw the others never causes a re-send, and the exchange ends once every owner holds every change. Existing members converge from the re-sent deltas (`DJSGroup` → all members). **No blobs on owners:** an owner sends no `XGrpRosterRequest` on a gap — a change: today `requestRosterOnGap` (`Subscriber.hs:3438-3446`) skips only relays — and a roster blob served to an owner is not applied (it would revert members absent from it, `:3610-3619`, over the owner's keys). **Catch-up:** a relay keeps in `group_roster_latest_events` a copy of each owner's role or removal event (with a roster version) it applies, replacing that member's previous copy; at chat start an owner sends each relay `XGrpRosterRequest` carrying its `group_roster_catchup` version, and the relay answers an owner's request with the copies above it, forwarded as signed (then its departure notices, below); the owner applies them by key and records the highest version served. While an owner's connection on the relay is inactive (its queue at quota, `Subscriber.hs:1749-1762`) the relay drops every forward and direct send to it (`Subscriber.hs:4387`, `:4407`), that answer included; so when the connection resumes (`QCONT` and a resumed `SENT`, `Subscriber.hs:1291-1295`, `:1197-1199`, where a relay today calls `serveRoster` for the member ahead of the resumed backlog), a relay sends a current owner its catch-up instead — the copies above the version it last served that owner, then its departure notices. Both branches send to the member with the connection `continueSending` just reactivated (`m {activeConn = Just conn {quotaErrCounter = 0}}`): `continueSending` resets the counter only in the database (`Subscriber.hs:1765-1771`), so the event's `m` still holds the inactive connection that `readyMemberConn` rejects (`Internal.hs:2881`, `:2899-2901`) — today's blob serve there never leaves the relay — and a version counts as served to a member or owner only when `readyMemberConn` handed the send to the agent (today `serveRoster` records it after any attempt, `Internal.hs:1307-1313`, which also makes `xGrpRosterRequest` skip that member, `Subscriber.hs:3685`). An owner's own change that a relay dropped comes back as trigger (4), not through catch-up. When a blob that completes on a relay reverts a member (`:3614-3618`) whose kept copy is by another author than the blob's, the relay passes that copy, with its `broker_ts`, to every current owner: the blob's author applies it by key and re-sends under trigger (1) or (2), and an owner already holding that key re-sends it under trigger (5). **Departed owners' changes** (overview §4): every device ignores content from a member that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner keep changing roles through a single relay — and members and relays keep doing so; owners carry a leaver's in-flight changes instead. *Hold (leave only):* an owner that applies from link data (Reconcile, above) a `revokedOwners` entry with `leave` set, before processing that owner's `x.grp.leave`, stores as `roster_bound` the entry's `rosterVersion`, capped at its own gate plus the departed-owner margin of trigger (4) — the flag and the version are asserted by whoever wrote the entry, which during a removal's healable window can be the removed owner — and starts a hold with `hold_running_left` set to a fixed period of counted time (`ChatConfig`, 2 hours: above draining a full queue plus a relay's resume, about an hour, and short because it also bounds a forged leave, below). During the hold, an `x.grp.mem.role`, or an `x.grp.mem.del` of a member, by that owner — forwarded, passed on, in a relay's departure report, passed back or served as catch-up — that carries a roster version, has no `retryOf`, was made at or below `roster_bound`, and names neither a current owner nor this device's membership, is verified against `group_revoked_owners.member_key` before the author lookup (`xGrpMsgForward`, `Subscriber.hs:4009-4027`, which would recreate an author that member cleanup deleted as keyless, `Store/Groups.hs:1230-1240`) and applied by key with owner authority taken from that removal record, without looking up or recreating the author's row; its forwarded `x.grp.leave` is verified the same way, only to end the hold; anything else from that author stays ignored. The hold ends at the first of: that `x.grp.leave`; an owner's `x.grp.mem.del` of that owner (a removal contradicts the leave flag); an `x.grp.owner.departed` for that owner from every relay that was current and connected to this owner when the hold started and has not since been removed or left (`group_departure_notices`, recorded whenever a notice arrives, also before a hold starts); `hold_running_left` reaching zero, decremented by the periodic cleanup (`cleanupUser`, `Commands.hs:5922-5933`), and also on suspending (`APISuspendChat`, `Commands.hs:611-612`, on the transition from activated to suspended only) and on stopping (after `stopChatController` returns, `Commands.hs:369`, which covers `APIStopChat`, `:596`, and the CLI's exit), only while the chat is activated — a tick re-reads `chatActivated` at its decrement — by the time elapsed since the latest of the hold's start, the previous decrement, and the chat's last start or activation in this run, capped at one cleanup interval plus one pass of the loop, and only when this device received a message from at least one of the channel's current relays in that time. A hold's decrements are serialized (read, compute and write in one transaction), and a decrement error is caught, as the cleanup steps catch theirs. The base leaves out iOS suspension, when no tick runs, and counts the stretch after a session's last tick at its suspension; the cap leaves out OS sleep on desktop and Android, which never suspend the chat — a running process decrements at least once per interval plus one pass, so a longer gap is sleep or a frozen process. A crash loses at most the last stretch, which only lengthens a hold. The relay condition is needed because the cleanup loop runs whenever the app is active (`Commands.hs:5895-5914`), with or without a network, and the device's own subscriptions stay up while a relay is down; with it, 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.