docs: channel multi-owner plans: define the hold decrement cap, base and locking precisely

This commit is contained in:
spaced4ndy committed 2026-10-14 11:12:23 +00:00
1 parent 41d3e9737c
commit a5ad0c905b
1 file changed
+1 -1
@@ -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 forwaLine truncated
- **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 forwaLine truncated
- **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.