diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index a0704ed76c..e680769ab3 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -120,7 +120,7 @@ Members and relays keep this protocol unchanged. With several owners, two owners - The blob that won rule 1 may have reverted one of the two changes on relays. The re-sent blob is built after the owner applied both, and its higher version replaces any blob at the old version, whatever its author. - Members already hold both changes. Their next event reads as a gap, so they fetch the served blob once (`Subscriber.hs:3438-3446`). 3. **Departure re-send.** When an owner applies another owner's departure, it re-sends its current blob to relays at its gate plus one, as rule 2 does. It does the same when it applies a served blob whose author has left (rule 6). A departure is applied either from the departed owner's `x.grp.leave` or from `revokedOwners` in a link-data read. - - A departure read from the link data can arrive before the departed owner's last messages, which this device then drops (`Subscriber.hs:4019-4020`), so its gate can stay below the relays'. The owner therefore also asks every current relay for the roster (rule 6), and re-sends again once the served blob is applied. + - A departure read from the link data can arrive before the departed owner's last messages, which this device then drops (`Subscriber.hs:4019-4020`), so its gate can stay below the relays'. The owner therefore first asks every current relay for the roster, before its own re-send, so the request carries its frontier from before that re-send. It re-sends again once the served blob is applied (rule 6). - Joiners verify a served blob against its author's key and ignore a departed author (`Subscriber.hs:3506`, `:4019-4020`). The re-send's higher version replaces the departed author's blob on every relay. 4. **Owners are outside the roster.** - Applying a roster skips members who are current owners, and members held as removed or left. Otherwise a blob made while a new owner was an admin would set it back to admin, and a stale blob would bring back a removed member. @@ -130,8 +130,9 @@ Members and relays keep this protocol unchanged. With several owners, two owners 6. **An owner sends a blob only when it is in sync.** It sends a blob, for its own change or for rules 2 and 3, only when its complete frontier equals its gate. While it is out of sync: - it sends `XGrpRosterRequest` to every current relay, and again with each role change it makes; - its role and removal events go out without a blob, so they cannot overwrite relays with a stale view; - - rules 2 and 3 wait until it is back in sync; - - it applies a served blob even when the blob's author has left. It verifies the blob with the key it kept for that author, at a version not below its gate. Content from a departed author is otherwise ignored (`Subscriber.hs:4019-4020`), and without this exception an owner could not get back in sync after the blob's author left. + - rules 2 and 3 wait until it is back in sync. + + Every owner, in sync or not, applies a served blob even when the blob's author has left. It verifies the blob with the key it kept for that author, at a version not below its gate. Content from a departed author is otherwise ignored (`Subscriber.hs:4019-4020`). Without this exception, an owner could not catch up after the blob's author left: neither one that is out of sync, nor one that dropped the author's last messages after reading its departure from the link data (rule 3). 7. **An owner back after a long absence requests the roster.** When its chat starts and the newest item it received in the channel is older than message expiry (21 days by default), or there is none, each owner sends each relay `XGrpRosterRequest` with its complete frontier, as members do on a gap. It then holds the current gate before it changes roles. **What this gives up.** @@ -226,7 +227,7 @@ It cannot see, verify or promote a silent subscriber who joined earlier (§11). ## 7. Leaving, and destroying the channel -**Leave.** Any owner can leave while the link data it reads shows another current owner, that is another chain entry not in `revokedOwners`. The core refuses the last owner. Steps (synchronous): +**Leave.** Any owner can leave while the link data it reads, merged as every write merges it (§2), shows another current owner, that is another chain entry not in `revokedOwners`. The core refuses the last owner. Steps (synchronous): 1. Read the link data, merge it as every write does (§2), and check that the result shows another current owner. The merge re-adds a current owner missing from the chain. 2. Write the link data with its own memberId added to `revokedOwners`, keeping its own entry (§2). @@ -238,7 +239,9 @@ It cannot see, verify or promote a silent subscriber who joined earlier (§11). - the creator erases its root private key, keeping the root public key; - every leaver erases its member private key. A later load of the group generates an unrelated key, so the erased one is gone. -**Repeating a leave.** A crash or a failed step leaves the leave half done. The user repeats it, and the app keeps offering Leave, because this device's own membership stays current: applying `revokedOwners` never changes it (below). A repeated leave that finds its memberId already in `revokedOwners` continues at step 4, and an `AUTH` error at step 4 counts as done. Until then, the device keeps its key, and devices that have not read the link data since still treat it as owner. +**Repeating a leave.** A crash or a failed step leaves the leave half done. What happens next depends on where it stopped: +- **Before step 5.** The membership stays current, because applying `revokedOwners` never changes this device's own membership (below). The app keeps offering Leave. A repeated leave that finds its memberId already in `revokedOwners` continues at step 4, and an `AUTH` error at step 4 counts as done. Until then, the device keeps its key, and devices that have not read the link data since still treat it as owner. Deleting the chat in this state is still a current owner's destroy: it sends `x.grp.del` and, for the creator, `DEL` on the link queue. +- **After step 5.** The chat is left. Deleting it completes step 6's link deletion locally, never with `DEL` (below). **Why no `DEL`.** Today `APILeaveGroup` deletes the link connection with `DEL` on the shared queue (`deleteGroupLinkIfExists`, `Commands.hs:3246`, `Internal.hs:1739-1748`). After step 4 the creator's key is normally no longer authorised, but a concurrent `RKEY` by another owner can authorise it again (§3), so the deletion is local. @@ -256,7 +259,7 @@ A departed owner's later content is then ignored everywhere (`Subscriber.hs:4019 **Two last owners leaving at once.** Each can read the other as current and verify its own write before the other's write lands. Both leave, and the channel ends with no owner. -**Destroying the channel** stays any current owner's action through `APIDeleteChat`, which sends `x.grp.del` (`Commands.hs:1384-1400`). The creator deletes the link queue through its link connection, as today. A promoted owner deletes it with `DEL` using its link credentials, and only when it sends `x.grp.del`. `deleteGroupLinkIfExists` runs whether or not `x.grp.del` was sent, so it never sends this `DEL`. A creator whose membership is no longer a current owner, for example after a leave that stopped before step 6, deletes its link connection locally when it deletes the chat, never with `DEL`. +**Destroying the channel** stays any current owner's action through `APIDeleteChat`, which sends `x.grp.del` (`Commands.hs:1384-1400`). The creator deletes the link queue through its link connection, as today. A promoted owner deletes it with `DEL` using its link credentials, and only when it sends `x.grp.del`. `deleteGroupLinkIfExists` runs whether or not `x.grp.del` was sent, so it never sends this `DEL`. A creator whose membership is no longer a current owner, for example after a leave that stopped after step 5, deletes its link connection locally when it deletes the chat, never with `DEL`. **Group-link commands.** `APICreateGroupLink`, `APIAddGroupShortLink` and `APIDeleteGroupLink` are refused for channels (`Commands.hs:3349-3384`): @@ -296,7 +299,7 @@ A departed owner's later content is then ignored everywhere (`Subscriber.hs:4019 - A departed owner erased its keys (§7). - A departed owner that kept a copy of its member key, or the creator a copy of its root key, while still an owner can still act with it: - with the link's SMP server's help, it can serve forged link data to readers, including new owner entries that every party then applies (§6); - - through a colluding relay, it can serve a roster blob it signs with the kept key, at any version, which an out-of-sync owner applies and re-sends (§4 rule 6). A colluding relay alone can replay a blob the departed owner signed before leaving. + - through a colluding relay, it can serve a roster blob it signs with the kept key, at any version not below an owner's gate, which that owner applies and re-sends (§4 rules 3 and 6). A colluding relay alone can replay a blob the departed owner signed before leaving. Such an owner was already able to do anything an owner can. - **The link server sees a promoted owner's network address** when it writes. Recipient commands are not proxied (`Server.hs:2205-2211`), so the owner's device connects directly to the server the creator chose. diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index 80bf6c27ad..fd0830361f 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -120,7 +120,7 @@ The relay side ships in the same release as promotion. Promotion is refused whil - **`APILeaveGroup` by an owner of a multi-owner channel** runs the leave (Part 6.6) instead of `deleteGroupLinkIfExists` (`Commands.hs:3246`). - It is refused when no other current owner remains. - A sole owner keeps today's behaviour. -- **`APIDeleteChat` by a promoted owner**, in the branch that sends `x.grp.del` (`doSendDel`, `Commands.hs:1394-1400`), also deletes the link queue with `deleteLinkQueueWithCreds`, after reading the link data for its `linkRcvId`. `deleteGroupLinkIfExists` at `:1400` runs regardless of `doSendDel`, so this `DEL` is not added there. When this device created the channel and its membership is no longer a current owner (a leave stopped before step 6), `APIDeleteChat` deletes the link connection with `deleteConnectionLocal` instead of `deleteGroupLinkIfExists`. The key may still be authorised (overview §3), so it never sends `DEL`. +- **`APIDeleteChat` by a promoted owner**, in the branch that sends `x.grp.del` (`doSendDel`, `Commands.hs:1394-1400`), also deletes the link queue with `deleteLinkQueueWithCreds`, after reading the link data for its `linkRcvId`. `deleteGroupLinkIfExists` at `:1400` runs regardless of `doSendDel`, so this `DEL` is not added there. When this device created the channel and its membership is no longer a current owner (a leave stopped after step 5), `APIDeleteChat` deletes the link connection with `deleteConnectionLocal` instead of `deleteGroupLinkIfExists`. The key may still be authorised (overview §3), so it never sends `DEL`. - **`APICreateGroupLink`, `APIAddGroupShortLink` and `APIDeleteGroupLink` are refused for channels** (`Commands.hs:3349-3384`). - **`APIGetUpdatedGroupLinkData`.** Replace the owner branch at `Commands.hs:1968` with the owner reconcile (Part 6.2). For subscribers it also applies the owners chain (Part 6.5). - **`APIAddGroupRelays`.** A promoted owner takes the channel link from `publicGroup.groupLink`; the creator takes it from its group link row (`Commands.hs:2790-2793`). Publication goes through Part 6.1. @@ -265,7 +265,7 @@ Members and relays keep `applyAtRosterVersion` and the completion gate. The rule - it applies another owner's departure, either from `xGrpLeave` of an owner, or from Part 6.5 marking a held owner left from `revokedOwners`; - it applies a served blob whose author it holds as left (rule 6). - A departure from a link-data read can come before the departed owner's last forwarded messages, which `xGrpMsgForward` then drops (`Subscriber.hs:4019-4020`). So on that path the owner also sends `XGrpRosterRequest` with its complete frontier to every current relay. + A departure from a link-data read can come before the departed owner's last forwarded messages, which `xGrpMsgForward` then drops (`Subscriber.hs:4019-4020`). So on that path the owner first sends `XGrpRosterRequest` with its complete frontier to every current relay, before its own re-send. The request then carries the frontier from before that re-send. 4. **Owners are outside the roster.** - `processRosterEntries` (`Subscriber.hs:3610-3630`) skips entries for current owners and for members held as removed or left. - `xGrpMemRole` (`Subscriber.hs:3448-3500`), including its own-membership branch (`:3451-3454`), ignores an event whose target is a current owner or a member held as removed or left, and any event that assigns the owner role. @@ -274,8 +274,9 @@ Members and relays keep `applyAtRosterVersion` and the completion gate. The rule 6. **In sync before sending.** An owner sends a blob (own change, rule 2, rule 3) only when its complete frontier equals its gate. While it is out of sync: - it sends `XGrpRosterRequest` with its complete frontier to every current relay, and again with each role change it makes; - `broadcastRoster` reserves the version and sends the events without the blob; - - rules 2 and 3 wait until it is back in sync; - - in `xGrpMsgForward` (`Subscriber.hs:4009-4027`), it processes an `XGrpRoster` whose author it holds as left, verified against that author's kept key, instead of ignoring it. Completion still rejects a version below its gate. + - rules 2 and 3 wait until it is back in sync. + + Every owner, in sync or not, processes in `xGrpMsgForward` (`Subscriber.hs:4009-4027`) an `XGrpRoster` whose author it holds as left, verified against that author's kept key, instead of ignoring it. Completion still rejects a version below its gate. This is how an owner catches up both when it is out of sync and after a departure read from link data (rule 3). 7. **Absence request.** At chat start, when the newest item an owner received in the channel is older than message expiry, or there is none, it sends `XGrpRosterRequest` with its complete frontier to each relay. Relays already answer members this way (`xGrpRosterRequest`, `Subscriber.hs:3679-3685`). ### 6.10 Count, and a relay's own leave @@ -370,7 +371,7 @@ Under `describe "channels"` (`tests/ChatTests/Groups.hs`), with relays from `cha - **Leave.** - The last owner's leave is refused. - The creator's leave sends no `DEL`, and the link still resolves. - - A leave stopped after step 2 and repeated continues at step 4. A reconcile before the repeat leaves the leaver's own membership current. A creator that deletes the chat after its leave stopped before step 6 sends no `DEL`. + - A leave stopped after step 2 and repeated continues at step 4. A reconcile before the repeat leaves the leaver's own membership current. A creator that deletes the chat after its leave stopped after step 5 sends no `DEL`. - The creator changes roles while M is offline, then leaves. M reads the link data before its backlog: it asks every relay, applies the creator's served blob, and re-sends, and a later joiner gets a roster it can verify. - After any leave, the leaver's stored member key no longer matches its chain entry's key.