From 69d0f05549bbfba1d87b80a37fcccf31afad52e3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:21:42 +0000 Subject: [PATCH] docs: channel multi-owner plans: hold cap units and margin, lock write order, stop clause wording --- plans/2026-07-21-channel-multi-owner-implementation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index 584cbbdbed..c98506f7d3 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -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 on suspending (`APISuspendChat`, `Commands.hs:611-612`, on the transition from activated to suspended only) and on stopping (after `stopChatController`, `Commands.hs:363`, returns, having cancelled the cleanup loop, `:369`, which covers `APIStopChat`, `:596`, and the CLI's exit, `Core.hs:111`), only while the chat is activated — a tick re-reads `chatActivated` at its decrement — by the time elapsed since the latest of the hold's start, the previous decrement, and the chat's last start or activation in this run, capped at one cleanup interval plus one pass of the loop (`cleanupManagerInterval` + (5 × users + 1) × `cleanupManagerStepDelay`, `Chat.hs:125-126`), and only when this device received a message from at least one of the channel's current relays in that time — a decrement that fails this condition still moves the base forward, counting zero. A hold's decrements are serialized under one lock that also guards the in-memory base, the activation time is recorded in the same write that sets `chatActivated` (`Commands.hs:602`) so a tick that sees the chat activated also sees its new base, and a decrement error is caught, as the cleanup steps catch theirs. The base leaves out iOS suspension, when no tick runs, and counts the stretch after a session's last tick at its suspension; the cap 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. In Android's periodic mode, which runs the chat for up to a minute every 10 minutes without stopping it (`MessagesFetcherWorker.kt`), a tick counts the frozen time before it, up to the cap, and the device drains the relays at each run. 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. +- **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`, `Commands.hs:363`, returns, as it cancels the cleanup loop at `:369`; both `APIStopChat`, `:596`, and the CLI's exit, `Core.hs:111`, call it), only while the chat is activated — a tick re-reads `chatActivated` at its decrement — by the time elapsed since the latest of the hold's start, the previous decrement, and the chat's last start or activation in this run, capped at one cleanup interval plus one pass of the loop (`cleanupManagerInterval` + (5 × users + 1) × `cleanupManagerStepDelay`, `Chat.hs:125-126`: five step delays per user, `Commands.hs:5922-5933`, and one as a margin for the steps' own running time, which a slower pass exceeds only to lengthen a hold; the decrement adds no step delay; the interval is in seconds and the step delay in microseconds, `Controller.hs:175-176`, so they are converted before adding, or the cap never binds), and only when this device received a message from at least one of the channel's current relays in that time — a decrement that fails this condition still moves the base forward, counting zero. A hold's decrements are serialized under one lock that also guards the in-memory base, which a decrement advances before writing `hold_running_left` (a write lost to a stop's cancellation then only lengthens a hold), the activation time is recorded in the same write that sets `chatActivated` (`Commands.hs:602`) so a tick that sees the chat activated also sees its new base, and a decrement error is caught, as the cleanup steps catch theirs. The base leaves out iOS suspension, when no tick runs, and counts the stretch after a session's last tick at its suspension; the cap 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. In Android's periodic mode, which runs the chat for up to a minute at most every 10 minutes without stopping it (`MessagesFetcherWorker.kt`), a tick counts the frozen time before it, up to the cap, and each run receives what the relays have queued, up to a minute's worth. A crash loses at most the last stretch, which only lengthens a hold. The relay condition is needed because the cleanup loop runs whenever the app is active (`Commands.hs:5895-5914`), with or without a network, and the device's own subscriptions stay up while a relay is down; with it, 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.