mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 10:12:25 +00:00
docs: channel multi-owner S3-min plans: review round 3 fixes - relays check owner additions and event counters against the link, nothing saved before a deferred check, write callback knows whether it wrote, writes store the roster commitment, repeated roster fetch, relay checks as their own slice
This commit is contained in:
1 parent
116f42cfd1
commit
1839223688
2 files changed
+65
-53
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Channel multi-owner (S3-min) — implementation plan
|
||||
|
||||
Branch: `f/channel-multi-owner` · Repos: simplexmq (from pinned `cba13823`), simplex-chat · Estimated diff: ~2,300 production Haskell lines (simplexmq ~470, chat ~1,810) plus tests, and ~400 lines per app platform.
|
||||
Branch: `f/channel-multi-owner` · Repos: simplexmq (from pinned `cba13823`), simplex-chat · Estimated diff: ~2,350 production Haskell lines (simplexmq ~470, chat ~1,880) plus tests, and ~400 lines per app platform.
|
||||
|
||||
The overview, `2026-10-15-channel-multi-owner-s3min.md`, says what owners can do and why; this file says what to build. Anchors are simplex-chat paths under `src/Simplex/Chat/` and simplexmq paths under `src/Simplex/Messaging/` at `cba13823`.
|
||||
|
||||
@@ -13,7 +13,7 @@ A channel has several equal owners. Any owner can do what the single owner does
|
||||
| AC1 | An owner invites a member whose code it verified; the member accepts; one link write adds the member's chain entry and recipient key; every relay and subscriber treats it as owner; it posts, edits and changes roles | T1, T20, T21 |
|
||||
| AC2 | Promotion is refused for an unverified member, while a relay is below `multiOwnerVersion`, and on a link server below SMP v24 | T2 |
|
||||
| AC3 | The creator promotes M and leaves; M does every owner action; a later joiner sees M as owner and the creator as departed, verifies the roster and gets the creator's history unsigned | T3 |
|
||||
| AC4 | An owner removes another; the removed owner's link writes and `DEL` get `AUTH`; its device shows the removal and erases its keys; relays refuse its owner additions, owner removals and channel deletion | T4, T17 |
|
||||
| AC4 | An owner removes another; the removed owner's link writes and `DEL` get `AUTH`; its device shows the removal and erases its keys; relays refuse its owner additions and removals, its channel deletion, and its profile and role changes at versions they have not read from the link | T4, T17 |
|
||||
| AC5 | Concurrent link writes both land or one is refused; a profile edit over a profile changed since the editor opened is refused; concurrent role changes reach every relay and member and no demotion is reverted; mutual removal and the last two owners leaving each leave one current owner | T5–T8, T14 |
|
||||
| AC6 | A role change made just before its author leaves reaches every device | T9, T15, T22 |
|
||||
| AC7 | A new owner pulls the member list from a relay; the count keeps updating after the creator leaves | T10, T11 |
|
||||
@@ -102,15 +102,15 @@ Keys, IDs and `OwnerAuth` encode in JSON as base64url of their `smpEncode` (`str
|
||||
|
||||
**Locks.** `ChatController.linkWriters :: TMap GroupId Lock` serialises this device's link writes per channel. Network calls never run under the group lock (`withGroupLock`), which every agent event of the channel takes on the single event thread (`Subscriber.hs:146-147`, `Store/Connections.hs:67-68`); DB steps take it briefly.
|
||||
|
||||
**`writeChannelLink :: User -> GroupId -> ChannelLinkChange -> (ChannelLink -> CM a) -> CM a`**, under the link writer lock:
|
||||
**`writeChannelLink :: User -> GroupId -> ChannelLinkChange -> (LinkWrite -> ChannelLink -> CM a) -> CM a`** (`LinkWrite = LWWritten | LWUnchanged`), under the link writer lock:
|
||||
|
||||
1. Read with `getConnShortLinkForWrite` on `publicGroup.groupLink`; decode `UserContactData` and `GroupShortLinkData` with its raw JSON.
|
||||
2. Under the group lock: `applyChannelLinkData` (below), then `applyLinkChange :: ChannelLinkChange -> ChannelLink -> CM (Either ChannelLinkError (Maybe ChannelLink))`; `Right Nothing` = already applied, no write. If the read shows this device departed: stop with `CLENotOwner`.
|
||||
1. Read with `getConnShortLinkForWrite` on `publicGroup.groupLink`; decode `UserContactData` and `GroupShortLinkData` with its raw JSON; `channelLinkWriteHook` (tests).
|
||||
2. Under the group lock: `applyChannelLinkData` (below), then `applyLinkChange :: ChannelLinkChange -> ChannelLink -> CM (Either ChannelLinkError (Maybe ChannelLink))`; `Right Nothing` = already applied, no write. If the read shows this device departed: stop with `CLENotOwner` (a leave instead completes its departure, §4.9).
|
||||
3. When the owner set changed, `linkQueueKeys`: `creatorQueueKey.key` unless the creator is revoked, plus the `ownerKey` of every other chain entry not in `revokedOwners`; the last-owner check keeps it non-empty.
|
||||
4. `linkVersion` = read (0 if absent) + 1; `channelLinkWriteHook` (tests); swap: with `GRKPrivate` the creator uses `setConnShortLinkSwap`; any other owner `setShortLinkSwapWithCreds`.
|
||||
4. `linkVersion` = read (0 if absent) + 1; swap: with `GRKPrivate` the creator uses `setConnShortLinkSwap`; any other owner `setShortLinkSwapWithCreds`.
|
||||
5. `CONFLICT` → from step 1, at most `channelLinkWriteAttempts` (5), then `CLELinkBusy`. `AUTH` → one more read and apply (it shows the revocation), `CLENotOwner`. `TEVersion` → `CLELinkServerVersion`. `CMD LARGE` → `CLELinkDataTooLarge`. `CMD PROHIBITED` → `CLEDirectConnection`.
|
||||
6. On success, and on "already applied" (no write), under the group lock: store `link_data_version` and run the callback with the written (or read) link: local state, events to send, the result.
|
||||
7. Release the writer lock; fork the follow-ups of step 2 that are not already pending for the channel (`ChatController.linkFollowUps :: TMap GroupId (Set FollowUp)`), each a further write queued on the writer lock. A write never re-arms the follow-up kind it is running.
|
||||
6. On success (`LWWritten`), and on "already applied" (`LWUnchanged`), under the group lock: store the link's `linkVersion` in `link_data_version` and its roster commitment in `link_roster_*` (as `applyChannelLinkData` step 6), and run the callback: local state, events to send, the result.
|
||||
7. Release the writer lock; fork each follow-up of step 2 that is not in `ChatController.linkFollowUps :: TMap GroupId (Set FollowUp)` for the channel, adding it; a follow-up removes its entry when it starts, before its read, and runs as a further write queued on the writer lock. Reads outside writes (channel open, chat start, relay checks) fork their follow-ups through the same set. A write never re-arms the follow-up kind it is running.
|
||||
|
||||
Callers never hold the group lock across this call; message-triggered writes are forked. Commands that hold `withGroupLock` across their body today and must take it only around their DB steps: `APIMembersRole` (`Commands.hs:2984`), `APIRemoveMembers` (`:3123`), `APILeaveGroup` (`:3236`), `APIDeleteChat` (`:1390`), `APIAddGroupRelays` (`:2781`).
|
||||
|
||||
@@ -170,7 +170,7 @@ Each event: constructor, `CMEventTag`, tag string and parser, `toCMEventTag`, JS
|
||||
- the user is a current owner of a channel; M is a current member with a key, not an owner, not departed;
|
||||
- M's code is verified: `memberVerifiedCode` equals `getChannelMemberCode` now (`Commands.hs:3811-3815`), else `CLENotVerified` (a relay that substituted M's key would sign the acceptance with it; only the out-of-band check detects it);
|
||||
- every current relay supports `multiOwnerVersion`, else `CLERelayVersion`;
|
||||
- `linkServerSupportsSwap`, else `CLELinkServerVersion`.
|
||||
- `linkServerSupportsSwap`, else `CLELinkServerVersion` (`CMD PROHIBITED` → `CLEDirectConnection {host, prohibited = True}`).
|
||||
|
||||
It sends `x.grp.owner.inv {memberId}` and stores the message's shared msg id in M's `owner_inv_msg_id` (a new invitation replaces it), which puts the device in multi-owner mode. M's version is not checked (known only from M's join, `Store/Groups.hs:2533-2551`); an older M ignores the invitation.
|
||||
|
||||
@@ -183,11 +183,11 @@ It sends `x.grp.owner.inv {memberId}` and stores the message's shared msg id in
|
||||
1. Re-check M (current, not revoked) and the relays' versions; a failure that persists clears the invitation and is shown to the user. M already a current chain owner means the swap landed before an interruption: go to step 4.
|
||||
2. First promotion (the creator, link without `creatorQueueKey`): if `roster_version` is set, set `applied_complete_roster_version` to it, rebuild the blob from current roster members without M (`buildGroupRoster`), store it as its own (§4.10) and commit it as `roster {version, digest, self}`; with no roster version, commit none (the first claim takes version 0).
|
||||
3. `writeChannelLink` with `CLCAddOwner M (mkOwnerAuth …)`.
|
||||
4. Callback (also when the write was already applied): clear `owner_inv_msg_id` and `owner_inv_accepted`; M's role owner (its key is the chain key); send `x.grp.owner.add {memberId, ownerAuth, profile, linkVersion}` with M's redacted profile (`redactedMemberProfile`, `Internal.hs:1361`) and the link's `linkVersion`; first promotion: send the stored blob to current relays at its version (`sendStoredRoster`, §4.10); item `SGEMemberRole … GROwner`; `CEvtMemberRole`.
|
||||
4. Callback (also on `LWUnchanged`), only while M's `owner_inv_accepted = 1`, so a concurrent re-run (§4.15) that finds it cleared sends nothing: clear `owner_inv_msg_id` and `owner_inv_accepted`; M's role owner (its key is the chain key); send `x.grp.owner.add {memberId, ownerAuth, profile, linkVersion}` with M's redacted profile (`redactedMemberProfile`, `Internal.hs:1361`) and the link's `linkVersion`; first promotion: send the stored blob to current relays at its version (`sendStoredRoster`, §4.10); item `SGEMemberRole … GROwner`; `CEvtMemberRole`.
|
||||
|
||||
An unfinished promotion (`owner_inv_accepted = 1`) runs again at main-app chat start.
|
||||
|
||||
**Commit.** Members and owners (forwarded) apply `x.grp.owner.add` when: the sender is a current owner; `ownerAuth.ownerId == memberId`; `ownerAuth` verifies against the root key or an owner key held, current or departed (a delegated entry stays valid after its signer leaves); `memberId` is not held as departed. Then role owner, `member_pub_key = ownerKey` (warning if it replaces a different key), the member created from `profile` if unknown, the role item as `xGrpMemRole` creates it, and `link_data_version = max(stored, linkVersion)`, so a read older than the promotion is ignored rather than making M departed (§4.4 step 4). A relay sends its capabilities (`XGrpRelayCap`) to the new owner. An owner holding `memberId` as departed runs "removal wins" (§4.8). It is not `x.grp.mem.role … owner`, so older clients ignore it and no relay-asserted key is promoted (`Subscriber.hs:3465-3468`).
|
||||
**Commit.** Members and owners (forwarded) apply `x.grp.owner.add` when: the sender is a current owner; `ownerAuth.ownerId == memberId`; `ownerAuth` verifies against the root key or an owner key held, current or departed (a delegated entry stays valid after its signer leaves); `memberId` is not held as departed. Then role owner, `member_pub_key = ownerKey` (warning if it replaces a different key), the member created from `profile` if unknown, the role item as `xGrpMemRole` creates it, and `link_data_version = max(stored, linkVersion)`, so a read older than the promotion is ignored rather than making M departed (§4.4 step 4); relays forward a commit only after checking it against the link (§4.13), which bounds this value. A relay sends its capabilities (`XGrpRelayCap`) to the new owner. An owner holding `memberId` as departed runs "removal wins" (§4.8). It is not `x.grp.mem.role … owner`, so older clients ignore it and no relay-asserted key is promoted (`Subscriber.hs:3465-3468`).
|
||||
|
||||
**M becomes owner** when it applies the commit about itself or reads its own entry (§4.4), with its own key and a current membership: role owner, item `RGEUserRole GROwner`, `members_catchup = 1`, member catch-up (§4.7). It stores no credential: each write builds `LinkOwnerCreds`.
|
||||
|
||||
@@ -204,14 +204,14 @@ An unfinished promotion (`owner_inv_accepted = 1`) runs again at main-app chat s
|
||||
- **Relays** apply and forward `x.grp.mem.del` of an owner only after the link check (§4.13); members apply it from a current owner (`checkRole`, `Subscriber.hs:3783-3786`); a relay forwards it to X first, then deletes X's connection (`:3755-3761`).
|
||||
- **The removed owner's device.** An owner device applies an `x.grp.mem.del` of itself (own-membership branch, `Subscriber.hs:3734-3746`) only after a read shows itself revoked, since honest removers write first; a read showing it revoked also suffices on its own (§4.4 step 3). It then completes its departure: membership removed, keys erased (§4.9 step 3), catch-up stopped.
|
||||
- **Removal wins.** An owner that holds member M as departed (left or removed) and sees M as a current chain owner (a commit or a read) runs `CLCRevokeOwner M`, and on a write sends `x.grp.mem.del M`. Owners reject an admin's removal of a member they hold as owner (`checkRole`), so an admin cannot remove an owner through this.
|
||||
- **Self-revocation.** A device that applies its own departure (removal or leave) while it holds an accepted invitation (`OISAccepted` item) keeps `member_priv_key` and reads the link: if its own entry is current, it revokes it (`CLCRevokeOwner self`); if the entry is absent, it re-reads after `rosterRequestTimeout` and at main-app chat starts, until the entry appears (then revokes) or `rosterAbandonTTL` passes since the acceptance. Then it erases the key and sets the item to `OISClosed`. This settles an admin's removal racing the promotion in either order: before or after the promoter's swap.
|
||||
- **Self-revocation.** A device that applies its own departure (removal or leave) while it holds an accepted invitation (`OISAccepted` item) keeps `member_priv_key` and reads the link: if its own entry is current, it revokes it (`CLCRevokeOwner self`); if its memberId is in `revokedOwners`, it is resolved; if the entry is absent, it re-reads after `rosterRequestTimeout` and at main-app chat starts, until the entry appears (then revokes) or `rosterAbandonTTL` passes since the acceptance. Then it erases the key and sets the item to `OISClosed`. An owner device whose `OISAccepted` invitation was accepted less than `rosterAbandonTTL` ago applies an admin's removal of itself (sent while it was a member, racing the promotion) and runs this. This settles an admin's removal racing the promotion in any order of the swap, the removal and the commit.
|
||||
- Mutual removal: the first swap removes the other's key; the second gets `AUTH`, or `CONFLICT` and a read showing itself revoked.
|
||||
|
||||
### 4.9 Leaving
|
||||
|
||||
`APILeaveGroup` by an owner in multi-owner mode (single-owner channels keep `Commands.hs:3233-3277`):
|
||||
|
||||
1. Read. Refuse with `CLELastOwner` if no other current owner. If `roster.author` is this owner at its stored version and no relay has `SENT` the latest `x.grp.roster` this device sent (`msg_deliveries`), re-send the stored blob only, with no event (`sendStoredRoster`; an event at that version would mark it complete on members that never got its changes), and wait up to `channelOwnerWaitTimeout` (30 s), without the group lock (`SENT` is processed under it), for `SENT` of the blob's last chunk to a relay; else refuse with `CLERelaysUnreachable`. In channels a message to a relay that is not ready is dropped (`memberSendAction`, `Internal.hs:2840-2846`).
|
||||
1. Read. Refuse with `CLELastOwner` if no other current owner. If `roster.author` is this owner with `stored_roster_version` below `roster.version` (its claim landed but was not stored), re-sign first (§4.10 step 1) and continue with the new version. If `roster.author` is this owner at its stored version and no relay has `SENT` the last chunk of its `x.grp.roster` at `stored_roster_version` (`msg_deliveries`; or there is no such message), re-send the stored blob only, with no event (`sendStoredRoster`; an event at that version would mark it complete on members that never got its changes), and wait up to `channelOwnerWaitTimeout` (30 s), without the group lock (`SENT` is processed under it), for `SENT` of the blob's last chunk to a relay; else refuse with `CLERelaysUnreachable`. In channels a message to a relay that is not ready is dropped (`memberSendAction`, `Internal.hs:2840-2846`).
|
||||
2. `writeChannelLink` with `CLCRevokeOwner self` (own entry kept, key dropped); the last-owner check runs on every attempt.
|
||||
3. Complete the departure, only while the membership is current: `x.grp.leave` to relays; delete member connections waiting for delivery; membership `GSMemLeft`; erase `member_priv_key` (a later load generates an unrelated key, `Store/Shared.hs:750-762`); the creator replaces `root_priv_key` with the public key and deletes its group link (`deleteGroupLinkIfExists`: its `DEL` gets `AUTH` and deletes locally).
|
||||
|
||||
@@ -224,24 +224,25 @@ A crash after step 2 is completed by the next read (§4.4 step 3). A repeated le
|
||||
1. If `r > f`: if the read author is this owner (its claim `r` was lost before it was stored or sent), or the author is departed and the abandonment rule holds (below), claim from its own state; otherwise send `XGrpRosterRequest` with its `stored_roster_version` to every current relay and refuse with `CLERosterSyncing`.
|
||||
2. `RCChange`: any target role ≠ its start role → `CLEChangedByOwner`.
|
||||
3. Build the blob at `r + 1` from current roster members with the delta (`buildGroupRoster (applyRosterDelta …)`, `Internal.hs:2629-2654`); `roster = {r + 1, SHA-512 of the blob, self}`.
|
||||
4. Callback: `setOwnGroupRoster` sets `roster_version`, `applied_complete_roster_version`, `stored_roster_version` to `r + 1`, `roster_blob`, sender = own membership (header columns NULL); `sendStoredRoster`; return `r + 1` for the events.
|
||||
4. Callback, only on `LWWritten` (an unchanged link claimed nothing; return `Nothing`): `setOwnGroupRoster` sets `roster_version`, `applied_complete_roster_version`, `stored_roster_version` to `r + 1`, `roster_blob`, sender = own membership (header columns NULL); `sendStoredRoster`; return `r + 1` for the events.
|
||||
|
||||
Single-owner channels keep `broadcastRoster`. Owners outside the roster: `processRosterEntries` (`Subscriber.hs:3610-3630`) skips current owners, the user's own membership included, and departed members; `xGrpMemRole` ignores owner targets and the owner role; `APIMembersRole` refuses owner targets and the owner role in channels.
|
||||
|
||||
**Stored blob.** `getOwnStoredRoster` returns `(stored_roster_version, roster_blob)`; `sendStoredRoster` sends `XGrpRoster {version, sha512 of the bytes}` and the chunks of those exact bytes (`sendRoster`'s steps without re-encoding, `Internal.hs:2665-2681`). In multi-owner mode a relay gets a roster only this way and only when `stored_roster_version` equals the read `roster.version` (an owner's complete frontier can advance from another owner's events without the blob, so it is not the test); otherwise the owner requests the roster with its `stored_roster_version` (relays serve when theirs is greater) and sends after completion. This covers relay set-up (`Subscriber.hs:937-942`: in multi-owner mode it creates no version; a channel with none first claims version 0 with `RCResign`), the acknowledgement (`xGrpRosterAck`, `:3654-3672`: compared with the version this owner sent that relay; a lower one re-sends once per newer stored version), and every re-send.
|
||||
**Stored blob.** `getOwnStoredRoster` returns `(stored_roster_version, roster_blob)`; `sendStoredRoster` sends `XGrpRoster {version, sha512 of the bytes}` and the chunks of those exact bytes (`sendRoster`'s steps without re-encoding, `Internal.hs:2665-2681`), and records the version in each relay member's `roster_served_version` (unused on owners today). In multi-owner mode a relay gets a roster only this way and only when `stored_roster_version` equals `link_roster_version`, the commitment last read or written (§4.4 step 6; an owner's complete frontier can advance from another owner's events without the blob, so it is not the test); otherwise the owner requests the roster with its `stored_roster_version` (relays serve when theirs is greater) and sends after completion. This covers relay set-up (`Subscriber.hs:937-942`: in multi-owner mode it creates no version; a channel with none first claims version 0 with `RCResign`), the acknowledgement (`xGrpRosterAck`, `:3654-3672`: compared with that relay's `roster_served_version`; a lower one re-sends once per newer stored version), and every re-send.
|
||||
|
||||
**Departed authors.** A blob whose author is departed is accepted only if its version and digest equal `link_roster_version`/`link_roster_digest`:
|
||||
|
||||
- `xGrpMsgForward` (`Subscriber.hs:4019-4020`) passes an `XGrpRoster` from a departed owner to `xGrpRoster` on an owner device; other content stays ignored.
|
||||
- Relay direct path: from a departed owner's connection only `XGrpLeave`, `XGrpRoster` and the `BFileChunk`s of a roster transfer started from that connection (`bFileChunkGroup`, `:2626-2633`) are processed.
|
||||
- Owners and relays start the transfer (`xGrpRoster`, `:3503-3540`) and check the commitment at completion (`rosterCompletion`, `:3549-3586`); a mismatch triggers one link read (forked; the blob stays pending until it returns) and is applied if the fresh commitment matches.
|
||||
- Owners and relays start the transfer (`xGrpRoster`, `:3503-3540`) and test the blob at completion (`rosterCompletion`, `:3549-3586`). A departed author's blob needs its version and digest equal to the commitment. On a relay that checks events (§4.13), a current author's blob above `link_roster_version` needs its version at most `link_roster_version` and its author still a current owner. A failed test triggers one link read (forked; the blob stays pending until it returns), and the blob is applied if the test passes after it.
|
||||
- On an owner, a forwarded `XGrpRoster` header that is a duplicate (a relay serving it again reuses its shared msg id; `saveGroupFwdRcvMsg` drops duplicates, `Internal.hs:2958-2959`) still goes to `xGrpRoster` when no transfer from that relay is pending, so a failed transfer can be fetched again.
|
||||
- Relays answer a current owner's `XGrpRosterRequest` whenever stored > requested (`xGrpRosterRequest`, `:3679-3685`), without the served-version bound kept for members.
|
||||
|
||||
**Re-sign** (`CLCRoster RCResign`), a follow-up when the read author is departed or is this owner with a lower stored version (`applyChannelLinkData` step 6), after applying another owner's departure (`x.grp.leave`, `x.grp.mem.del` of an owner, including this device's own removal of the roster author), and after completing a departed author's blob: if `f >= r` claim `r + 1` from the current roster; if step 1 allows, claim from its own state; else request and stop. A re-sign writes only when its read shows the author departed, or itself with a lower stored version, or (at chat start, below) itself with its latest roster undelivered; otherwise, e.g. after another owner re-signed first, it makes no write. It runs only on these triggers and the abandonment re-check, at most one pending per channel (`linkFollowUps`). After a claim it sends the blob and one `x.grp.mem.role` at `r + 1`: the current role of the first current non-owner, non-relay member with a key, by memberId. Any event at `r + 1` makes members that dropped the departed author's events see a gap and fetch the blob (`applyAtRosterVersion` acts before the target lookup, `Subscriber.hs:3410-3424`). The swap lets one owner claim; the others' reads then show a current author.
|
||||
**Re-sign** (`CLCRoster RCResign`), a follow-up when the read author is departed or is this owner with a lower stored version (`applyChannelLinkData` step 6), after applying another owner's departure (`x.grp.leave`, `x.grp.mem.del` of an owner, including this device's own removal of the roster author), and after completing a departed author's blob: if `f >= r` claim `r + 1` from the current roster; if step 1 allows, claim from its own state; else request and stop. A re-sign writes only when its read shows the author departed, or itself with a lower stored version, or no roster (relay set-up, below), or (undelivered roster, below) itself with its stored roster undelivered; otherwise, e.g. after another owner re-signed first, it makes no write. It runs only on these triggers, the abandonment re-checks and the undelivered-roster check, at most one pending per channel (`linkFollowUps`). After a claim it sends the blob and one `x.grp.mem.role` at `r + 1`: the current role of the first current non-owner, non-relay member with a key, by memberId. Any event at `r + 1` makes members that dropped the departed author's events see a gap and fetch the blob (`applyAtRosterVersion` acts before the target lookup, `Subscriber.hs:3410-3424`). The swap lets one owner claim; the others' reads then show a current author.
|
||||
|
||||
**Abandonment.** A version `r` by a departed author is abandoned (claim `r + 1` from own state; nobody received `r`) when `link_roster_seen_at` is older than `rosterAbandonTTL` (1 day) and a request sent to every current relay at least `rosterRequestTimeout` (5 minutes) earlier brought no blob at `r`: the re-sign sends that request and forks a re-check after the timeout. An owner that also missed earlier versions of that author (f < r − 1) drops them too, though some devices received them: only a departed author's committed version is accepted, and the alternative is a channel whose roles can never change again.
|
||||
**Abandonment.** A version `r` by a departed author is abandoned (claim `r + 1` from own state; nobody received `r`) when `link_roster_seen_at` is older than `rosterAbandonTTL` (1 day) and a request sent to every current relay at least `rosterRequestTimeout` (5 minutes) earlier brought no blob matching the commitment. A re-sign that lacks the blob sends that request; before the TTL it forks one re-check at `link_roster_seen_at + rosterAbandonTTL`, which requests again and forks the last re-check `rosterRequestTimeout` later. Chat start runs the re-sign again. An owner that also missed earlier versions of that author (f < r − 1) drops them too, though some devices received them: only a departed author's committed version is accepted, and the alternative is a channel whose roles can never change again.
|
||||
|
||||
**Chat start** (main app): an owner that is the link's roster author at its stored version, when no relay has `SENT` its latest `x.grp.roster` (its blob and events were dropped), makes an `RCResign` claim at `r + 1`: the blob and the event at `r + 1` make members that missed `r` see a gap and fetch. It never sends an event at a version it is re-sending.
|
||||
**Undelivered roster.** At main-app chat start, when a relay connection becomes ready (`CON`, `Subscriber.hs:889`) and when it resumes after a full queue (`SENT` with `continueSending`, `:1197-1199`): an owner that is the link's roster author at its stored version, when no relay has `SENT` the last chunk of its `x.grp.roster` at `stored_roster_version` (or there is no such message; its blob and events were dropped), makes an `RCResign` claim at `r + 1`: the blob and the event at `r + 1` make members that missed `r` see a gap and fetch. It never sends an event at a version it is re-sending.
|
||||
|
||||
### 4.11 Subscriber count
|
||||
|
||||
@@ -250,19 +251,30 @@ In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:16
|
||||
### 4.12 Profile edits and the link counter
|
||||
|
||||
- In multi-owner mode `runUpdateGroupProfile` (`Commands.hs:4164-4192`, behind `APIUpdateGroupProfile`, `APISetPublicGroupAccess` and the other profile commands) calls `writeChannelLink` with `CLCProfile base new`, where `base` is the local profile when the command starts; `CLCProfile` compares profiles as `J.toJSON` values. The callback writes the local profile, then `XGrpInfo` goes out with the written `linkVersion`. The command API is unchanged.
|
||||
- The app's profile editors refuse to save, with the "changed by another owner" alert, when the channel's profile in the app model changed while the editor was open (an `XGrpInfo` from another owner updates the model); comparing the app's own model on both sides avoids false conflicts from fields the apps do not model. The core check covers a change that reached the link but not yet this device.
|
||||
- `xGrpInfo` (`Subscriber.hs:3840-3865`): an event with `linkVersion` below `link_data_version` is not applied; a higher one is stored. Relays forward every `XGrpInfo` and gate only their own apply.
|
||||
- The app's profile editors refuse to save, with the "changed by another owner" alert, when the channel's profile in the app model changed while the editor was open (an `XGrpInfo` from another owner updates the model; fields the apps do not model cause no false conflict). The core check covers a change that reached the link but not yet this device.
|
||||
- `xGrpInfo` (`Subscriber.hs:3840-3865`): an event with `linkVersion` below `link_data_version` is not applied; a higher one is stored. Relays forward one below their `link_data_version` without applying it, and one above only after their link check (§4.13).
|
||||
|
||||
### 4.13 Relays in a multi-owner channel
|
||||
|
||||
- **Irreversible owner actions checked against the link.** In a channel where the relay holds at least two owner rows (any status), it applies and forwards `x.grp.mem.del` of an owner and `x.grp.del` only after a fresh proxied read shows the sender a current chain owner and, for a removal, the target revoked; for `x.grp.del` a read failing with `AUTH` also passes (the queue is gone, and only a current recipient key can delete it). The check never blocks the event thread: the relay saves the message, defers its ACK (`withAckMessage`, `Subscriber.hs:1797-1832`, gains a "defer" result; `ackMessageAsync` acks later), and forks the read, retrying temporary errors with backoff. The deferred ACK holds that connection's later messages, keeping the sender's order, and a restart redelivers the message. After the read, under the group lock, it applies or rejects the event, creates the delivery task, and acks. Members get these events only through relays and check the sender as today. Owner additions are not checked: an addition made without a link write is undone at the relay's next read (§4.4 step 4), and its owner's deletions and removals fail the check.
|
||||
- **Owner changes and counters checked against the link.** These events wait for one fresh proxied link read and are tested against the state it leaves (`applyChannelLinkData`): every `x.grp.owner.add`, and the others in a channel where the relay holds at least two owner rows (any status):
|
||||
|
||||
| Event | Waits | Applied and forwarded after the read when |
|
||||
|---|---|---|
|
||||
| `x.grp.owner.add` | every one | the member holds a current chain entry with the commit's key, and its `linkVersion` ≤ `link_data_version` |
|
||||
| `x.grp.mem.del` of an owner | every one | the target is revoked |
|
||||
| `x.grp.del` | every one | the sender is a current owner, or the read failed with `AUTH` (the queue is gone, and only a current recipient key deletes it) |
|
||||
| `x.grp.info` with `linkVersion` | above `link_data_version` | `linkVersion` ≤ `link_data_version` and the sender is a current owner |
|
||||
| `x.grp.mem.role`, `x.grp.mem.del` with a roster version | above `link_roster_version` | the version ≤ `link_roster_version` and the sender is a current owner |
|
||||
|
||||
Roster blobs are tested at completion (§4.10). Members and owners get these events only through relays and keep today's checks. A post the new owner sends to a relay still checking its commit is refused there, as a post racing the commit can be today.
|
||||
- **Deferred check.** The relay verifies and parses the message but does not save a waiting event; the other events of a batch are processed as today, and the waiting ones share one read and the message's ACK. It records `(connId, agentMsgId)` in `ChatController.relayLinkChecks :: TMap (ConnId, AgentMsgId) ()`, returns a new "defer" result from `withAckMessage` (`Subscriber.hs:1797-1832`; no ACK), and forks the read, retrying temporary errors with backoff. The deferred ACK holds that connection's later messages, keeping the sender's order. A `MSG` re-notified after an SMP reconnection (same ID, `Agent.hs:3767-3787`) with an entry is ignored. When the read returns, under the group lock: save the event, apply or reject it, create the delivery task, ack (`ackMessageAsync`), delete the entry. After a restart nothing was saved, so the redelivered message is checked again. A message notified 8 times over a day is dropped by the agent (`Agent/Env/SQLite.hs:271-272`), as any message.
|
||||
- **Activation.** The `LINK` handler body (`Subscriber.hs:1437-1477`) becomes `activateGroupRelays :: User -> GroupInfoKeys -> [ShortLinkContact] -> CM ()`, called by the handler and in the callback of a `CLCRelays` write; it reads `group_relays` and the group link row only when present. `CEvtGroupLinkDataUpdated` and `CRGroupRelaysAdded` carry `Maybe GroupLink` (`Controller.hs:862`, `:1035`).
|
||||
- **Publication** after a relay's acknowledgement (`Subscriber.hs:3661-3665`) and a pre-roster relay's connection (`:946-947`): a forked `CLCRelays [link] []`. Main-app chat start publishes acknowledged relays missing from the link.
|
||||
- **Removal** of a relay (`Commands.hs:3149-3153`) and a relay's own leave seen by any owner: `CLCRelays [] [link]`.
|
||||
- **`APIAddGroupRelays`** without a group link row takes the link from `publicGroup.groupLink` (`Commands.hs:2790-2793`). A relay below `multiOwnerVersion` added in multi-owner mode stays invited (its `updateRelayGroupKeys` fails); the owner removes it.
|
||||
- **Owners join relays added by others** from `XGrpRelayNew` (`Subscriber.hs:3881-3886`) and in reconcile. Relay side: `memberJoinRequestViaRelay` (`:1690-1720`) accepts a key-proven join onto a current owner row, as for roster rows, and refuses joins onto departed rows. Owner side: `updatePreparedUserAndHostMembers'` (`Store/Groups.hs:821-844`) and the host `CON` branch (`Subscriber.hs:892-922`) leave the membership and group profile and the joined items alone when the membership is an owner.
|
||||
- **Invitation by any owner.** `updateRelayGroupKeys` (`Store/Groups.hs:2353-2373`) creates an owner row (`createLinkOwnerMember`) for every chain owner without one and applies `revokedOwners`; the inviting-owner check (`Subscriber.hs:4598`) excludes `revokedOwners`.
|
||||
- **Capabilities.** `XGrpRelayCap` (`Subscriber.hs:1103-1105`) from a relay without this owner's `group_relays` row is stored in `group_members.relay_web_domain` (`relayWebDomain` in `GroupMember` JSON); `updateRelayCapabilities` (`Store/Groups.hs:1901-1911`) updates only `group_relays` today. A relay sends `XGrpRelayCap` to an owner when it applies its commit or its join, since `sendRelayCapIfNeeded` (`Internal.hs:2686-2696`) sends only when the group-level domain changes.
|
||||
- **Capabilities.** `XGrpRelayCap` (`Subscriber.hs:1103-1106`) is stored whatever the receiver's role (it can arrive before the commit that makes it an owner); without this device's `group_relays` row, in `group_members.relay_web_domain` (`relayWebDomain` in `GroupMember` JSON; `updateRelayCapabilities`, `Store/Groups.hs:1901-1911`, updates only `group_relays` today). A relay sends `XGrpRelayCap` to an owner when it applies its commit or its join, since `sendRelayCapIfNeeded` (`Internal.hs:2686-2696`) sends only when the group-level domain changes.
|
||||
|
||||
### 4.14 Other guards
|
||||
|
||||
@@ -272,7 +284,7 @@ In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:16
|
||||
- **`APIShareChatMsgContent`** signs with any current owner's member key (`Commands.hs:1238-1240`).
|
||||
- **Connect plan and join** exclude `revokedOwners` from `glOwners`, `verifyLinkOwner` and owner binding (`Commands.hs:4615-4653`, `:4806-4815`); `APIConnectPreparedGroup` creates owners only for non-revoked entries and departed rows for revoked ones (`:2324-2332`).
|
||||
- **History for joiners.** A signed as-channel item whose author is no longer a current owner is served unsigned (`FwdChannel`, `itemForwardMsgs`, `Internal.hs:1540-1548`).
|
||||
- **Destroying.** In multi-owner mode `APIDeleteChat` by an owner (`Commands.hs:1384-1401`) first reads and applies the link; a departed device completes its departure and sends no `x.grp.del`; if the read fails, it proceeds as today. A promoted owner then deletes the link queue with `deleteLinkQueueWithCreds`; the creator keeps `deleteGroupLinkIfExists` (current: `DEL` deletes the queue; departed: `AUTH`, local delete).
|
||||
- **Destroying.** In multi-owner mode `APIDeleteChat` by an owner (`Commands.hs:1384-1401`) first reads and applies the link; a departed device completes its departure and sends no `x.grp.del`; if the read fails, the creator proceeds as today. A promoted owner first deletes the link queue with `deleteLinkQueueWithCreds` and, if that fails, returns the error and deletes nothing; the creator keeps `deleteGroupLinkIfExists` (asynchronous; current: `DEL` deletes the queue; departed: `AUTH`, local delete).
|
||||
|
||||
### 4.15 Main app and chat start
|
||||
|
||||
@@ -294,14 +306,14 @@ Each slice adds its rows: promotions and catch-up with S8–S9, the roster re-se
|
||||
| command | `APIAcceptGroupOwnerInvitation {groupId, chatItemId, allowDirect}` | → `CRChatItemUpdated` |
|
||||
| error | `CEChannelLink {channelLinkError :: ChannelLinkError}` | `CLENotVerified`, `CLERelayVersion`, `CLELinkServerVersion`, `CLEDirectConnection {host, prohibited}`, `CLEChangedByOwner`, `CLERosterSyncing`, `CLELastOwner`, `CLENotOwner`, `CLELinkDataTooLarge`, `CLELinkBusy`, `CLERelaysUnreachable` |
|
||||
| item | `RGEOwnerInvitation {invStatus :: OwnerInvitationStatus}` | `OISPending`, `OISAccepted`, `OISClosed` |
|
||||
| column | `groups.link_data_version INTEGER` | highest `linkVersion` read or written |
|
||||
| column | `groups.link_data_version INTEGER` | highest `linkVersion` read, written, or carried by an applied `x.grp.owner.add` or `x.grp.info` |
|
||||
| column | `groups.link_roster_version INTEGER`, `link_roster_digest BLOB` (`BYTEA`), `link_roster_seen_at TEXT` (`TIMESTAMPTZ`) | last read roster commitment; when its version first appeared |
|
||||
| column | `groups.members_catchup INTEGER NOT NULL DEFAULT 0` | new owner's catch-up pending |
|
||||
| column | `group_members.owner_inv_msg_id BLOB` (`BYTEA`), `owner_inv_accepted INTEGER NOT NULL DEFAULT 0` | promoter: the pending invitation to this member; accepted, promotion to run |
|
||||
| column | `group_members.relay_web_domain TEXT` | relay capability for an owner without the relay's row |
|
||||
| config | `channelLinkWriteAttempts = 5`, `channelCountPublishInterval = 60 s`, `rosterAbandonTTL = 1 day`, `rosterRequestTimeout = 5 min`, `memberCatchupTimeout = 1 min`, `channelOwnerWaitTimeout = 30 s` | `ChatConfig` |
|
||||
| hook | `channelLinkWriteHook :: Maybe (GroupInfo -> IO ())` | `ChatHooks`, between read and swap, outside the group lock; tests |
|
||||
| state | `linkWriters :: TMap GroupId Lock`, `linkFollowUps :: TMap GroupId (Set FollowUp)`, `countPublishPending :: TMap GroupId ()`, `memberCatchups :: TMap GroupId (Int, Maybe MemberId)`, `mainApp :: TVar Bool` | `ChatController` |
|
||||
| hook | `channelLinkWriteHook :: Maybe (GroupInfo -> IO ())` | `ChatHooks`, between a write's read and its apply, outside the group lock; tests |
|
||||
| state | `linkWriters :: TMap GroupId Lock`, `linkFollowUps :: TMap GroupId (Set FollowUp)`, `countPublishPending :: TMap GroupId ()`, `memberCatchups :: TMap GroupId (Int, Maybe MemberId)`, `relayLinkChecks :: TMap (ConnId, AgentMsgId) ()`, `mainApp :: TVar Bool` | `ChatController` |
|
||||
|
||||
One migration `M20261015_channel_multi_owner` with `down_m20261015_channel_multi_owner`, SQLite and Postgres, registered in both `Migrations.hs` and `simplex-chat.cabal`; schema dumps regenerate from tests.
|
||||
|
||||
@@ -318,8 +330,9 @@ One migration `M20261015_channel_multi_owner` with `down_m20261015_channel_multi
|
||||
| Two profile edits | the editor's check and the swap; `linkVersion` | §4.12 | the later is refused if the profile changed since its editor opened; members keep the newer |
|
||||
| A relay added while another is removed | the swap | `CLCRelays` | both kept |
|
||||
| A member removed or leaving while promoted | removal wins; self-revocation | §4.8 | the member ends departed |
|
||||
| A removed owner acting before relays learn | the relay's deferred link check | §4.13 | owner removals and deletion refused; an owner it adds without a link write is undone at the relay's next read; posts and role events possible until the relay learns |
|
||||
| A read older than a promotion applied after its commit | the commit's `linkVersion` | §4.6 | the read is ignored; M stays an owner |
|
||||
| A removed owner acting before relays learn | the relay's deferred link check | §4.13 | owner changes, deletion and counters above the link's refused; posts, and profile and role events at a version the relay holds, possible until it learns |
|
||||
| A relay restart or reconnection during a check | nothing saved before the check; pending checks by message | §4.13 | the redelivered message is checked again or ignored while its check runs |
|
||||
| A read older than a promotion applied after its commit | the commit's `linkVersion`, checked by relays | §4.6, §4.13 | the read is ignored; M stays an owner |
|
||||
| A party that was offline | link data on every read | §4.4 | converges at its next read |
|
||||
| A single-owner write queued before the first promotion | `LSET` refused on a multi-key queue | §4.1 | the late write gets `AUTH`; nothing is overwritten |
|
||||
|
||||
@@ -333,7 +346,7 @@ One migration `M20261015_channel_multi_owner` with `down_m20261015_channel_multi
|
||||
- **Adversary.**
|
||||
- Relay: cannot forge owner-signed events, commits or acceptances; can withhold or reorder as today; cannot substitute a promoted member's key (verified code); can fabricate subscribers in a page, never privileged ones.
|
||||
- Link server: cannot forge link data; a stale read is ignored by `linkVersion`; ignoring the compare-and-swap brings back lost writes and roster divergence, no forgery.
|
||||
- Removed owner: its key leaves the set in the removing write; relays refuse its owner removals and deletion after a link check, and undo an owner it adds without a link write at their next read; owners and relays accept its blobs only when committed; until a relay learns of the removal it can still post and send role events there; with a colluding link server it can serve forged link data to readers.
|
||||
- Removed owner: its key leaves the set in the removing write; after a link check relays refuse its owner changes, its deletion and any counter it sends above the link's; owners and relays accept its blobs only when committed; until a relay learns of the removal it can still post there and send profile and role events at a version the relay holds; with a colluding relay its events reach that relay's members unchecked, and an inflated version among them stops those members' later profile, link and role updates; with a colluding link server it can serve forged link data to readers.
|
||||
- Any current owner can destroy the channel or replace the key set with `RKEY`.
|
||||
- **Privacy.** A promoted owner connects directly to the link server (recipient commands are not proxied) for its user's actions and, as first chain entry, for the count; acceptance names the host and the IP exposure and needs `ADYes` when the settings would route privately; direct-prohibited settings refuse the writes. Link commands use a session of their own; link reads stay proxied. The link server learns when role changes happen. `linkRcvId` and `creatorQueueKey` authorise nothing. The member-page cursor is a memberId, not the relay's row ID.
|
||||
|
||||
@@ -347,16 +360,17 @@ Each slice compiles and passes its tests on its own. simplexmq: `cabal test simp
|
||||
| S2 | simplexmq protocol: v24, `LSWP`, handler, `LSET` refusal on multi-key queues, client, protocol doc | ~110 | server tests |
|
||||
| S3 | simplexmq agent: §4.2 | ~190 | agent tests |
|
||||
| S4 | chat structural: extract `activateGroupRelays`, the channel intro member creation | ~80 | `-m "channels"` green, no behaviour change |
|
||||
| S5 | chat wire and data (inert): migration, link fields, events, versions, errors, commands in record syntax (refusing), config, hook, state; Bot API docs and regenerated files | ~300 | `-m "channels"`, protocol, JSON and "Bot API docs" tests |
|
||||
| S5 | chat wire and data (inert): migration, link fields, events, versions, errors, commands in record syntax (refusing), optional `groupLink` in `CEvtGroupLinkDataUpdated` and `CRGroupRelaysAdded`, config, hook, state; Bot API docs and regenerated files (a later slice that changes an API type regenerates them) | ~300 | `-m "channels"`, protocol, JSON and "Bot API docs" tests |
|
||||
| S6 | link core: locks, `writeChannelLink` and `applyChannelLinkData` with no follow-up kinds yet, reconcile, `currentOwner`, multi-owner mode, `linkVersion`, `XGrpInfo` gating, durable rows, intro guard, relay reads and tolerant invitation, main-app gating | ~250 | T12, existing tests |
|
||||
| S7 | callers: profile check (core and the editors' rule in §6.1), relays (activation, publication, removal, owner joins, capabilities), count publisher and threshold | ~200 | existing tests |
|
||||
| S7 | callers: profile check (core; the editors' rule is in S14–S15), relays (activation, publication, removal, owner joins, capabilities), count publisher and threshold | ~200 | existing tests |
|
||||
| S8 | promotion: invite, accept with the direct-connection check, `promoteOwner` with the stored-blob store and send, commit, M becomes owner (`FUBecomeOwner`), chat-start rows for promotions | ~260 | T1 (without the role change), T2, T5 (profile, relays), T13, T20 |
|
||||
| S9 | member catch-up with watchdog | ~120 | T10 |
|
||||
| S10 | roster: claim, owners outside the roster, stored-blob rules, departed authors, re-sign (`FUResign`), abandonment, relays serving owners, chat-start re-sign | ~250 | T1 role change, T5 roles, T21 |
|
||||
| S11 | removal and leave: removal, removal wins (`FURemovalWins`), own departure (`FUOwnDeparture`), self-revocation, leave with wait, removed device, destroy, relays' deferred checks, departed-owner guard | ~280 | T3 (without the history check), T4, T6–T9, T11, T14–T19, T22 |
|
||||
| S12 | guards: group-link commands, share signing, connect plan, history | ~70 | existing tests, T3's history check |
|
||||
| S13 | iOS (§6.1) | ~400 | host build, manual checks |
|
||||
| S14 | Kotlin, mirroring S13 | ~400 | host build, manual checks |
|
||||
| S10 | roster: claim, owners outside the roster, stored-blob rules, departed authors and duplicate headers, re-sign (`FUResign`), abandonment, relays serving owners, undelivered-roster re-sign | ~260 | T1 role change, T5 roles, T21 |
|
||||
| S11 | removal and leave: removal, removal wins (`FURemovalWins`), own departure (`FUOwnDeparture`), self-revocation, leave with wait, removed device, destroy, departed-owner guard | ~210 | T3 (without the history check), T4, T6–T9, T11, T14–T16, T18, T19, T22 |
|
||||
| S12 | relay checks (§4.13): the deferred check, the events it covers, roster blobs above the commitment | ~130 | T17 |
|
||||
| S13 | guards: group-link commands, share signing, connect plan, history | ~70 | existing tests, T3's history check |
|
||||
| S14 | iOS (§6.1) | ~400 | host build, manual checks |
|
||||
| S15 | Kotlin, mirroring S14 | ~400 | host build, manual checks |
|
||||
|
||||
### 6.1 Apps (iOS and Kotlin, mirrored)
|
||||
|
||||
@@ -400,7 +414,7 @@ Not compiled in the container; built on the host.
|
||||
| T6 | mutual removal: one owner remains |
|
||||
| T7 | the last two owners leave: one refused |
|
||||
| T8 | removal wins: O2 removes subscriber M while O1's promotion of M is held by the hook; M ends departed everywhere |
|
||||
| T9 | O1 demotes the only admin and leaves while O2 is offline; O2 returns, accepts O1's committed blob, re-signs; members that dropped O1's events fetch on the gap; a joiner gets the re-signed roster; the demotion holds |
|
||||
| T9 | O1 demotes the only admin and leaves while O2 is offline; O2 returns, accepts O1's committed blob (and fetches it again when its first transfer is discarded), re-signs; members that dropped O1's events fetch on the gap; a joiner gets the re-signed roster; the demotion holds |
|
||||
| T10 | catch-up: earlier subscribers appear on M with keys (it verifies and promotes one); a restart mid-way restarts and skips held members; a silent relay is replaced after the timeout and no member is skipped |
|
||||
| T11 | count: after the creator leaves, joins and leaves change the published count within the (test-shortened) interval |
|
||||
| T12 | `linkVersion`: a lower `XGrpInfo` is not applied but relays forward it; a stale read does not revert the profile |
|
||||
@@ -408,12 +422,12 @@ Not compiled in the container; built on the host.
|
||||
| T14 | chain dependency: creator → M → N; M leaves (its entry stays as N's signer); N leaves; writes keep validating |
|
||||
| T15 | a relay reads the creator's departure before processing its queued blob and still accepts it |
|
||||
| T16 | a leave stopped after its swap (hook) completes at the next chat start; abandonment after the (test-shortened) TTL claims the next version |
|
||||
| T17 | relays refuse a removed owner's `x.grp.mem.del` of an owner and `x.grp.del` sent after its removal, and undo an owner it adds without a link write at their next check; its link `DEL` gets `AUTH`; while a delayed read holds a relay's check, its other channels keep delivering |
|
||||
| T18 | an admin removes M while O1's promotion of M is held: M's device revokes itself; M ends departed everywhere |
|
||||
| T17 | relays refuse a removed owner's `x.grp.owner.add`, `x.grp.mem.del` of an owner, `x.grp.del`, and `x.grp.info` and role events at versions above the link's, sent after its removal; later profile and role changes by other owners still apply everywhere; its link `DEL` gets `AUTH`; while a delayed read holds a relay's check, its other channels keep delivering; a relay restarted, and one reconnected, during a check of a deletion apply it once |
|
||||
| T18 | an admin removes M while O1's promotion of M is held: M's device revokes itself; M ends departed everywhere; the same when M and every owner apply the commit before the removal |
|
||||
| T19 | destroy by a device that left after its swap sends no `x.grp.del` |
|
||||
| T20 | a read held (hook) across a promotion is applied after the commit: M stays an owner everywhere |
|
||||
| T21 | O2 adds a relay right after O1's role change: the relay gets the current blob, acknowledges and is published |
|
||||
| T22 | relays not ready when O1 demotes an admin: O1's chat start re-signs at the next version and members fetch it; the same with O1 leaving instead (blob only, then O2's re-sign) |
|
||||
| T21 | O2 adds a relay right after O1's role change, and O1 adds one right after its own: each relay gets the current blob, acknowledges and is published |
|
||||
| T22 | relays not ready when O1 demotes an admin: O1's chat start, and without a restart a relay connection becoming ready, re-signs at the next version and members fetch it; the same with O1 leaving instead (blob only, then O2's re-sign) |
|
||||
|
||||
Existing tests that must stay green: the whole `-m "channels"` group, `ProtocolTests`, `JSONTests`, "Bot API docs", schema dump. Final: `cabal test simplex-chat-test` and `simplexmq-test` (Postgres on the host).
|
||||
|
||||
@@ -424,13 +438,11 @@ Existing tests that must stay green: the whole `-m "channels"` group, `ProtocolT
|
||||
- **Riskiest assumption:** members' and relays' unchanged roster protocol converges when versions are claimed through the link. T5, T9, T15, T21 and T22 in S10–S11; S1–S2 first prove the swap's atomicity and size.
|
||||
- The departed-author exception narrows a drop rule that protects members: roster blobs only, on owners and relays, matching a committed digest.
|
||||
- Rollout: link servers (presets included) must run v24, and relays chat v23, before promotion works.
|
||||
- Postgres server tests and app builds run only on the host.
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. The link server must be reachable for every role change in a multi-owner channel (1–3 s). Decided by the user: accepted.
|
||||
2. Consent and key-substitution defence: verified code plus an invitation in the channel. Decided by the user.
|
||||
3. Member list: pulled from one relay, no images. Decided by the user.
|
||||
Questions 1–3, decided by the user: role changes in multi-owner channels need the link server (1–3 s); consent and key-substitution defence by verified code plus an invitation in the channel; member list pulled from one relay without images.
|
||||
|
||||
4. `LSWP` sends only the user data. Recommended: as planned; otherwise owners are capped at about 6.
|
||||
5. `LSET` is refused on queues with several recipient keys (a server behaviour change at v24). Recommended: as planned; it is what stops stale or downgraded single-owner writes from erasing other owners.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ The implementation plan is `2026-10-15-channel-multi-owner-s3min-implementation.
|
||||
| Post, moderate, edit the channel, manage relays, share the link | as the creator does today, from any owner's device |
|
||||
| Make a member an owner | the owner first verifies the member's security code; the member gets an invitation in its "chat with admins" and becomes an owner when it accepts |
|
||||
| Change member, moderator and admin roles | as today; with several owners each change takes 1–3 s, because it is first recorded on the channel's link server |
|
||||
| Remove an owner | any owner can remove another; the removed device can no longer change the link at once, and relays stop accepting its owner actions as soon as they learn of the removal (normally seconds) |
|
||||
| Remove an owner | any owner can remove another; the removed device can no longer change the link at once; relays check its owner actions against the link, and stop accepting its posts as soon as they learn of the removal (normally seconds) |
|
||||
| Leave | any owner can leave while another owner remains; in a channel with several owners the last one cannot leave |
|
||||
| Destroy the channel | any owner, as today |
|
||||
| See the members | a new owner downloads the member list from a relay in the background, without profile pictures |
|
||||
@@ -26,7 +26,7 @@ The channel's link data lives on one SMP server, the link server, which already
|
||||
- **One new server command, `LSWP`.** An owner reads the link data, makes its one change, and writes it back with the hash of what it read. The server accepts the write only if the data is still what the owner read, and in the same step replaces the list of owner keys allowed to write. If another owner wrote in between, the owner reads again and re-applies its change. No write is lost, and adding or removing an owner changes the data and the keys together. The server also refuses the old plain write on a link with several owners, so an old or delayed single-owner write cannot erase them.
|
||||
- **Role changes are numbered on the link server.** The link data records the latest roster version and who made it. An owner claims the next number with the same compare-and-swap before it sends the change. Each number then has one author who had already applied all earlier changes, which is what members and relays expect today; they need no change.
|
||||
- **Consent.** The promoter sends an invitation in the member's support chat; the member accepts; then one write adds it as an owner. The security code check stops a relay that substituted the member's key, because such a relay could also sign the acceptance with the substituted key.
|
||||
- **Irreversible owner actions are checked against the link.** Relays accept an owner's removal or the channel's deletion only after reading the link data in the background, so a device that was removed cannot use them; an owner added without a link write is undone at the relay's next read.
|
||||
- **Owner changes are checked against the link.** Relays accept an owner's addition or removal, the channel's deletion, and a profile or role change numbered beyond what they have read only after reading the link data in the background. A removed device therefore cannot use them, nor freeze later changes with an inflated number.
|
||||
- **Member list and count.** A new owner pulls the member list from a relay page by page, at its own pace (an owner's queue holds 128 messages and a relay drops messages to a full queue, so pushing would lose data). The first current owner in the link's owner list publishes the subscriber count, at most once a minute.
|
||||
|
||||
## Decisions
|
||||
@@ -42,7 +42,7 @@ The channel's link data lives on one SMP server, the link server, which already
|
||||
|
||||
## Trade-offs users notice
|
||||
|
||||
- **Owner actions need the link server.** Role changes, removal, leaving and promotion need it reachable; while it is down an owner can still post, and a channel deletion reaches relays once it is back. A role change right after another owner's waits a few seconds ("catching up, try again").
|
||||
- **Owner actions need the link server.** Role changes, removal, leaving, promotion and, for an owner other than the creator, destroying the channel need it reachable; while it is down an owner can still post, and the creator's deletion of the channel reaches relays once it is back. A role change right after another owner's waits a few seconds ("catching up, try again"). Relays apply owner changes, profile edits and role changes a few seconds later than today, after their check.
|
||||
- **Edits over a changed profile are refused.** If another owner changed the profile after this owner opened the editor, the edit is refused and the current profile shown.
|
||||
- **A new owner's device connects to the link server directly** for its owner actions and, when it is the first owner, for the count. This reveals its IP address to that server's operator. The invitation says so and names the server; if the user's settings route such traffic privately, accepting needs an explicit confirmation, and settings that prohibit direct connections block owner writes. Link reads stay private.
|
||||
- **The link server learns when owners act.**
|
||||
@@ -54,11 +54,11 @@ The channel's link data lives on one SMP server, the link server, which already
|
||||
None of these is a race between honest owners:
|
||||
|
||||
- **A link server that ignores the compare-and-swap** can make honest owners lose writes or diverge on roles. It cannot forge anything.
|
||||
- **Until a relay learns of a removal** (the remover's announcement, normally seconds, or the relay's link check every 15 minutes), the removed device can still post and send role changes through it, and an owner it adds without a link write lasts until the relay's next read. It cannot remove owners or delete the channel there.
|
||||
- **A removed owner that kept a copy of its key**, or a removed creator that kept the root key, can serve forged link data to readers only with a colluding link server.
|
||||
- **Until a relay learns of a removal** (the remover's announcement, normally seconds, or the relay's link check every 15 minutes), the removed device can still post through it and send profile and role changes numbered as ones the relay already has, which the next link read or change replaces. It cannot change owners or delete the channel there.
|
||||
- **A removed owner that kept a copy of its key**, or a removed creator that kept the root key, can serve forged link data to readers only with a colluding link server. With a colluding relay, its messages reach that relay's subscribers unchecked until they learn of the removal; an inflated number among them stops those subscribers' later profile and role updates.
|
||||
- **Any current owner can destroy the channel or lock the others out.** Owners are trusted as the single owner is today; removal is administration, not a defence against a malicious owner.
|
||||
- **A role change can be abandoned** in two failure cases: its author's device is lost in the second between recording and sending it, or it reached only a relay that then failed. Nobody received it. While a lost author is still an owner the others cannot change roles until they remove it; after that, role changes wait up to a day before the lost version is abandoned. An owner that also missed that author's earlier changes drops them with it, although some devices received them.
|
||||
- **An admin removing a member at the moment an owner promotes it** ends with the member removed: the member's device, which keeps its key until this is settled, revokes its own owner entry when it appears, in either order. If the member's device is gone and every owner saw the promotion first, the member stays listed as an owner until an owner removes it, like any lost owner device.
|
||||
- **An admin removing a member at the moment an owner promotes it** ends with the member removed: the member's device, which keeps its key until this is settled, revokes its own owner entry when it appears, in any order. For that, for a day after accepting, the new owner's device accepts an admin's removal of itself. If the member's device is gone and every owner saw the promotion first, the member stays listed as an owner until an owner removes it, like any lost owner device.
|
||||
- **A remover's device that crashes in the instant after the server accepted the removal** sends no announcement; every device applies the removal at its next read of the link, and relays at their next check.
|
||||
- **Blocking and unblocking a member is not ordered** between owners and moderators, as today.
|
||||
- **Downgrades.** A downgraded server loses link data written since its last start (owner keys are kept), and multi-owner writes stop until it is upgraded. A creator downgraded to an older app cannot change the link.
|
||||
@@ -72,4 +72,4 @@ None of these is a race between honest owners:
|
||||
|
||||
## Size
|
||||
|
||||
About 2,300 lines of production Haskell: ~470 in simplexmq (server command and refusal, three queue stores, store log, client, agent) and ~1,810 in chat. About 400 lines per app platform. Five new chat events and one changed; five optional link-data fields; eight columns and no new table; one SMP command and one SMP error.
|
||||
About 2,350 lines of production Haskell: ~470 in simplexmq (server command and refusal, three queue stores, store log, client, agent) and ~1,880 in chat. About 400 lines per app platform. Five new chat events and one changed; five optional link-data fields; eight columns and no new table; one SMP command and one SMP error.
|
||||
Reference in new issue
Block a user