plans: channel multi-owner, review round 3 fixes

This commit is contained in:
spacedandy-agents-simplex committed 2026-10-16 15:00:14 +00:00
1 parent 52ffef9e17
commit 245ee4ccea
2 files changed
+26 -31

No files matched your search

@@ -6,7 +6,7 @@ The overview, `2026-10-15-channel-multi-owner-s3min.md`, says what owners can do
## 1. Goal and acceptance criteria
A channel has several equal owners. Any owner can do what the single owner does today; owners are added with the member's consent, can remove each other and can leave; concurrent owner actions are ordered by the link server, so none is lost, overwritten unseen or reverted. Owners trust each other as the single owner is trusted today; the design protects against malicious relays and against a link server that withholds or serves stale data.
A channel has several equal owners. Any owner can do what the single owner does today; owners are added with the member's consent, can remove each other and can leave; concurrent owner actions are ordered by the link server, so none is lost, overwritten unseen or reverted. Owners are trusted as the single owner is today; the design protects against malicious relays and a link server that withholds or serves stale data.
| # | Criterion | Tests (§7) |
|---|---|---|
@@ -44,7 +44,7 @@ The link server orders everything owners share. One new SMP command compares the
- **Types and error** (`Protocol.hs`): `newtype LinkDataHash = LinkDataHash ByteString`, the SHA-256 (32 bytes) of the stored `EncDataBytes` contents (nonce and ciphertext, no wire length prefix). `CONFLICT` added to `ErrorType` (string and binary encodings `:1605-1703`, `:2145-2182`; JSON by TH); only `LSWP` returns it and it is not delayed like `AUTH`.
- **Command:** `LSWP :: LinkDataHash -> Maybe (NonEmpty RcvPublicAuthKey) -> EncUserDataBytes -> Command Recipient`, tag `"LSWP"`, `e (LSWP_, ' ', h, keys_, ud)`, parsed `LSWP <$> _smpP <*> smpP <*> smpP`. The fixed data and link ID are not sent; the server keeps them. A transmission (16,365 bytes, `Protocol.hs:2359-2360`) carries the 13,826-byte user data and up to 52 Ed25519 keys; an `LSET`-shaped command would fit 6.
- **Handler** (`Server.hs`, near `:1574`): `LSWP h ks_ ud -> withQueue $ \q qr -> checkMode QMContact qr $ OK <$$ liftIO (swapQueueLinkData (queueStore ms) q h ks_ ud)`. Not in the proxy allow-list (`:2198-2211`).
- **`LSET` on a queue with more than one recipient key gets `AUTH`** (`Server.hs:1575-1579`), checked inside the store's transaction as the swap checks its hash, not on the handler's snapshot, which a concurrent `LSWP` can change; store-log replay of `CreateLink` and `LKEYS` skips the check. Only multi-owner links have several keys (nothing sends `RKEY` today), so a stale single-owner write, or a creator downgraded to an app without `LSWP`, cannot overwrite multi-owner link data.
- **`LSET` on a queue with more than one recipient key gets `AUTH`** (`Server.hs:1575-1579`), checked inside the store's transaction as the swap checks its hash, not on the handler's snapshot, which a concurrent `LSWP` can change; store-log replay of `CreateLink` and `LKEYS` skips the check. Only multi-owner links have several keys (nothing sends `RKEY` today), so stale or downgraded single-owner writes cannot overwrite multi-owner data.
- **Store method** in `QueueStoreClass` (`QueueStore/Types.hs:30-55`): `swapQueueLinkData :: s -> q -> LinkDataHash -> Maybe (NonEmpty RcvPublicAuthKey) -> EncUserDataBytes -> IO (Either ErrorType ())`. No link data → `AUTH`; hash differs → `CONFLICT`; else store `(fixed, ud)` under the same link ID and, when given, the key set. The swap, the cached record and the log lines run under `withQueueRec` (`Postgres.hs:760-762`; the queue lock, real in the journal store).
- STM (`QueueStore/STM.hs`): one `atomically` over `readQueueRec`, then the log. The STM message store's queue lock is a no-op (`MsgStore/STM.hs:64`); log order rests on the next swap needing this swap's hash, a network round trip away.
- Postgres (`QueueStore/Postgres.hs`): one `withDB` transaction: `SELECT link_id, fixed_data, user_data FROM msg_queues WHERE recipient_id = ? AND deleted_at IS NULL FOR UPDATE`, compare in Haskell, `UPDATE msg_queues SET user_data = ? [, recipient_keys = ?] WHERE recipient_id = ?`; then the cached record gets the real bytes and keys. The row lock orders swaps in the Postgres-only store, which has no cache and a no-op queue lock (`MsgStore/Postgres.hs:84`, `:100`); elsewhere the cache holds placeholder link bytes (`Postgres.hs:729`).
@@ -67,7 +67,7 @@ Every owner, the creator included, writes with explicit credentials: `LinkOwnerC
| `linkServerPrivateRouted :: AgentClient -> UserId -> SMPServer -> AM Bool` | the settings route to this server privately and the connection is not IP-protected (`shouldUseProxy`, `ipAddressProtected`, `Agent/Client.hs:1190-1211`) |
- **Async read with the hash.** `LDATA` gains a `LinkDataHash` field (`Agent/Protocol.hs:407`); `getConnShortLink'` computes it from the bytes it read (`:1339`), the sync `getConnShortLink` drops it, and the chat match (`Subscriber.hs:1245`) and agent tests take the field. `getConnShortLinkAsync` is unchanged.
- **Async swap command.** `ACommand` gains `LSWP LinkOwnerCreds LinkDataHash (Maybe (NonEmpty RcvPublicAuthKey)) UserContactData` (`Agent/Protocol.hs:466-475`), tag `"LSWP"`, `strEncode` with the keys as `C.StoredPrivateKey` and the user data last, since its parser consumes the rest (`:2075-2079`); worker branch `withServer' . tryCommand $ setShortLinkSwap' c NRMBackground userId creds h ks_ ud >> notify OK` (as `LSET`, `Agent.hs:2255-2258`), reporting `TEVersion` as `ERR` instead of retrying it as a host error (`Agent/Client.hs:1627-1637`). The keys stay in the command table until it completes or its mailbox connection is deleted. Commands are blobs without a version (`Agent/Store.hs:509-520`): no migration.
- **Async swap command.** `ACommand` gains `LSWP LinkOwnerCreds LinkDataHash (Maybe (NonEmpty RcvPublicAuthKey)) UserContactData` (`Agent/Protocol.hs:466-475`), tag `"LSWP"`, `strEncode` with the keys as `C.StoredPrivateKey` and the user data last, since its parser consumes the rest (`:2075-2079`); worker branch `withServer' . tryCommand $ setShortLinkSwap' c NRMBackground userId creds h ks_ ud >> notify OK` (as `LSET`, `Agent.hs:2255-2258`), reporting `TEVersion` as `ERR` instead of retrying it as a host error (`Agent/Client.hs:1627-1637`). Commands are blobs without a version (`Agent/Store.hs:509-520`): no migration.
- **Link session.** `getConnShortLinkForWrite` when not proxied, the swaps, `DEL` and the version check use one transport session per link, `(userId, server, Just linkId)` (`withClient`/`withLogClient`, `Agent/Client.hs:1153-1159`), not the one shared per user and server (`mkTSession`, `:1442-1444`), which would tie them to the user's other queues there. Other reads are unchanged.
- **Direct connections.** The creds calls and `linkServerSupportsSwap` fail with `CMD PROHIBITED` when `linkServerPrivateRouted` holds and the agent's policy allows no direct fallback (`directAllowed`, `Agent/Client.hs:1204-1208`). Owner validation fails with `AGENT A_LINK`.
- `Crypto/ShortLink.hs`: extract and export `mkOwnerAuth :: OwnerId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> OwnerAuth`; `newOwnerAuth` (`:81-85`) uses it.
@@ -112,8 +112,8 @@ Callers never hold the group lock across this call. In multi-owner mode only, co
**Link sync**, the background path: `enqueueLinkSync :: User -> GroupInfo -> LinkSyncKind -> CM ()` (`LSSync | LSResign`, §4.10), main app only (§4.15). The count timer's sync is skipped while a sync read row for the mailbox, under 10 minutes old, is pending; other triggers always enqueue. The mailbox is the owner's `ConnReady` connection to a current relay (an `ERR` does not fail it, `Subscriber.hs:179`; its events take the group lock), preferably one whose receive queue is not on the link server, as the agent runs one worker per connection and server (`Agent.hs:2226-2228`), then the lowest member ID; without one there is no sync until the next trigger. Deleting the mailbox connection, as departure does, deletes its pending commands and their keys once the agent completes it (a swap that runs first gets `AUTH`). New `CommandFunction`s (`Types.hs:2162-2225`): `CFChannelLinkSync`, `CFChannelLinkResign` (expect `LDATA_`), `CFChannelLinkSwap` (expects `OK_`).
1. `createCommand db user (Just mailbox) fn`, then `getConnShortLinkAsync` with the mailbox (`Internal.hs:3160-3165`).
2. `LDATA` continuation (`processGroupMessage`, `Subscriber.hs:1245`, under the group lock): decode; `channelLinkWriteHook`; `applyChannelLinkData`; if this device is departed, stop. Run the callbacks the read shows due, then apply each pending change in turn (`applyLinkChange`; a refusal is handled per change). If the result differs from the read: key set, creator fields and `linkVersion` as in steps 3–4, `createCommand … CFChannelLinkSwap`, `setShortLinkSwapAsync` with the read's hash.
3. Swap continuation (`Subscriber.hs:1234-1236`, `:1316-1320`, dispatched on the function before any view event): `OK`, `CONFLICT` or `AUTH` → an `LSSync` (whose read runs the callbacks) while `ChatController.linkSyncSwaps :: TMap GroupId Int`, counting this chain's swaps, is below `channelLinkWriteAttempts`; only a trigger resets it, so a link server refusing swaps, or accepting them and serving old data, cannot keep a device looping. `CONFLICT` and `AUTH` make no view event; a landed swap the agent retried after a network error also gets `CONFLICT`. Other `ERR`: a view event, stop.
2. `LDATA` continuation (`processGroupMessage`, `Subscriber.hs:1245`, under the group lock): decode; `channelLinkWriteHook`; `applyChannelLinkData`; if this device is departed, stop. Run the callbacks the read shows due, then apply each pending change in turn (`applyLinkChange`; a refusal is handled per change). If the result differs from the read and `ChatController.linkSyncSwaps :: TMap GroupId Int`, this channel's swaps since its last trigger, is below `channelLinkWriteAttempts`: key set, creator fields and `linkVersion` as in steps 3–4, `createCommand … CFChannelLinkSwap`, `setShortLinkSwapAsync` with the read's hash.
3. Swap continuation (`Subscriber.hs:1234-1236`, `:1316-1320`, dispatched on the function before any view event): `OK`, `CONFLICT` or `AUTH` → an `LSSync`, whose read runs the callbacks; only a trigger resets the swap count, so a link server refusing swaps, or accepting them and serving old data, cannot keep a device looping. `CONFLICT` and `AUTH` make no view event; a landed swap the agent retried after a network error also gets `CONFLICT`. Other `ERR`: a view event, stop.
Only the command row is carried between steps; every decision comes from the latest read and the database, so a lost or repeated agent event (`Agent.hs:2459`, `:2467-2470`) or a crash between steps is absorbed by the next trigger or chat start.
@@ -130,14 +130,14 @@ Only the command row is carried between steps; every decision comes from the lat
Triggers: acceptance (§4.6); removal wins (§4.8); a relay's acknowledgement or own leave (§4.13); the count timer (§4.11); a relay connection ready or resuming, relay set-up (§4.10, `LSResign`); a user write or channel open whose read shows pending work; chat start (§4.15).
Chain rules: signing order is kept (`validateLinkOwners`); the creator signs new entries with the root key (`Commands.hs:2757`), others with their member key (`mkOwnerAuth`); a revoked entry is dropped once no current entry depends on it, transitively, except the writer's own entry in its leave write (a non-root writer must be in the chain, `Agent/Protocol.hs:1996-2001`). An entry is about 124 bytes of the 13,784-byte user data.
Chain rules: signing order is kept (`validateLinkOwners`); the creator signs new entries with the root key (`Commands.hs:2757`), others with their member key (`mkOwnerAuth`); a revoked entry is dropped once no current entry depends on it, transitively, except the writer's own entry in its leave write (a non-root writer must be in the chain, `Agent/Protocol.hs:1996-2001`).
**`applyChannelLinkData`**, under the group lock, on every channel link read: members at channel open (`APIGetUpdatedGroupLinkData`, `Commands.hs:1959-1972`) and connect plan (`:4624`, `:4651`); owners at channel open, in syncs and in writes; relays at invitation (`getLinkDataCreateRelayLink`, `Subscriber.hs:4585-4606`) and in relay checks (`checkRelayServedGroups`, `Commands.hs:6001-6020`); joiners in `APIConnectPreparedGroup` (`Commands.hs:2303-2333`).
1. A read whose `linkVersion` (absent = 0) is below `link_data_version` is ignored; else store it.
2. Profile and count as `updateGroupFromLinkData` (`Internal.hs:1633-1654`).
3. `revokedOwners`: each held member becomes departed (`GSMemLeft`) at whatever role it is held; unknown memberIds get departed rows. A relay keeps a departed owner's connection (§4.13). Own memberId listed while the membership is current: complete the departure (§4.9 step 3).
4. Non-revoked chain entries: a member not held as owner becomes owner with `ownerKey` (created with a placeholder profile if unknown), a departed one included, so non-owners follow the link; on an owner a departed one instead enqueues a link sync (§4.8); its own entry with its own key and a current membership: become owner (§4.6); with another key: alert item, stay a subscriber. A read only adds owners and marks revoked ones departed, so one older than a promotion cannot undo it.
4. Non-revoked chain entries: a member not held as owner becomes owner with `ownerKey` (created with a placeholder profile if unknown), a departed one included (`GSMemUnknown`, §4.3), so non-owners follow the link; owners instead revoke a departed one (§4.8); its own entry with its own key and a current membership: become owner (§4.6); with another key: alert item, stay a subscriber. A read only adds owners and marks revoked ones departed, so one older than a promotion cannot undo it.
5. `roster` → `link_roster_version`; `link_roster_seen_at` = now when the version changes.
6. Owners: join each listed relay without a relay member (`connectToRelayAsync`, `Internal.hs:1606-1616`); `RSInactive` for active `group_relays` rows missing from the list (as `Subscriber.hs:1466-1477`). When the read `roster.version` is above `stored_roster_version`, `XGrpRosterRequest` to every current relay (§4.10).
@@ -179,9 +179,9 @@ It sends `x.grp.owner.inv {memberId}` and stores the message's shared msg id in
2. `CLCAddOwner M (mkOwnerAuth …)`.
3. Callback, when a read shows M's entry with M's key while M's `owner_inv_accepted = 1` (a repeated read finds it cleared and sends nothing): clear `owner_inv_msg_id` and `owner_inv_accepted`; M's role owner (its key is the chain key), `owner_since` = now; send `x.grp.owner.add {memberId, memberKey, profile}` with M's redacted profile (`redactedMemberProfile`, `Internal.hs:1361`); item `SGEMemberRole … GROwner`; `CEvtMemberRole`.
**Commit.** Relays, members and owners apply `x.grp.owner.add` when the sender is a current owner and `memberId` is not held as departed: role owner, `member_pub_key = memberKey` (warning if it replaces a different key), the member created from `profile` if unknown, `owner_since` = the commit's arrival, the broker timestamp of the message that brought it (`brokerTs`, set by this device's own queue server, `Subscriber.hs:4009`; not the relay-set `fwdBrokerTs`), the role item as `xGrpMemRole` creates it. A relay sends its capabilities (`XGrpRelayCap`) to the new owner. An owner holding `memberId` as departed enqueues a link sync (§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.** Relays, members and owners apply `x.grp.owner.add` when the sender is a current owner and `memberId` is not held as departed: role owner, `member_pub_key = memberKey` (warning if it replaces a different key), the member created from `profile` if unknown, `owner_since` = the commit's arrival (§4.8), the role item as `xGrpMemRole` creates it; relays send their capabilities (§4.13). An owner holding `memberId` as departed enqueues a link sync (§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, `owner_since` = the commit's arrival, or now when it reads its entry first, item `RGEUserRole GROwner`, `members_catchup = 1`, member catch-up (§4.7). It stores no credential: each write builds them.
**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 them.
### 4.7 New owner's member list
@@ -194,13 +194,13 @@ It sends `x.grp.owner.inv {memberId}` and stores the message's shared msg id in
- **Command.** `APIRemoveMembers` with a current owner as the only target, by a current owner, in a channel: `writeChannelLink` with `CLCRevokeOwner X`; callback: mark X removed (rows kept, §4.14), send `x.grp.mem.del X False Nothing` (`deleteMemsSend`, `Commands.hs:3191-3229`). Removing oneself is a leave. After a crash between swap and callback, parties learn from link reads.
- **Receivers** apply it as today (`checkRole`, `Subscriber.hs:3783-3786`); a relay forwards it to X first, then deletes X's connection (`:3755-3761`).
- **The removed owner's device** applies it on receipt (own-membership branch, `Subscriber.hs:3734-3746`), as it does a read showing itself revoked (§4.4 step 3): membership removed, keys erased (§4.9 step 3), catch-up stopped.
- **A removal racing a promotion** reaches parties in either order. One that applied the removal refuses the commit (§4.6); one that applied the commit applies an owner's removal of an owner as usual, and an admin's removal arriving less than `promotionRaceWindow` (1 day) after `owner_since` as an owner's (`checkRole` gains this case in both branches of `xGrpMemDel`), on every party. Both are arrivals at the party's own queue server, nearly equal everywhere; a relay can only delay its copy, and the first copy wins. Later removals, and owners learned from the chain, are refused as today. Owners revoke an owner so removed (below), and any party that decided otherwise, the removing admin's device included, follows the link at its next read (§4.4 step 4).
- **Removal wins.** An owner that holds M departed and sees M as a non-revoked chain entry (a commit or a read), or applies the removal or leave of a member it held as an owner, enqueues a link sync, which revokes M if the read still lists it. No announcement: the removal itself reached every party.
- **A removal racing a promotion** reaches parties in either order. One that applied the removal refuses the commit (§4.6); one that applied the commit refuses an admin's removal of an owner as today (`checkRole`, `Subscriber.hs:3783-3786`), except other owners: they apply one arriving less than `promotionRaceWindow` (1 day) after `owner_since` (unset for owners learned from the chain; `checkRole` gains this case in `xGrpMemDel`'s other-member branch) and revoke the member (below). Both times are arrivals at the owner's own queue server, the outer `brokerTs` of `xGrpMsgForward` (`Subscriber.hs:4009`), which the commit and `xGrpMemDel` must get instead of the relay-set `msgTs` (`:4058`); a relay can only delay its copy, and the first copy wins. A relay that applied the removal first forwarded it to the owners at once. Every other party, the removing admin's and M's devices included, follows the link at its next read (§4.4 steps 3–4).
- **Removal wins.** An owner that holds M departed and receives a commit about M, or applies an `x.grp.mem.del` or `x.grp.leave` of a member it held as an owner, enqueues a link sync, which revokes M if the read still lists it. No announcement: other parties follow the link.
- 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
A single-owner creator first reads the link: a read with `creatorQueueKey` (a restore from an older backup) is applied, making it multi-owner; otherwise, or if the read fails, it keeps `Commands.hs:3233-3277`. `APILeaveGroup` by an owner in multi-owner mode:
A single-owner creator first reads the link, before the group lock: a read with `creatorQueueKey` (a restore from an older backup) is applied, making it multi-owner; otherwise it keeps `Commands.hs:3233-3277`, and a failed read refuses the leave (that path's retried `DEL` would delete a multi-owner link). `APILeaveGroup` by an owner in multi-owner mode:
1. Read. Refuse with `CLELastOwner` if no other current owner. If `roster.author` is this owner and either `stored_roster_version` is below `roster.version` (its claim landed but was not stored) or its stored roster is undelivered (`roster_pending_msg_id` set, §4.10), make an `RCResign` claim first (§4.10, through `writeChannelLink`; it sends the blob and one role event) and wait up to `channelOwnerWaitTimeout` (30 s), re-reading the column without the group lock (`SENT` is processed under it), until a relay has `SENT` the new blob's last chunk; else refuse with `CLERelaysUnreachable`.
2. `writeChannelLink` with `CLCRevokeOwner self` (own entry kept, key dropped); the last-owner check runs on every attempt.
@@ -217,7 +217,7 @@ The next read completes a leave that crashed after step 2 (§4.4 step 3); a repe
3. Build the blob at the claimed version `v` (`r + 1`, or `r + 2` when abandoning) from current roster members with the delta (`buildGroupRoster (applyRosterDelta …)`, `Internal.hs:2629-2654`); `roster = {v, self}`.
4. Callback, only on `LWWritten` (an unchanged link claimed nothing; return `Nothing`): `setOwnGroupRoster` sets `roster_version`, `applied_complete_roster_version`, `stored_roster_version` to `v`, `roster_blob`, sender = own membership (header columns NULL); `sendStoredRoster`; return `v` for the events.
An owner that applies another owner's versioned event above `s` requests the roster from the relay that forwarded it, once while `rosterRequests` holds no request (below), so a later claim usually finds it complete.
An owner that applies another owner's versioned event above `s` requests the roster from every current relay, as a read does, while `rosterRequests` holds no request (below), so a later claim usually finds it complete; owners in multi-owner mode skip the frontier gap request (`requestRosterOnGap`, `Subscriber.hs:3436-3446`).
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.
@@ -233,13 +233,13 @@ Single-owner channels keep `broadcastRoster`. Owners outside the roster: `proces
An `RCResign` claim writes `roster {v, self}` with no delta; in a sync rule (a) then sends it, and through `writeChannelLink` its callback does the same. A sync never sends an event at a version it is re-sending.
**Abandonment.** A version `r` by a departed author, with `s < r`, is abandoned (claim `r + 2` from its own state: no relay serves `r`'s blob, and the skipped version makes devices that applied some of `r`'s events see a gap and fetch the new roster) when `link_roster_seen_at` is older than `rosterAbandonTTL` (1 day) and a request to every current relay at least `rosterRequestTimeout` (5 minutes) earlier brought no blob at `r`: `ChatController.rosterRequests :: TMap GroupId UTCTime` keeps the time of the first unanswered request (set when a read or claim sends one to every current relay while behind, cleared when `s` reaches the read version). Claims and syncs check it when they need it; no timer. An owner that also missed that author's earlier versions (`s < r − 1`) drops them too, though some devices received them; otherwise roles could never change again. A current author's version is never abandoned: its device sends it when back (rules (a), (c)); a lost device is removed first.
**Abandonment.** A version `r` by a departed author, with `s < r`, is abandoned (claim `r + 2` from its own state: no relay serves `r`'s blob, and the skipped version makes devices that applied some of `r`'s events see a gap and fetch the new roster) when `link_roster_seen_at` is older than `rosterAbandonTTL` (1 day) and a request to every current relay at least `rosterRequestTimeout` (5 minutes) earlier brought no blob at `r`: `ChatController.rosterRequests :: TMap GroupId UTCTime` keeps the time of the first unanswered request (set when a read, claim or early request sends one to every current relay while behind, cleared when an applied blob brings `s` to the read version). Claims and syncs check it when they need it; no timer. An owner that also missed that author's earlier versions (`s < r − 1`) drops them too, though some devices received them; otherwise roles could never change again. A current author's version is never abandoned: its device sends it when back (rules (a), (c)); a lost device is removed first.
**Undelivered roster.** `setOwnGroupRoster` sets `groups.roster_pending_msg_id` to 0 ("not sent") in its transaction; `sendStoredRoster` of a roster this owner authored sets it to the last chunk's message; the relay `SENT` handler (`Subscriber.hs:1189-1194`) clears it for that message, and applying another owner's blob clears it. A message to a relay that is not ready is dropped (`memberSendAction`, `Internal.hs:2840-2846`), leaving it set. At main-app chat start, on a relay's `CON` (`:889`) and when sending resumes (`continueSending` on `SENT` or `QCONT`, `:1197-1199`, `:1291-1295`), an owner whose own stored roster is at `link_roster_version` with the column set enqueues an `LSResign` sync.
### 4.11 Subscriber count
In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:1618-1631`) recounts and, when `members_catchup = 0` in the main app, schedules a publish: the first trigger inserts an entry in `ChatController.countPublishPending :: TMap GroupId ()` and forks a thread that sleeps `channelCountPublishInterval` (60 s), deletes it and enqueues a link sync. The fork is a delay, bounding count writes that would make other owners' swaps conflict in a burst of joins; the agent has no delayed commands. The relay-announcement threshold becomes `publicMemberCount > number of current owners` (`Subscriber.hs:1445-1449`).
In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:1618-1631`) recounts and, when `members_catchup = 0` in the main app, schedules a publish: the first trigger inserts an entry in `ChatController.countPublishPending :: TMap GroupId ()` and forks a thread that sleeps `channelCountPublishInterval` (60 s), deletes it and enqueues a link sync. The fork is a delay, bounding count writes that would make other owners' swaps conflict in a burst of joins. The relay-announcement threshold becomes `publicMemberCount > number of current owners` (`Subscriber.hs:1445-1449`).
### 4.12 Profile edits and the link counter
@@ -249,7 +249,7 @@ In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:16
### 4.13 Relays in a multi-owner channel
- **Owner events** as today, plus the commit (§4.6) and the admin-removal window (§4.8); relays apply the link at invitation and in relay checks (§4.4).
- **Owner events** as today, plus the commit (§4.6); relays apply the link at invitation and in relay checks (§4.4).
- **Departed owners.** A relay that learns of an owner's departure from a read keeps its connection until that owner's `x.grp.leave` or a remover's `x.grp.mem.del`, since its last blob can be queued on it. From that connection it processes only `x.grp.leave`, roster transfers (`xGrpRoster`, `:3503-3540`, and their chunks, `bFileChunkGroup`, `:2626-2633`) and versioned role and removal events, up to `link_roster_version`, which the read that taught the departure set at or above the owner's last claim; the direct path has no other sender-status check (`Store/Connections.hs:110-112`).
- **Activation.** The `LINK` handler body (`Subscriber.hs:1437-1477`) becomes `activateGroupRelays :: User -> GroupInfoKeys -> [ShortLinkContact] -> CM ()`, called by the handler and the `CLCRelays` callback; 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 link sync (§4.4).
@@ -266,7 +266,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 for non-revoked entries, departed rows for revoked ones (`:2324-2332`).
- **History for joiners.** A signed as-channel item by an author no longer a current owner is served unsigned (`FwdChannel`, `itemForwardMsgs`, `Internal.hs:1540-1548`).
- **Destroying.** A creator first reads the link as for leaving (§4.9). 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`; `AUTH` (no link: this device's earlier delete landed) goes on to `x.grp.del`; any other failure refuses. Every owner, the creator included, then deletes the link queue with `deleteLinkQueueWithCreds` before sending `x.grp.del`, so the link server orders a destroy against a concurrent removal of its owner; on failure it returns the error and deletes nothing. The creator then runs `deleteGroupLinkIfExists` for its local records.
- **Destroying.** A single-owner creator first reads the link as for leaving (§4.9); a failed read lets its destroy go on as today. 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`; `AUTH` (no link: this device's earlier delete landed) goes on to `x.grp.del`; any other failure refuses. Every owner, the creator included, then deletes the link queue with `deleteLinkQueueWithCreds` before sending `x.grp.del`, so the link server orders a destroy against a concurrent removal of its owner; on failure it returns the error and deletes nothing. The creator then runs `deleteGroupLinkIfExists` for its local records.
### 4.15 Main app and chat start
@@ -300,13 +300,10 @@ One migration `M20261015_channel_multi_owner` with `down_m20261015_channel_multi
| A role change just before leaving, or in flight when its author is removed | the claim; the leaver's re-sign and wait (§4.9, §4.10) | departed authors' roster messages accepted; a blob no relay got is abandoned after a day |
| Two profile edits | the editor's check, the swap, `linkVersion` (§4.12) | the later refused if the profile changed; devices keep the newer |
| A relay added while another is removed | the swap (§4.13) | both kept |
| A member removed (by an owner or an admin) or leaving while promoted | the removal reaching every party; removal wins (§4.8) | the member ends departed |
| A member removed (by an owner or an admin) or leaving while promoted | owners applying it; removal wins (§4.8) | the member ends departed, unless no other owner remained to apply it (§9, question 6) |
| A user's role change and a sync on one device | the link writer lock (§4.10 rule (a)) | no claim taken for a lost one |
| A read older than a promotion | reads only add owners (§4.4) | M stays an owner |
| A single-owner write queued before the creator's first swap | `LSET` refused in the store on a multi-key queue (§4.1) | `AUTH` |
A crash or a lost agent event in a sync, and a party that was offline, converge at the next read (§4.4).
## 5. Compatibility and security
- **Wire.** SMP v24 (`LSWP`, `CONFLICT`, `LSET` refused on multi-key queues); a v23 server gets no `LSWP`. Chat v23 events (§4.5); `XGrpInfo` gains an optional field. Two API commands, one response and new types, in the Bot API docs. Internally the agent's `LDATA` gains a field and its command store a constructor.
@@ -317,8 +314,8 @@ A crash or a lost agent event in a sync, and a party that was offline, converge
- Relay: cannot forge owner-signed events, commits, acceptances or removals; can withhold or reorder as today (link reads and the roster gap request converge); can replay old signed events, which the roster gate, the `linkVersion` gate and departed rows stop; 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` and otherwise only adds owners, so a non-owner can take a removed owner as current until its next read (an honest removed device sends nothing); ignoring the compare-and-swap brings back lost writes and roster divergence, no forgery.
- Owners and removed owners are trusted (non-goal). The link orders profile edits, roster role changes and removals, owner additions and removals, leaving, destroying and the relay list: honest software sends these only after its link write lands (destroy: after its read and `DEL`). Posts, moderation, blocks, subscriber removals, relay invitations and owner invitations are sent as today. Until a relay learns of a removal (the remover's announcement, or its link check every 15 minutes), a removed device running modified software can act there as an owner, including role events at inflated versions that stop later role changes at that relay and its members; afterwards the relay accepts from it only its departure, roster transfers and role events up to the link's roster version, new ones at that version included, which members accept above their gate. 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`. An admin can remove an owner whose promotion reached the relays less than `promotionRaceWindow` earlier (§4.8).
- **Privacy.** A promoted owner connects directly to the link server (recipient commands are not proxied), with the consent and refusals of §4.6; link commands use a session of their own, link reads stay proxied. The link server learns when owners act and how many there are. `linkRcvId` and `creatorQueueKey` authorise nothing; the member-page cursor is a memberId. A pending async swap keeps the owner's keys in the agent's command table (the encrypted database of its queue keys) until it completes or its mailbox goes.
- Any current owner can destroy the channel or replace the key set with `RKEY`. An admin can remove an owner within `promotionRaceWindow` of its promotion reaching the other owners (§4.8).
- **Privacy.** A promoted owner connects directly to the link server (recipient commands are not proxied), with the consent and refusals of §4.6; link commands use a session of their own, link reads stay proxied. The link server learns when owners act and how many there are; the member-page cursor is a memberId. A pending async swap keeps the owner's keys in the agent's command table (the encrypted database of its queue keys) until it completes or its mailbox goes.
## 6. Slices
@@ -363,8 +360,6 @@ Same file split, names and branch order on both platforms.
| Relays: add-relay excludes relay members' links too; web settings take domains from rows and relay members' `relayWebDomain` | `ChannelRelaysView.swift:38-41`, `ChannelWebAccessView.swift:86-120` | `ChannelRelaysView.kt:109-137`, `ChannelWebPageView.kt` |
| Strings | `en.xliff` via `NSLocalizedString` | `moko-resources/base/strings.xml` |
Not compiled in the container; built on the host.
## 7. Test plan
**simplexmq** (`tests/ServerTests.hs` per store, `tests/CoreTests/StoreLogTests.hs`, `tests/CoreTests/MsgStoreTests.hs`, `tests/AgentTests/FunctionalAPITests.hs`):
@@ -395,7 +390,7 @@ Not compiled in the container; built on the host.
| T15 | a relay reads the creator's departure before processing its queued blob and still accepts it; a role event from that connection above the link's roster version is refused |
| T16 | a leave stopped after its swap (hook) completes at the next chat start; a departed author's undelivered version is abandoned after the (shortened) TTL and an unanswered request |
| T17 | an acceptance processed while the link server is down promotes once it is back; a restart between swap and read sends the commit once; a relay acknowledgement publishes the relay and a join changes the count; removal wins completes after a restart; a sync whose mailbox was removed is redone at chat start; a server answering every swap `CONFLICT` stops the sync at the bound |
| T18 | an admin removes M while the hook holds O1's promotion of M: M ends departed everywhere, relays and M's device applying both in either order, a relay offline across both included; an admin's removal later than the (shortened) window is refused, and the admin's device takes M as owner at its next read |
| T18 | an admin removes M while the hook holds O1's promotion of M: M ends departed everywhere after O1's revocation, relays and M's device receiving both in either order, a relay offline across both included; an admin's removal later than the (shortened) window is refused by owners, and the admin's device takes M as owner at its next read |
| T19 | a device that left after its swap destroys without `x.grp.del`; the creator's destroy is refused while its read fails; a destroy racing a removal of its owner: one fails |
| T20 | a channel-open 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, and O1 one right after its own: each relay gets the current blob, acknowledges and is published |
@@ -409,15 +404,14 @@ 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 from complete rosters and departed owners' roster messages pass the gate (T5, T21, T22 in S11; T9, T15 in S12). S1–S2 first prove the swap's atomicity and size.
- Background syncs re-derive every callback from the latest read and rows; a non-idempotent condition would send twice or never (T17 in S9).
- Rollout: link servers (presets included) must run v24, and relays chat v23, before promotion works.
## 9. Open questions
Questions 1–3 are decided: role changes need the link server; consent by verified code plus an invitation; member list pulled from one relay without images.
Questions 1–3 are decided (overview, Decisions).
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.
6. An admin's removal of a member wins over a concurrent promotion of it, so every party applies an admin's removal of an owner that reached a relay less than `promotionRaceWindow` (1 day) after the promotion did. Recommended: as planned; otherwise devices that applied the promotion first keep the member as an owner and the others keep it removed, until an owner removes it.
6. An admin's removal of a member wins over a concurrent promotion of it: owners apply an admin's removal of an owner that reached them less than `promotionRaceWindow` (1 day) after the promotion and revoke it, and every party follows the link. If the promoter leaves at once and no other owner applies it, the member stays an owner without the relays that applied it first, or, if its device did, no working owner remains. Recommended: as planned; otherwise devices that applied the promotion first keep the member as an owner and the others keep it removed, until an owner removes it.
7. Blocking and unblocking a member stay unordered between owners, admins and moderators, as in groups today. Recommended: as planned (parity; admins and moderators block too and cannot write the link); otherwise owners' restrictions carry the roster version and are claimed through the link.
## 10. Out of scope
@@ -48,7 +48,7 @@ The channel's link data lives on one SMP server, the link server, which already
- **Edits over a changed profile are refused**, and the current profile is shown.
- **A new owner's device connects to the link server directly**, revealing its IP address to that server's operator; the invitation says so, and private-routing settings need an explicit confirmation. Link reads stay private.
- **The link server learns when owners act and how many there are.**
- **An admin's removal wins over a promotion at the same moment**: it is applied if it reached the relays within a day of the promotion.
- **An admin's removal wins over a promotion at the same moment**: owners apply it if it reached them within a day of the promotion, and every device follows the link. If the promoting owner leaves within seconds, before the removal reaches it, the channel can be left without a working owner.
- **Old apps and servers.** Promotion needs every relay and the link server upgraded; subscribers on older apps reject a new owner's actions until they upgrade.
- **A new owner's profile reaches every subscriber**; the member is told before it accepts.
@@ -63,8 +63,9 @@ None of these is a race between honest parties:
- **A role change can be abandoned** when no relay serves it: its author's device is lost in the second after recording it, its author is removed before any relay got it, or only a relay that then failed got it. While a lost author is an owner the others cannot change roles until they remove it; then they wait up to a day, and devices that applied part of the lost version return to the abandoning owner's roster. An owner that also missed that author's earlier changes drops them too.
- **A remover crashing right after its removal landed** sends no announcement; devices apply it at their next link read, relays at their next check.
- **Blocking and unblocking a member is not ordered**, as today.
- **A creator restored from a backup older than its first promotion** acts as the single owner until it leaves or destroys the channel, and its role changes can collide with another owner's.
- **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.
## Not verified, size
App code and Postgres tests run only on the host. The 50-owner headroom is computed from the encoders (the first slices test it); catch-up for 100,000 members (about 2,000 messages) is an estimate. About 2,290 lines of production Haskell (~490 simplexmq, ~1,800 chat) and 380 per app platform; five new chat events and one changed; five optional link-data fields; nine columns, no new table; one SMP command and one error.
App code and Postgres tests run only on the host. The 50-owner headroom is computed from the encoders (the first slices test it); catch-up for 100,000 members (about 2,000 messages) is an estimate. Five new chat events and one changed; five optional link-data fields; nine columns, no new table; one SMP command and one error.