diff --git a/plans/2026-10-15-channel-multi-owner-s3min-implementation.md b/plans/2026-10-15-channel-multi-owner-s3min-implementation.md index a86350a110..a7c1044a81 100644 --- a/plans/2026-10-15-channel-multi-owner-s3min-implementation.md +++ b/plans/2026-10-15-channel-multi-owner-s3min-implementation.md @@ -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,150 production Haskell lines (simplexmq ~480, chat ~1,670) plus tests, and ~380 lines per app platform. +Branch: `f/channel-multi-owner` · Repos: simplexmq (from pinned `cba13823`), simplex-chat · Estimated diff: ~2,290 production Haskell lines (simplexmq ~490, chat ~1,800) plus tests, and ~380 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`. @@ -10,17 +10,17 @@ A channel has several equal owners. Any owner can do what the single owner does | # | Criterion | Tests (§7) | |---|---|---| -| 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, members and joiners treat it as departed | T4, T15 | -| 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; a removal racing a promotion ends with the member removed everywhere | T5–T8, T14, T18 | +| AC1 | An owner invites a member whose code it verified; it accepts; one link write adds its chain entry and key; relays and subscribers treat it as owner; it posts, edits and changes roles | T1, T20, T21 | +| AC2 | Promotion is refused for an unverified member, a relay below `multiOwnerVersion`, 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 departed, verifies the roster, gets the creator's history unsigned | T3 | +| AC4 | An owner removes another; the removed owner's writes and `DEL` get `AUTH`; its device erases its keys; relays, members and joiners treat it as departed | T4, T15 | +| AC5 | Concurrent writes both land or one is refused; an edit over a changed profile is refused; concurrent role changes reach every relay and member, none reverted; mutual removal and the last two leaving leave one owner; a removal racing a promotion removes the member everywhere | T5–T8, T14, T18 | | 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 | | AC8 | An older profile event or link read never replaces a newer profile | T12 | -| AC9 | Background owner work (promotion, relay publication, count, removal wins, roster recovery) completes after a link server outage and after a restart, once | T16, T17 | -| AC10 | `LSWP` swaps link data and keys atomically on every store, and a restart replays both | §7 simplexmq | -| AC11 | Single-owner channels behave as today: the existing `-m "channels"` group passes | §7 | +| AC9 | Background work (promotion, relay publication, count, removal wins, roster recovery) completes once after an outage or a restart | T16, T17 | +| AC10 | `LSWP` swaps data and keys atomically on every store; a restart replays both | §7 simplexmq | +| AC11 | Single-owner channels behave as today: `-m "channels"` passes | §7 | ## 2. Non-goals @@ -30,20 +30,16 @@ Protection against current or removed owners (they are trusted as the single own | Where | Today | |---|---| -| `Internal.hs:1669-1685` `groupLinkData` | owners = `[self]`, only with the root private key | -| `Internal.hs:1618-1631` `updatePublicGroupData` | creator republishes the count with an async `LSET` on every join or leave; throws without a group link row | -| `Commands.hs:2994-2995` | owner role refused in channels | -| `Commands.hs:3246`, `Internal.hs:1739-1748` | leaving deletes the link queue with `DEL` | -| `Internal.hs:2642-2654`, `Subscriber.hs:1431-1482` | roster version = local gate + 1, one signer; relays activated only after the creator's own async write | -| `Store/Groups.hs:2353-2373` | relays read owners once, at invitation, and throw for an owner without a row; the relay check reads only `relays` (`Commands.hs:6001-6020`) | -| `Subscriber.hs:3783-3786` `xGrpMemDel` | an admin's removal of an owner is refused, and not forwarded | -| `Subscriber.hs:4019-4020` | forwarded content from departed authors ignored | -| simplexmq `Server.hs:1574-1579`, `QueueStore/*` | `LSET` and `RKEY` are separate and last-writer-wins | -| simplexmq `Agent.hs:1337-1356`, `:2207-2262` | the link read keeps the encrypted bytes local; async commands belong to a connection, are retried by the agent and answer with the chat command's ID | +| `Internal.hs:1669-1685`, `:1618-1631` | owners `[self]`, only with the root key; the creator republishes the count by async `LSET` on every join or leave | +| `Commands.hs:2994-2995`, `:3246`, `Internal.hs:1739-1748` | owner role refused in channels; leaving `DEL`s the link queue | +| `Internal.hs:2642-2654`, `Subscriber.hs:1431-1482` | roster version = gate + 1, one signer; relays activated only after the creator's own write | +| `Store/Groups.hs:2353-2373`, `Commands.hs:6001-6020` | relays read owners only at invitation, throwing for one without a row; the relay check reads only `relays` | +| `Subscriber.hs:3783-3786`, `:4019-4020` | an admin's removal of an owner is refused and not forwarded; forwarded content from departed authors is ignored | +| simplexmq `Server.hs:1574-1579`, `Agent.hs:1337-1356`, `:2207-2262` | `LSET` and `RKEY` separate, last writer wins; the read keeps the encrypted bytes local; async commands belong to a connection and are retried by the agent | ## 4. Design -The link server orders everything owners share. One new SMP command compares the hash of the stored encrypted user data and swaps the user data and, when owners change, the recipient key set in one atomic step. Every multi-owner link write reads, applies only its own change, and swaps. The link data commits the roster version and its author, so each version has one author who had applied every earlier one: members and relays keep today's roster protocol. A user's command writes synchronously and returns the result; work an owner does in the background is one link sync, made of agent async commands whose continuations re-derive what to do from the latest read and the database, so it survives restarts and outages. +The link server orders everything owners share. One new SMP command compares the hash of the stored encrypted user data and swaps the user data and, when owners change, the recipient key set in one atomic step. Every multi-owner link write reads, applies only its own change, and swaps. The link data commits the roster version and its author, and an owner claims the next version only from the complete roster of the current one, so each version has one author who had all earlier ones: members and relays keep today's roster protocol. A user's command writes synchronously and returns the result; work an owner does in the background is one link sync, made of agent async commands whose continuations re-derive what to do from the latest read and the database, so it survives restarts and outages. ### 4.1 simplexmq: server @@ -51,10 +47,10 @@ 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`). Only multi-owner links have several keys (nothing sends `RKEY` today), so a stale single-owner write, or a creator's app downgraded to a version 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` 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. - **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`); its log order rests on the next swap needing this swap's hash, which takes the next writer a network round trip to learn. - - 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, whose queue lock is a no-op and which has no cache (`MsgStore/Postgres.hs:84`, `:100`); the cache elsewhere holds placeholder link bytes (`Postgres.hs:729`). + - 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`). - Journal: delegate in `QStore` (`MsgStore/Journal.hs:304-361`). - **Store log** (`Server/StoreLog.hs`): a key-changing swap logs a new record `CreateLinkKeys RecipientId LinkId QueueLinkData (NonEmpty RcvPublicAuthKey)`, tag `"LKEYS"` (the parser is an ordered `A.choice`; the tag must not start with `LINK` or `KEYS`), and then the existing `UpdateKeys`; replay (`StoreLog/ReadWrite.hs:40-79`) runs `addQueueLinkData` then `updateKeys`. A swap without keys logs the existing `CreateLink` with the full `QueueLinkData`. A restart never restores data without its keys; an older server skips `LKEYS` but still restores the key set. - **Client** (`Client.hs`): `swapSMPQueueLink :: SMPClient -> NetworkRequestMode -> RcvPrivateAuthKey -> RecipientId -> LinkDataHash -> Maybe (NonEmpty RcvPublicAuthKey) -> EncUserDataBytes -> ExceptT SMPClientError IO ()`, gated `thVersion (thParams c) >= linkSwapSMPVersion`, else `PCETransportError TEVersion` (as `Client.hs:1080-1084`). @@ -65,20 +61,20 @@ Every owner, the creator included, writes with explicit credentials: `LinkOwnerC | Function | Does | |---|---| -| `getConnShortLinkForWrite :: AgentClient -> NetworkRequestMode -> UserId -> ConnShortLink 'CMContact -> AE (FixedLinkData 'CMContact, ConnLinkData 'CMContact, LinkDataHash)` | the contact branch of `getConnShortLink'` (`Agent.hs:1337-1356`), proxied as `LGET` is, also returning the hash of the received user data | +| `getConnShortLinkForWrite :: AgentClient -> NetworkRequestMode -> UserId -> ConnShortLink 'CMContact -> AE (FixedLinkData 'CMContact, ConnLinkData 'CMContact, LinkDataHash)` | `getConnShortLink'`'s contact branch (`Agent.hs:1337-1356`), proxied as `LGET` is, with the user data's hash | | `setShortLinkSwap :: AgentClient -> NetworkRequestMode -> UserId -> LinkOwnerCreds -> LinkDataHash -> Maybe (NonEmpty RcvPublicAuthKey) -> UserContactData -> AE ()` | require `sigKey`'s public key to be `rootKey` or in `owners`, and `validateLinkOwners rootKey owners` (`Agent/Protocol.hs:2003-2017`); `encodeSignUserData` with `sigKey`, `encryptUserData` with the key from `contactShortLinkKdf linkKey`; `LSWP` signed with `rcvAuthKey` | | `setShortLinkSwapAsync :: AgentClient -> ACorrId -> ConnId -> LinkOwnerCreds -> LinkDataHash -> Maybe (NonEmpty RcvPublicAuthKey) -> UserContactData -> AE ()` | the same as an async command on `(connId, Just server)`, `connId` being a mailbox only (as `getConnShortLinkAsync`'s, `Agent.hs:1206-1210`); event `OK` | | `deleteLinkQueueWithCreds :: AgentClient -> NetworkRequestMode -> UserId -> LinkOwnerCreds -> AE ()` | `DEL` with `rcvAuthKey`, to destroy the channel | | `getConnLinkQueueCreds :: AgentClient -> ConnId -> AE (SMP.RecipientId, SMP.RcvPrivateAuthKey)` | local read of the creator's link queue ID and key, as `getConnLinkPrivKey` (`Agent.hs:494-496`, `:1312-1318`) reads its link signing key | | `linkServerSupportsSwap :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SMP.LinkId -> AE Bool` | connect on the link session, compare the negotiated version with v24 | -| `linkServerPrivateRouted :: AgentClient -> UserId -> SMPServer -> AM Bool` | whether the user's settings route commands to this server privately and the connection is not IP-protected (`sendOrProxySMPCommand`'s `shouldUseProxy`, `ipAddressProtected`, `Agent/Client.hs:1190-1211`) | +| `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 the hash: `LDATA :: FixedLinkData 'CMContact -> ConnLinkData 'CMContact -> ConnectionRequestUri 'CMContact -> LinkDataHash -> AEvent AEConn` (`Agent/Protocol.hs:407`); `getConnShortLink'` computes it from the bytes it read (`:1339`), the sync `getConnShortLink` drops it, and the two chat matches (`Subscriber.hs:1245`) and agent tests take the extra 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 user data last, because its parser consumes the rest of the input (`:2075-2079`); worker branch `withServer' . tryCommand $ setShortLinkSwap' c NRMBackground userId creds h ks_ ud >> notify OK` (as `LSET`, `Agent.hs:2255-2258`). The private keys stay in the agent's command table until the command completes. No migration: commands are stored as blobs without a version (`Agent/Store.hs:509-520`). -- **Link session.** A non-proxied read (sync or async), the swaps, `DEL` and the version check use one transport session per link, `(userId, server, Just linkId)` through `withClient`/`withLogClient` (`Agent/Client.hs:1153-1159`), not the session shared per user and server (`mkTSession`, `:1442-1444`), which would tie them to the user's other queues there. The link ID comes from the link key. -- **Direct connections.** The creds calls and `linkServerSupportsSwap` fail with `CMD PROHIBITED` when the user prohibits direct connections to unknown servers (`smpProxyFallback = SPFProhibit`) and the connection is not IP-protected. +- **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. +- **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 falling back to direct connections is prohibited (`smpProxyFallback = SPFProhibit`). 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. -- Errors chat sees: `SMP _ CONFLICT`; `SMP _ AUTH` (key not in the set); `BROKER _ (TRANSPORT TEVersion)` (server below v24); `CMD LARGE` (user data over 13,784 bytes); `CMD PROHIBITED` (direct connection prohibited). The async worker retries temporary and host errors without limit and reports the others as `ERR` (`Agent.hs:2446-2466`). +- Errors chat sees: `SMP _ CONFLICT`; `SMP _ AUTH` (key not in the set); `BROKER _ (TRANSPORT TEVersion)`; `CMD LARGE` (user data over 13,784 bytes); `CMD PROHIBITED`. The async worker retries temporary and host errors without limit and reports the others as `ERR` (`Agent.hs:2446-2466`). ### 4.3 Link data, owners, modes @@ -86,50 +82,50 @@ Every owner, the creator included, writes with explicit credentials: `LinkOwnerC | Field | Type | Written | Meaning | |---|---|---|---| -| `linkVersion` | `Maybe Int64` | every channel link write: in a swap read + 1; in a single-owner write `link_data_version` + 1, persisted when the write is made (async writes: when queued) | write counter; absent means not gated | +| `linkVersion` | `Maybe Int64` | every swap: `max(read, link_data_version) + 1`; single-owner writes carry none | write counter; absent counts as 0 | | `revokedOwners` | `Maybe [MemberId]` | removal and leave | departed owners; never shrinks | -| `creatorQueueKey` | `Maybe CreatorQueueKey {memberId :: MemberId, key :: SMP.RcvPublicAuthKey}` | the creator, in every swap from its first promotion | its link-queue recipient key | -| `linkRcvId` | `Maybe SMP.RecipientId` | the creator, in every swap from its first promotion | queue address for other owners; authorises nothing | -| `roster` | `Maybe LinkRoster {version :: VersionRoster, author :: MemberId}` | every roster claim and the first promotion | the committed roster version and its author | +| `creatorQueueKey` | `Maybe CreatorQueueKey {memberId :: MemberId, key :: SMP.RcvPublicAuthKey}` | the creator, in every swap from its first one | its link-queue recipient key | +| `linkRcvId` | `Maybe SMP.RecipientId` | the creator, in every swap from its first one | queue address for other owners; authorises nothing | +| `roster` | `Maybe LinkRoster {version :: VersionRoster, author :: MemberId}` | every roster claim and the creator's first swap | the committed roster version and its author | -Keys, IDs and `OwnerAuth` encode in JSON as base64url of their `smpEncode` (`strToJSON`/`strParseJSON`). A write encodes the record and merges it over the read JSON object (left-biased union), keeping keys this version does not know; `GroupShortLinkData` today drops unknown fields on parse. +Keys, IDs and `OwnerAuth` encode in JSON as base64url of their `smpEncode`. A write merges the encoded record over the read JSON object (left-biased union), keeping keys this version does not know, which `GroupShortLinkData` drops on parse today. -**Current owner** (`currentOwner m = memberRole' m == GROwner && not (memberRemoved m)`) is used for every owner check below. Owner rows created from the chain or a commit have status `GSMemUnknown` (`createLinkOwnerMember`, `Store/Groups.hs:3575-3603`), which `memberCurrent` excludes. +**Current owner** (`currentOwner m = memberRole' m == GROwner && not (memberRemoved m)`) is used for every owner check below; owner rows from the chain or a commit have status `GSMemUnknown` (`createLinkOwnerMember`, `Store/Groups.hs:3575-3603`), which `memberCurrent` excludes. -**Multi-owner mode** (`isMultiOwnerChannel`): this device holds an owner row other than its own membership (any status), or has an owner invitation outstanding (`owner_inv_msg_id` set on any member). From the first invitation every link write of the device goes through §4.4; single-owner channels keep `setGroupLinkData`/`setGroupLinkDataAsync` (`Internal.hs:1588-1604`) and add only `linkVersion`. +**Multi-owner mode** (`isMultiOwnerChannel`): this device holds an owner row other than its own membership (any status), a member's `owner_inv_accepted = 1`, or `link_data_version` is set (the link has had a swap). From the first accepted invitation every link write of the device goes through §4.4; single-owner channels, and a creator whose invitations nobody accepted, keep `setGroupLinkData`/`setGroupLinkDataAsync` (`Internal.hs:1588-1604`) unchanged. The creator's first swap makes the queue multi-key (§4.4 step 3), so from then on its queued single-owner writes get `AUTH`. **Credentials** for a write are built each time: the channel link's server and key, `rcvId` and `rcvAuthKey` from `getConnLinkQueueCreds` on the creator and from the read's `linkRcvId` and the member key elsewhere, the group's root public key, and `sigKey` (§4.2). ### 4.4 Writes, reads, link syncs -**Locks.** `ChatController.linkWriters :: TMap GroupId Lock` serialises this device's user-command 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. +**Locks.** `ChatController.linkWriters :: TMap GroupId Lock` serialises this device's user-command link writes per channel. Network calls never run under the group lock, which every agent event of the channel takes on the single event thread (`Subscriber.hs:146-147`); DB steps take it briefly. **`writeChannelLink :: User -> GroupId -> ChannelLinkChange -> (LinkWrite -> ChannelLink -> CM a) -> CM a`** (`LinkWrite = LWWritten | LWUnchanged`), for user commands, under the link writer lock: 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; `setShortLinkSwap` with the read's hash. -5. `CONFLICT`, a network error or a timeout → from step 1 (a lost response is found applied, step 2), 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 (`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_version` (as `applyChannelLinkData` step 5), and run the callback: local state, events to send, the result. -7. Release the writer lock; enqueue a link sync if the read left background work pending (below). +3. When the owner set changes, or the read lacks `creatorQueueKey`, `linkQueueKeys`: `creatorQueueKey.key` unless the creator is revoked, plus the `ownerKey` of every chain entry not in `revokedOwners`, the creator's own included (two at least while the creator is an owner). A creator's write whose read lacks `creatorQueueKey` also writes it and `linkRcvId`, and, if the read has no roster, `roster_version` is set and the change is not a roster claim, stores its rebuilt roster as its own (§4.10) and commits `roster {roster_version, self}`. +4. `linkVersion` = `max(read, link_data_version) + 1`; `setShortLinkSwap` with the read's hash. +5. `CONFLICT` → from step 1, at most `channelLinkWriteAttempts` (5), then `CLELinkBusy`; a network error or timeout → from step 1 once (a lost response is found applied, step 2). `AUTH` → one more read and apply (it shows the revocation), `CLENotOwner`. `TEVersion` → `CLELinkServerVersion`. `CMD LARGE` → `CLELinkDataTooLarge`. `CMD PROHIBITED` → `CLEDirectConnection`. +6. On success (`LWWritten`), and on "already applied" (`LWUnchanged`), under the group lock, while the membership is current: store `max` of the stored and the link's `linkVersion` in `link_data_version` and its roster commitment in `link_roster_version` (as `applyChannelLinkData` step 5), and run the callback: local state, events to send, the result. +7. Release the writer lock; enqueue a link sync if the read left background work pending (below, including rule (a) of §4.10), or the write failed after an attempt that may have landed. -Callers never hold the group lock across this call. 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`). +Callers never hold the group lock across this call. In multi-owner mode only, commands that hold `withGroupLock` across their body today take it only around their DB steps: `APIMembersRole` (`Commands.hs:2984`), `APIRemoveMembers` (`:3123`), `APILeaveGroup` (`:3236`), `APIDeleteChat` (`:1390`), `APIAddGroupRelays` (`:2781`); their other paths keep it. -**Link sync**, the background path. `enqueueLinkSync :: User -> GroupInfo -> LinkSyncKind -> CM ()` (`LSSync | LSResign`, §4.10) runs in the main app only (§4.15). Its mailbox is the owner's connection to the current relay with the lowest member ID whose connection is `ConnReady`, which an `ERR` does not mark failed (`agentMsgConnStatus`, `Subscriber.hs:178`), and whose events run under the group lock; without one there is no sync until the next trigger or chat start. New `CommandFunction`s (`Types.hs:2162-2225`): `CFChannelLinkSync`, `CFChannelLinkResign` (expect `LDATA_`) and `CFChannelLinkSwap` (expects `OK_`). +**Link sync**, the background path: `enqueueLinkSync :: User -> GroupInfo -> LinkSyncKind -> CM ()` (`LSSync | LSResign`, §4.10), main app only (§4.15). It does nothing while a read row of the same or the stronger kind for the mailbox, under 10 minutes old, is pending (that read sees the latest state; an older row lost its event). The mailbox is the owner's `ConnReady` connection to a current relay (an `ERR` does not fail it, `Subscriber.hs:178`; 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 (cascade). 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 to the read in turn (`applyLinkChange`; a refusal is handled per change and the others go on). If the result differs from the read: key set and `linkVersion` as in steps 3–4, `createCommand … CFChannelLinkSwap`, `setShortLinkSwapAsync` with the read's hash; else stop. -3. Swap continuation: on `OK`, and on `ERR` with `CONFLICT` or `AUTH` (`Subscriber.hs:1234-1236`, `:1316-1320`, now dispatched on the function), enqueue an `LSSync`; the next read runs the callbacks. The agent retries network errors itself, and a landed swap it retries gets `CONFLICT`. Any other `ERR`: an event to the view, 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: 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` → an `LSSync`, whose read runs the callbacks. `CONFLICT` or `AUTH` → no view event; `ChatController.linkSyncRefusals :: TMap GroupId Int` counts it and an `LSSync` follows below `channelLinkWriteAttempts` (an `OK` or a trigger resets it), so a link server refusing every swap cannot keep a device looping; a landed swap the agent retried after a network error also gets `CONFLICT`. Other `ERR`: a view event, stop. -Nothing is carried between the steps but the command row: every decision comes from the latest read and the database, so a lost or repeated event (an agent event can be lost or repeated across a crash, `Agent.hs:2459`, `:2467-2470`) and a crash between steps are absorbed by the next trigger or by chat start. +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. **Changes**, with when a sync finds each pending and the callback it runs once a read shows it applied: | `ChannelLinkChange` | Applies | No write / refuses | In a sync: pending when → callback | |---|---|---|---| | `CLCProfile base new` | the profile, whole | read = `new` → no write (landed); read ≠ `base` → `CLEChangedByOwner` | user commands only | -| `CLCAddOwner member ownerAuth` | entry after the signer's; key into the set; first promotion also `creatorQueueKey`, `linkRcvId`, `roster` (§4.6) | member revoked or departed → refuse; already in the chain → no write | a member has `owner_inv_accepted = 1` → §4.6 step 4 | +| `CLCAddOwner member ownerAuth` | entry after the signer's; key into the set | member revoked or departed → refuse; already in the chain → no write | a member has `owner_inv_accepted = 1` → §4.6 step 3 | | `CLCRevokeOwner memberId` | memberId into `revokedOwners`; its key out | already revoked → no write; no other current owner would remain → `CLELastOwner` | a member held departed is a non-revoked chain entry → none (§4.8) | | `CLCRelays added removed` | read relays + added − removed − relays held as left | no change → no write | an acknowledged relay (`RSAcknowledgedRoster`) is missing, or a relay held as left is listed → `activateGroupRelays` (§4.13) | | `CLCCount n` | `publicGroupData.publicMemberCount = n` | not the first current chain entry, or read count = `n` → no write | `members_catchup = 0` and the tally differs from the read count → none | @@ -139,16 +135,16 @@ Triggers: an acceptance (§4.6), a commit or read showing a departed member as a 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. -**`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 every link sync and in step 2 of every write; relays at invitation (`getLinkDataCreateRelayLink`, `Subscriber.hs:4585-4606`) and in every relay check (`checkRelayServedGroups`, `Commands.hs:6001-6020`); joiners in `APIConnectPreparedGroup` (`Commands.hs:2303-2333`). +**`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` is below `link_data_version` is ignored; else store it. +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 held neither as owner nor departed becomes owner with `ownerKey` (created with a placeholder profile if unknown); one held as departed: on an owner, 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. 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 `applied_complete_roster_version`, `XGrpRosterRequest` to every current relay (§4.10). +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). -Every channel owner reads on channel open (single-owner creators too, which refreshes `link_data_version` after a restore). +Owners read on channel open in multi-owner mode; for a single-owner creator `APIGetUpdatedGroupLinkData` returns the group without a read, keeping today's behaviour. ### 4.5 Events and wire @@ -163,85 +159,86 @@ Every channel owner reads on channel open (single-owner creators too, which refr | `x.grp.members.page` | `{members :: [{memberInfo :: MemberInfo, memberRestrictions :: Maybe MemberRestrictions}], cursor :: Maybe MemberId}` | relay → owner | no | no | | `x.grp.info` (changed) | adds optional `linkVersion` | as today | yes | yes | -Each event: constructor, `CMEventTag`, tag string and parser, `toCMEventTag`, JSON params (`Protocol.hs:460-1557`), `requiresSignature` (`:1357-1371`), `isForwardedGroupMsg` (`:528-549`), relay dispatch (`Subscriber.hs:1080-1132`), forwarded dispatch (`:4041-4064`), and the Bot API docs (`bots/src/API/Docs/{Commands,Responses,Events,Types}.hs`, with the generated `bots/api/*.md`, TypeScript and Python types, checked by `tests/APIDocs.hs`). Older clients ignore them. +Each event: constructor, tag, parser and JSON (`Protocol.hs:460-1557`), `requiresSignature` (`:1357-1371`), `isForwardedGroupMsg` (`:528-549`), relay and forwarded dispatch (`Subscriber.hs:1080-1132`, `:4041-4064`), Bot API docs (`bots/src/API/Docs/*.hs`, generated files checked by `tests/APIDocs.hs`). Older clients ignore them. ### 4.6 Promotion with consent **Invite.** `APIInviteGroupOwner {groupId, groupMemberId}` (CLI `/_owner invite # `, `/owner invite # `) → `CROwnerInvitationSent {user, groupInfo, member}`. Refused unless: - 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`; +- M's code is verified: `memberVerifiedCode` equals `getChannelMemberCode` now (`Commands.hs:3811-3815`), else `CLENotVerified` (only this out-of-band check detects a relay that substituted M's key, as it could sign the acceptance too); +- every current relay supports `multiOwnerVersion`, else `CLERelayVersion`; the chain has fewer than 50 entries (the key set's limit, §4.1), else `CLELinkDataTooLarge`; - `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. +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). M's version is not checked (known only from M's join, `Store/Groups.hs:2533-2551`); an older M ignores the invitation. -**Receivers.** Relay: sender a current owner, M current → forward to M's support scope. M: author a current owner and the memberId its own → item `RGEOwnerInvitation {invStatus = OISPending}` in its own support scope (`mkGetMessageChatScope`, `Internal.hs:1984-2003`, which also sets `supportChat`). Others: no item. +**Receivers.** Relay: sender a current owner, M current → forward to M's support scope. M: author a current owner and the memberId its own → item `RGEOwnerInvitation {invStatus = OISPending}` in its support scope (`mkGetMessageChatScope`, `Internal.hs:1984-2003`, which sets `supportChat`). Others: no item. -**Accept.** `APIAcceptGroupOwnerInvitation {groupId, chatItemId, allowDirect :: AllowDirect}` (`AllowDirect = ADAsk | ADYes`; CLI `/_owner accept # [ direct=on]`) on M: the item must be a pending invitation, the membership current, not owner. Direct connections prohibited and the connection not IP-protected: `CLEDirectConnection {host, prohibited = True}`, whatever `allowDirect` says. `ADAsk` and `linkServerPrivateRouted` for the link's server: `CLEDirectConnection {host, prohibited = False}`; the app asks and repeats with `ADYes`. It sends `x.grp.owner.acpt {invId}` (the item's shared msg id), sets the item to `OISAccepted`, returns `CRChatItemUpdated`. Relays forward it to M's support scope; the promoter acts when `invId` equals M's `owner_inv_msg_id`. +**Accept.** `APIAcceptGroupOwnerInvitation {groupId, chatItemId, allowDirect :: AllowDirect}` (`ADAsk | ADYes`; CLI `/_owner accept # [ direct=on]`) on M: a pending invitation, the membership current, not owner. When the creds calls would get `CMD PROHIBITED` (§4.2): `CLEDirectConnection {host, prohibited = True}`. `ADAsk` and `linkServerPrivateRouted`: `CLEDirectConnection {host, prohibited = False}`; the app asks and repeats with `ADYes`. It sends `x.grp.owner.acpt {invId}` (the item's shared msg id), sets the item to `OISAccepted`, returns `CRChatItemUpdated`. Relays forward it to M's support scope; the promoter acts when `invId` equals M's `owner_inv_msg_id`. **Promote.** On the acceptance the promoter sets M's `owner_inv_accepted = 1` and enqueues a link sync. In the sync (§4.4): -1. Re-check M (current, not departed or revoked) and the relays' versions; a failure that persists clears the invitation and is shown to the user. -2. First promotion (the creator, a read without `creatorQueueKey`): if `roster_version` is set, set `applied_complete_roster_version` to it, rebuild the blob from current roster members without M (`buildGroupRoster`) and store it as its own (§4.10), and commit `roster {version, self}`; with no roster version, commit none (the first claim takes version 0). The change also writes `creatorQueueKey` and `linkRcvId` (`getConnLinkQueueCreds`). -3. `CLCAddOwner M (mkOwnerAuth …)`. -4. 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`. +1. Re-check M (current, not departed or revoked) and the relays' versions; a failure that persists clears the invitation and is shown. The creator's first swap also writes its own fields and roster commitment (§4.4 step 3; the stored roster is rebuilt without M, `buildGroupRoster`). +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` = now, 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 broker timestamp (`fwdBrokerTs` when forwarded, `Subscriber.hs:4010`), 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`). -**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` = now, 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, `owner_since` = the commit's broker timestamp, 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. ### 4.7 New owner's member list -- **Request.** `x.grp.members.req {cursor}` to one current relay with a ready connection; `cursor = Nothing` starts from the beginning. A watchdog (`ChatController.memberCatchups :: TMap GroupId (Int, Maybe MemberId)`, a request counter and the cursor) re-requests from the same cursor at the next ready relay when no page arrived within `memberCatchupTimeout` (1 minute). It is a forked delay, not a network step: the request itself is an ordinary message. Main-app chat start restarts from the beginning while `members_catchup = 1`. -- **Relay.** Serves a current owner only (silence otherwise; the watchdog moves on). One page: current members (statuses of `getGroupMembersByCursor`, `Store/Delivery.hs:352-390`) with `member_id > cursor ORDER BY member_id` (memberIds are the same on every relay and indexed, `UNIQUE(group_id, member_id)`; the cursor member's own row may be gone), excluding relays and the requester; `memberInfo` with the redacted profile and no image, plus restrictions, as many as fit one message (`maxEncodedMsgLength`, `Protocol.hs:929`); `cursor` = the last packed member's memberId, absent when none follows. -- **Owner.** A page from a current relay while `members_catchup = 1`, in one transaction: a held `GSMemUnknown` member gets its profile and status as `xGrpMemNew` gives them (`updateRosterMemberAnnounced`, `Subscriber.hs:3243-3251`), never role or key; departed and other held members are skipped; new ones are created by `xGrpMemIntro`'s channel rule (`Subscriber.hs:3326-3334`, extracted): roles from member up at the default role without a key (the roster establishes those), subscribers with the relay-reported key, as owners keep it from `XGrpMemNew` today. No per-member link write. Then the next request, or `members_catchup = 0`. A page from a relay asked earlier is applied, but only the relay asked last gets the next request. Without images 40–60 members fit a message. +- **Request.** `x.grp.members.req {cursor}` to one current relay with a ready connection; `cursor = Nothing` starts from the beginning. A watchdog (`ChatController.memberCatchups :: TMap GroupId (Int, Maybe MemberId)`, a request counter and the cursor) re-requests from the same cursor at the next ready relay when no page arrived within `memberCatchupTimeout` (1 minute); it is a forked delay, the request an ordinary message. Main-app chat start restarts from the beginning while `members_catchup = 1`. +- **Relay.** Serves a current owner only (silence otherwise). One page: current members (statuses of `getGroupMembersByCursor`, `Store/Delivery.hs:352-390`) with `member_id > cursor ORDER BY member_id` (the same on every relay, indexed by `UNIQUE(group_id, member_id)`; the cursor's own row may be gone), excluding relays and the requester; `memberInfo` with the redacted profile and no image, plus restrictions, as many as fit one message (`maxEncodedMsgLength`, `Protocol.hs:929`; 40–60); `cursor` = the last packed memberId, absent at the end. +- **Owner.** A page from a current relay while `members_catchup = 1`, in one transaction: a held `GSMemUnknown` member gets its profile and status as `xGrpMemNew` gives them (`updateRosterMemberAnnounced`, `Subscriber.hs:3243-3251`), never role or key; departed and other held members are skipped; new ones are created by `xGrpMemIntro`'s channel rule (`Subscriber.hs:3326-3334`, extracted): roles from member up at the default role without a key (the roster establishes those), subscribers with the relay-reported key. No per-member link write. Then the next request, or `members_catchup = 0`. A page from a relay asked earlier is applied; only the relay asked last gets the next request. ### 4.8 Removing an owner -- **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) and send `x.grp.mem.del X False Nothing` (`deleteMemsSend`, `Commands.hs:3191-3229`). Removing oneself is a leave. A crash between the swap and the callback sends no announcement; parties learn from link reads, relays within their check interval. -- **Receivers.** Relays and members apply an owner's removal of an owner from a current owner (`checkRole`, `Subscriber.hs:3783-3786`), as today; a relay forwards it to X first, then deletes X's connection (`:3755-3761`). -- **The removed owner's device** applies the signed removal of itself (own-membership branch, `Subscriber.hs:3734-3746`) on receipt, and a read showing itself revoked has the same effect (§4.4 step 3): it completes its departure: membership removed, keys erased (§4.9 step 3), catch-up stopped. -- **A removal racing a promotion.** A removal of a member and its promotion reach parties in either order. A party 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 applies an admin's removal of a member it made an owner less than `promotionRaceWindow` (1 day) ago (`owner_since`) as it would an owner's (`checkRole` gains this case in both branches of `xGrpMemDel`): relays, members, owners and the member's own device. Older owners, and owners learned from the chain (no `owner_since`), are refused as today. Every party ends with the member removed. -- **Removal wins.** An owner that holds member M as departed (left or removed) and sees M as a non-revoked chain entry (a commit or a read), or that applies the removal or leave of a member it held as an owner, enqueues a link sync; the sync revokes M when the read still lists it. It sends no announcement: the removal itself reached every party. +- **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. A party 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 of an owner whose broker timestamp is less than `promotionRaceWindow` (1 day) after its `owner_since` (the commit's broker timestamp, nearly the same on every party) as an owner's (`checkRole` gains this case in both branches of `xGrpMemDel`): relays, members, owners and the member's own device. Later removals, and owners learned from the chain (no `owner_since`), are refused as today. Every party ends with the member removed, one that learned it from the chain at its next read after owners revoke it. +- **Removal wins.** An owner that holds M departed (left or removed) 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. - 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 and either `stored_roster_version` is below `roster.version` (its claim landed but was not stored) or its stored roster is undelivered (no relay has `SENT` the last chunk of its `x.grp.roster` at `stored_roster_version`, in `msg_deliveries`, or there is no such message; in channels a message to a relay that is not ready is dropped, `memberSendAction`, `Internal.hs:2840-2846`), make an `RCResign` claim first (§4.10, through `writeChannelLink`; it sends the blob and one role event) and wait up to `channelOwnerWaitTimeout` (30 s), without the group lock (`SENT` is processed under it), for `SENT` of the new blob's last chunk to a relay; else refuse with `CLERelaysUnreachable`. +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. -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 the agent deletes the queue record locally, `Agent.hs:3080-3088`; no later write restores the key). +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`: `DEL` gets `AUTH` and the agent deletes the queue locally, `Agent.hs:3080-3088`). -A crash after step 2 is completed by the next read (§4.4 step 3). A repeated leave whose read shows itself already revoked completes step 3 and returns `CRLeftMemberUser`. +The next read completes a leave that crashed after step 2 (§4.4 step 3); a repeated leave whose read shows itself revoked completes step 3 and returns `CRLeftMemberUser`. ### 4.10 Roster ordered by the link -**Claim.** In multi-owner mode `APIMembersRole` and `APIRemoveMembers` call `claimRosterVersion` instead of `broadcastRoster` (`Commands.hs:3005-3008`, `:3136-3139`): `writeChannelLink` with `CLCRoster (RCChange delta targets startRoles)`, where `startRoles` are the targets' roles loaded when the command started. `RosterClaim = RCChange RosterDelta [GroupMember] [GroupMemberRole] | RCResign`. With `r` = read `roster.version` and `f` = `applied_complete_roster_version` (none = −1); a read without `roster` (before the creator's first promotion lands, or in a channel that never had a roster) gives `r` = `f` = this device's `roster_version` (none = −1), so a claim never goes below members' gate: +**Claim.** In multi-owner mode `APIMembersRole` and `APIRemoveMembers` call `claimRosterVersion` instead of `broadcastRoster` (`Commands.hs:3005-3008`, `:3136-3139`): `writeChannelLink` with `CLCRoster (RCChange delta targets startRoles)`, where `startRoles` are the targets' roles loaded when the command started. `RosterClaim = RCChange RosterDelta [GroupMember] [GroupMemberRole] | RCResign`. With `r` = read `roster.version` and `s` = `stored_roster_version`, the version of the complete roster this owner holds (its own claim or a blob it applied; none = −1). The complete frontier is not the test: it advances on the first delta of a version (`Subscriber.hs:3417-3421`), and a multi-member change has several. A read without `roster` (before the creator's first swap lands, or in a channel that never had one) gives `r` = `s` = this device's `roster_version`, so a claim never goes below members' gate: -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`. +1. If `s < r`: if the read author is this owner (its claim `r` was lost before it was stored or sent), claim from its own state; if the author is departed and the abandonment rule holds (below), claim `r + 2` from its own state; otherwise send `XGrpRosterRequest` with `s` 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, 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 `r + 1`, `roster_blob`, sender = own membership (header columns NULL); `sendStoredRoster`; return `r + 1` for the events. +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. 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`), 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 (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; with no roster in the link it enqueues an `LSResign` sync, which claims the next version), 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. +**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 `s` equals `link_roster_version`, the commitment last read or written; otherwise the owner requests the roster with `s` (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; with no roster in the link it enqueues an `LSResign` sync), 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.** Owners and members process a forwarded `XGrpRoster` header and versioned `XGrpMemRole` and `XGrpMemDel` from a departed owner by the roster gate, as from a current one (`xGrpMsgForward`, `Subscriber.hs:4019-4020`; roster chunks do not pass there, `:1131`); other content stays ignored. A relay processes them on a departed owner's kept connection up to `link_roster_version` (§4.13). Relays answer a current owner's `XGrpRosterRequest` whenever stored > requested (`xGrpRosterRequest`, `:3679-3685`), without the served-version bound kept for members. **Link sync rules**, the background roster work: -- (a) In every sync, when the read author is this owner with `stored_roster_version` below the read version, and no user write of this device holds the link writer lock (a held lock means a claim between its swap and its callback): store its own roster at that version (`setOwnGroupRoster`), `sendStoredRoster`, and one `x.grp.mem.role` at that version: the current role of the first current non-owner, non-relay member with a key, by memberId. Any event at that version makes members that missed earlier ones see a gap and fetch the blob (`applyAtRosterVersion` acts before the target lookup, `Subscriber.hs:3410-3424`). -- (b) In every sync, when `r > f`, the author is departed and the abandonment rule holds: an `RCResign` claim from its own state. -- (c) In `LSResign` syncs only, when the read author is this owner at its stored version and its stored roster is undelivered (as §4.9 step 1), or the read has no roster and a relay waits for its first one: an `RCResign` claim. +- (a) In every sync, when the read author is this owner with `stored_roster_version` below the read version, and no user write of this device holds the link writer lock (tested without waiting; a held lock means a claim between its swap and its callback): store its own roster at that version (`setOwnGroupRoster`), `sendStoredRoster`, and one `x.grp.mem.role` at that version: the current role of the first current non-owner, non-relay member with a key, by memberId. Any event at that version makes members that missed earlier ones see a gap and fetch the blob (`applyAtRosterVersion` acts before the target lookup, `Subscriber.hs:3410-3424`). +- (b) In every sync, when `s < r`, the author is departed and the abandonment rule holds: an `RCResign` claim of `r + 2` from its own state. +- (c) In `LSResign` syncs only, when the read author is this owner at its stored version and `roster_pending_msg_id` is set, or the read has no roster (relay set-up needs a committed one): an `RCResign` claim. -An `RCResign` claim writes `roster {r + 1, 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. +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 `f < r`, is abandoned (claim `r + 1` from its own state; nobody received `r`) when `link_roster_seen_at` is older than `rosterAbandonTTL` (1 day) and a request this owner sent 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 while behind, cleared when `f` reaches the read version). Claims and syncs check the rule when they need it; there is no timer. An owner that also missed earlier versions of that author (`f < 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)), and 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 event sends one 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. -**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 (`continueSending` true on `SENT` or `QCONT`, `:1197-1199`, `:1291-1295`): an owner whose stored roster is at `link_roster_version`, was authored by itself and is undelivered enqueues an `LSResign` sync. +**Undelivered roster.** `sendStoredRoster` of a roster this owner authored sets `groups.roster_pending_msg_id` to its last chunk's message; the relay `SENT` handler (`Subscriber.hs:1189-1194`) clears it for that message. 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 stored roster is at `link_roster_version` with the column set enqueues an `LSResign` sync. ### 4.11 Subscriber count @@ -249,87 +246,82 @@ 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. +- In multi-owner mode `runUpdateGroupProfile` (`Commands.hs:4164-4192`, behind every profile command) calls `writeChannelLink` with `CLCProfile base new`, `base` being the local profile at the command's start, compared as `J.toJSON` values; the callback writes the local profile and sends `XGrpInfo` with the written `linkVersion`. The API is unchanged. - The apps' profile editors refuse to save, with the "changed by another owner" alert, when the model's channel profile changed while the editor was open (§6.1; an `XGrpInfo` from another owner updates the model). The core check covers a change that reached the link but not this device. -- `xGrpInfo` (`Subscriber.hs:3840-3865`), on relays and members alike: an event with `linkVersion` below `link_data_version` is not applied; a higher one is applied and stored. Relays forward it as today. +- `xGrpInfo` (`Subscriber.hs:3840-3865`), on relays and members alike, gates locally, with no link read: an event whose `linkVersion` (absent = 0) is below `link_data_version` is not applied; a higher one is applied and stored. Relays forward it as today. A channel that never had a swap has no version and behaves as today. ### 4.13 Relays in a multi-owner channel - **Owner events** are handled as today, plus the commit (§4.6) and the admin-removal window (§4.8). Relays read the link at invitation and in every relay check and apply it (§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`, because its last blob can be queued on it. From that connection it processes only `x.grp.leave`, roster transfers (`xGrpRoster`, `:3503-3540`, and the chunks of a transfer started from it, `bFileChunkGroup`, `:2626-2633`) and versioned role and removal events, at versions up to `link_roster_version`, which the read that taught the departure set at or above the owner's last claim; the relay 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 by 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`). +- **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). - **Removal** of a relay (`Commands.hs:3149-3153`): `writeChannelLink` with `CLCRelays [] [link]`; a relay's own leave seen by any owner: a link sync. -- **`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 on reads. 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-1106`) is stored whatever the receiver's role (it can arrive before the commit); 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 it to an owner when it applies its commit or its join (`sendRelayCapIfNeeded`, `Internal.hs:2686-2696`, sends only on a domain change). +- **`APIAddGroupRelays`** without a group link row takes the link from `publicGroup.groupLink` (`Commands.hs:2790-2793`). A relay below `multiOwnerVersion` stays invited (its `updateRelayGroupKeys` fails); the owner removes it. +- **Owners join relays added by others** from `XGrpRelayNew` (`Subscriber.hs:3881-3886`) and on reads. The relay's `memberJoinRequestViaRelay` (`:1690-1720`) accepts a key-proven join onto a current owner row, as for roster rows, never onto a departed one; on the owner, `updatePreparedUserAndHostMembers'` (`Store/Groups.hs:821-844`) and the host `CON` branch (`Subscriber.hs:892-922`) leave an owner membership, the group profile and joined items alone. +- **Invitation by any owner.** `updateRelayGroupKeys` (`Store/Groups.hs:2353-2373`) creates a row (`createLinkOwnerMember`) for every chain owner without one and applies `revokedOwners`, which the inviting-owner check (`Subscriber.hs:4598`) excludes. +- **Capabilities.** `XGrpRelayCap` (`Subscriber.hs:1103-1106`) is stored whatever the receiver's role (it can precede the commit), without this device's `group_relays` row in `group_members.relay_web_domain` (`relayWebDomain` in JSON; `updateRelayCapabilities`, `Store/Groups.hs:1901-1911`). A relay sends it to an owner on its commit or join (`sendRelayCapIfNeeded`, `Internal.hs:2686-2696`, sends only on a domain change). ### 4.14 Other guards - **Durable rows.** In channels `getGroupMembersForExpiration` and `getRemovedMembersToCleanup` (`Store/Groups.hs:1357-1382`) skip owner and roster-role rows; an intro (`xGrpMemIntro`, `Subscriber.hs:3320-3322`) never changes a departed member's status. - **Group-link commands** `APICreateGroupLink`, `APIAddGroupShortLink`, `APIDeleteGroupLink` refused for channels (`Commands.hs:3349-3387`). - **`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; if the read fails it refuses, for the creator as for promoted owners; a departed device completes its departure and sends no `x.grp.del`. A promoted owner then 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). +- **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.** 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 -`ChatController.mainApp :: TVar Bool`, set by `startChatController` (`Commands.hs:232-269`). Link syncs, count timers and catch-up run only in the main app: the iOS notification extension starts with `mainApp = False` and does not resume agent commands (`subscribeUsers`, `Commands.hs:255`, `:336`); an event it processes leaves its trigger in a row (`owner_inv_accepted`, relay status, member status). Main-app chat start (`startChannelOwnerWork`, next to `startRelayChecks`) and `APIActivateChat` with `restoreChat` (`Commands.hs:599-610`; how iOS returns to the foreground) enqueue an `LSResign` sync for each multi-owner channel where the user is a current owner, which covers every pending kind of §4.4, and restart a pending member catch-up. +`ChatController.mainApp :: TVar Bool`, set by `startChatController` (`Commands.hs:232-269`). Link syncs, count timers and catch-up run only in the main app: the iOS notification extension starts with `mainApp = False` and resumes no agent commands (`subscribeUsers`, `Commands.hs:255`, `:336`); an event it processes leaves its trigger in a row (`owner_inv_accepted`, relay or member status). Main-app chat start (`startChannelOwnerWork`, next to `startRelayChecks`) and `APIActivateChat` with `restoreChat` (`Commands.hs:599-610`; iOS's return to the foreground) enqueue an `LSResign` sync for each multi-owner channel where the user is a current owner, covering every pending kind of §4.4, and restart a pending catch-up. ### 4.16 Types, database, configuration | Kind | Name | Notes | |---|---|---| -| command | `APIInviteGroupOwner {groupId, groupMemberId}` | → `CROwnerInvitationSent`; record syntax and Bot API docs, as every command | -| command | `APIAcceptGroupOwnerInvitation {groupId, chatItemId, allowDirect}` | → `CRChatItemUpdated` | +| commands | `APIInviteGroupOwner`, `APIAcceptGroupOwnerInvitation` (§4.6) | record syntax and Bot API docs, as every command | | error | `CEChannelLink {channelLinkError :: ChannelLinkError}` | `CLENotVerified`, `CLERelayVersion`, `CLELinkServerVersion`, `CLEDirectConnection {host, prohibited}`, `CLEChangedByOwner`, `CLERosterSyncing`, `CLELastOwner`, `CLENotOwner`, `CLELinkDataTooLarge`, `CLELinkBusy`, `CLERelaysUnreachable` | | item | `RGEOwnerInvitation {invStatus :: OwnerInvitationStatus}` | `OISPending`, `OISAccepted` | | command function | `CFChannelLinkSync`, `CFChannelLinkResign`, `CFChannelLinkSwap` | text `channel_link_sync`, `channel_link_resign`, `channel_link_swap` | -| column | `groups.link_data_version INTEGER` | highest `linkVersion` read, written, or carried by an applied `x.grp.info` | -| column | `groups.link_roster_version INTEGER`, `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.owner_since TEXT` (`TIMESTAMPTZ`) | when this device made the member an owner from a commit or its own promotion | -| column | `group_members.relay_web_domain TEXT` | relay capability for an owner without the relay's row | +| columns on `groups` | `link_data_version INTEGER`; `link_roster_version INTEGER`, `link_roster_seen_at TEXT` (`TIMESTAMPTZ`); `members_catchup INTEGER NOT NULL DEFAULT 0`; `roster_pending_msg_id INTEGER` | highest `linkVersion` read, written or applied; last roster commitment and when it first appeared; catch-up pending; own roster's last chunk not yet `SENT` | +| columns on `group_members` | `owner_inv_msg_id BLOB` (`BYTEA`), `owner_inv_accepted INTEGER NOT NULL DEFAULT 0`; `owner_since TEXT` (`TIMESTAMPTZ`); `relay_web_domain TEXT` | promoter's pending invitation, accepted; the commit's broker timestamp (§4.8); relay capability without the relay's row | | config | `channelLinkWriteAttempts = 5`, `channelCountPublishInterval = 60 s`, `rosterAbandonTTL = 1 day`, `rosterRequestTimeout = 5 min`, `memberCatchupTimeout = 1 min`, `channelOwnerWaitTimeout = 30 s`, `promotionRaceWindow = 1 day` | `ChatConfig` | | hook | `channelLinkWriteHook :: Maybe (GroupInfo -> IO ())` | `ChatHooks`, between a link read and its apply: in a user write and at channel open outside the group lock, in a link sync on the event thread; tests | -| state | `linkWriters :: TMap GroupId Lock`, `countPublishPending :: TMap GroupId ()`, `memberCatchups :: TMap GroupId (Int, Maybe MemberId)`, `rosterRequests :: TMap GroupId UTCTime`, `mainApp :: TVar Bool` | `ChatController` | +| state | `linkWriters :: TMap GroupId Lock`, `linkSyncRefusals :: TMap GroupId Int`, `countPublishPending :: TMap GroupId ()`, `memberCatchups :: TMap GroupId (Int, Maybe MemberId)`, `rosterRequests :: TMap GroupId UTCTime`, `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. ### 4.17 Races and where each is ordered -| Race | Ordered by | Where | Outcome | -|---|---|---|---| -| Two link writes of any kind | the swap | `swapQueueLinkData` | the second re-reads and re-applies, or is refused | -| A promotion by O1 while O2 removes O1 | the swap (keys and data together) | §4.4 | removal first: O1 gets `AUTH` or `CONFLICT` and reads itself revoked; else the removal keeps M's key | -| Two owners remove each other; a removal and a leave; the last two owners leaving | the swap; last-owner check per attempt | §4.4, §4.8 | one current owner remains | -| Two role changes | `roster.version` | `claimRosterVersion` | the second catches up and claims `v + 2`, or is refused for the same target | -| A role change just before leaving | the claim; the leaver's re-sign and wait for `SENT` | §4.9, §4.10 | relays, owners and members accept the departed author's roster messages | -| A role change in flight when its author is removed | relays' order of the two | §4.10 | a relay that processed the blob serves it; otherwise abandoned after a day, received by nobody | -| 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; devices keep the newer | -| A relay added while another is removed | the swap | `CLCRelays` | both kept | -| A member removed, by an owner or by an admin, or leaving, while promoted | the removal reaching every party; removal wins | §4.8 | the member ends departed | -| A user's role change and a background sync of the same device | the link writer lock | §4.10 rule (a) | the sync does not take the claim for a lost one | -| A read older than a promotion applied after its commit | reads only add owners | §4.4 | M stays an owner | -| A single-owner write queued before the first promotion | `LSET` refused on a multi-key queue | §4.1 | the late write gets `AUTH` | +| Race | Ordered by (where) | Outcome | +|---|---|---| +| Two link writes of any kind | the swap (§4.1) | the second re-reads and re-applies, or is refused | +| O1 promotes while O2 removes O1 | the swap: keys and data together (§4.4) | removal first: O1 reads itself revoked; else M's key is kept | +| Mutual removal; a removal and a leave; the last two leaving | the swap, last-owner check per attempt (§4.8, §4.9) | one current owner remains | +| Two role changes, one with several deltas | `roster.version`, claims from the complete roster (§4.10) | the second waits for the first's blob (`CLERosterSyncing`), then claims `v + 2`; refused for the same target | +| A destroy and a removal of its owner | the swap and `DEL` (§4.14) | whichever lands first; the other fails | +| 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 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 link sync, and a party that was offline, converge at the next read (§4.4). +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 new API commands, one new response and new types, documented in the Bot API docs. The agent's `LDATA` event gains a field and its command store a constructor (both internal). +- **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. - **Database.** One chat migration, columns only, both backends; existing rows get NULL or 0. No agent or server schema change. -- **Downgrades.** An older server skips `LKEYS` lines and restores keys from the `KEYS` lines; it loses those swaps' data since its last start and accepts `LSET` again (multi-owner writes stop: there is no `LSWP`). An older creator's `LSET` on the multi-key queue gets `AUTH`. +- **Downgrades.** An older server skips `LKEYS` lines and restores keys from the `KEYS` lines; it loses those swaps' data since its last start and accepts `LSET` again (multi-owner writes stop: there is no `LSWP`); readers ignore its older data until the next write, which takes the version past theirs. An older creator's `LSET` on the multi-key queue gets `AUTH`. An older agent marks stored `LSWP` commands failed and keeps them, with their keys, in its command table. - **Older parties.** Promotion needs every relay at chat v23 and the link server at SMP v24 (link servers, presets included, upgrade first). Older members ignore `x.grp.owner.add` and reject the new owner's signed messages until they upgrade; older joiners take every chain entry as an owner, including revoked ones, whose keys authorise nothing on the link queue. - **Adversary.** - 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 can only add owners or mark revoked ones departed; ignoring the compare-and-swap brings back lost writes and roster divergence, no forgery. - - Owners and removed owners are trusted (non-goal). Honest software sends an owner action only after a successful link write or a current read. 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 there; afterwards the relay accepts from it only its departure and roster messages up to the link's version. With a colluding link server it can serve forged link data to readers. + - 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 within `promotionRaceWindow` of a device learning its promotion (§4.8). -- **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 owners act. `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, in the same encrypted database as its queue keys. +- **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 owners act and, from the key set, 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, in the same encrypted database as its queue keys, until it completes or departure deletes its mailbox connection. ## 6. Slices @@ -337,20 +329,23 @@ Each slice compiles and passes its tests on its own. simplexmq: `cabal test simp | # | Slice | Est. | Done when | |---|---|---|---| -| S1 | simplexmq store: `LinkDataHash`, `CONFLICT`, `swapQueueLinkData` (STM, Postgres, journal), `CreateLinkKeys` + `UpdateKeys` logging and replay | ~170 | store, store-log and Postgres store tests | -| S2 | simplexmq protocol: v24, `LSWP`, handler, `LSET` refusal on multi-key queues, client, protocol doc | ~110 | server tests | +| S1 | simplexmq store: `LinkDataHash`, `CONFLICT`, `swapQueueLinkData` (STM, Postgres, journal), the multi-key `LSET` refusal inside the store, `CreateLinkKeys` + `UpdateKeys` logging and replay | ~190 | store, store-log and Postgres store tests, an `LSET` racing a key-adding swap | +| S2 | simplexmq protocol: v24, `LSWP`, handler, client, protocol doc | ~100 | server tests | | S3 | simplexmq agent: §4.2, sync and async | ~200 | 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, command functions, commands in record syntax (refusing), optional `groupLink` in `CEvtGroupLinkDataUpdated` and `CRGroupRelaysAdded`, config, hook, state; Bot API docs and generated files (later API changes regenerate them) | ~290 | `-m "channels"`, protocol, JSON and "Bot API docs" tests | -| S6 | link core: locks, `writeChannelLink`, link syncs with no pending kinds yet (mailbox, continuations, `LDATA`/`OK`/`ERR` dispatch), `applyChannelLinkData`, `currentOwner`, multi-owner mode, `linkVersion`, `XGrpInfo` gating, durable rows, intro guard, relay reads and tolerant invitation, main-app gating and chat start | ~270 | T12, existing tests | -| S7 | callers: profile check (core; the editors' rule is in S13–S14), relays (activation, publication, removal, owner joins, capabilities), count publisher and threshold | ~190 | T5 (profile, relays), T11, existing tests | -| S8 | promotion: invite, accept with the direct-connection check, `CLCAddOwner` in syncs with the first-promotion store, commit, M becomes owner | ~230 | T1 (without the role change), T2, T13, T17, T20 | -| S9 | member catch-up with watchdog | ~120 | T10 | -| S10 | roster: claim, owners outside the roster, stored-blob rules, departed authors on owners, members and relays, sync rules (a)–(c), abandonment, relays serving owners, undelivered-roster triggers | ~240 | T1 role change, T5 roles, T9, T15, T21, T22 | -| S11 | removal and leave: removal, removed device, removal wins, admin-removal window, leave with re-sign and wait, destroy | ~170 | T3 (without the history check), T4, T6–T8, T14, T16, T18, T19 | -| S12 | guards: group-link commands, share signing, connect plan, history | ~60 | existing tests, T3's history check | -| S13 | iOS (§6.1) | ~380 | host build, manual checks | -| S14 | Kotlin, mirroring S13 | ~380 | host build, manual checks | +| S4 | chat on the new simplexmq, structural: the `LDATA` match, Bot API docs regenerated for `CONFLICT`; extract `activateGroupRelays` and the channel intro member creation | ~90 | `-m "channels"` and "Bot API docs" green, no behaviour change | +| S5 | chat wire and data (inert): migration, link fields, events, versions, errors, command functions, commands in record syntax (refusing), their view and CLI output, optional `groupLink` in `CEvtGroupLinkDataUpdated` and `CRGroupRelaysAdded`, config, hook, state; Bot API docs (later API changes regenerate them) | ~300 | `-m "channels"`, protocol, JSON and "Bot API docs" tests | +| S6 | link writes: locks, `writeChannelLink`, `applyChannelLinkData`, `currentOwner`, multi-owner mode, `linkVersion`, `XGrpInfo` gating, durable rows, intro guard, relay reads and tolerant invitation | ~200 | existing tests | +| S7 | link syncs with no pending kinds yet: mailbox, continuations, `LDATA`/`OK`/`ERR` dispatch, coalescing, refusal bound, main-app gating, chat start | ~170 | existing tests | +| S8 | callers: profile check (core; the editors' rule is in S14–S17), relays (activation, publication, removal, owner joins, capabilities), count publisher and threshold | ~190 | existing tests, single-owner relay and count behaviour unchanged | +| S9 | promotion: invite, accept with the direct-connection check, `CLCAddOwner` in syncs with the creator's first-swap fields, commit, M becomes owner | ~220 | T1 (without the role change), T2, T5 (profile, relays), T12, T13, T17 (without removal wins), T20 | +| S10 | member catch-up with watchdog | ~120 | T10 | +| S11 | roster: claim from the complete roster, owners' early requests, owners outside the roster, stored-blob rules, departed authors on owners, members and relays, sync rules (a)–(c), abandonment, relays serving owners, the undelivered-roster column and triggers | ~250 | T1 role change, T5 roles, T21, T22 (without the leave case) | +| S12 | removal and leave: removal, removed device, removal wins, admin-removal window, leave with re-sign and wait, destroy | ~200 | T3 (without the history check), T4, T6–T9, T11, T14–T16, T17 removal wins, T18, T19, T22 leave case | +| S13 | guards: group-link commands, share signing, connect plan, history | ~60 | existing tests, T3's history check | +| S14 | iOS core-facing (§6.1): API, types, errors, the invitation item, strings | ~180 | host build | +| S15 | iOS views (§6.1): role picker, remove, leave, link screen, channel open, editors, composer, relays | ~200 | host build, manual checks | +| S16 | Kotlin, mirroring S14 | ~180 | host build | +| S17 | Kotlin, mirroring S15 | ~200 | host build, manual checks | ### 6.1 Apps (iOS and Kotlin, mirrored) @@ -360,9 +355,9 @@ Same file split, names and branch order on both platforms. |---|---|---| | API: `apiInviteGroupOwner`, `apiAcceptGroupOwnerInvitation(allowDirect)`, `ownerInvitationSent`; `groupLink` optional in `groupRelaysAdded`, `groupLinkDataUpdated`, `AddGroupRelaysResult.added` | `SimpleXAPI.swift`, `AppAPITypes.swift:1001, 1210` | `SimpleXAPI.kt:6815, 6846` | | Errors: `channelLink(ChannelLinkError)`, one alert per case; SMP `CONFLICT` | `APITypes.swift:744+` | `SimpleXAPI.kt:7807+` | -| `RcvGroupEvent.ownerInvitation(invStatus)`: text, notification, bold like `newMemberPendingReview`; tapping a pending one asks "Become an owner?" (the profile goes to all subscribers; the device connects directly to , revealing its IP address, for channel changes and the count) → accept; on `directConnection`, confirm and repeat with `allowDirect`, or, when `prohibited`, explain the network setting; an accepted one has no action | `ChatTypes.swift:6054+`, `ChatItemView.swift:153-157` | `ChatModel.kt:5447+`, `ChatItemView.kt:734-748` | -| Role picker: Owner for a current owner and a current non-owner member with a key; unverified → verification (`APIVerifyGroupMember`); else confirm (member is asked; owners can remove owners; any owner can delete the channel) → `apiInviteGroupOwner`; no picker for owner targets | `ChatTypes.swift:3382-3394`, `GroupMemberInfoView.swift:327-331, 731-768` | `ChatModel.kt:2944-2955`, `GroupMemberInfoView.kt:149-158, 947-991` (roles list keyed so it recomputes) | -| Remove: "Remove owner" label and confirmation | `GroupMemberInfoView.swift:697-707`, `GroupChatInfoView.swift:995-1044` | `GroupMemberInfoView.kt:843-854`, `:253-334` | +| `RcvGroupEvent.ownerInvitation(invStatus)`: text, notification, bold like `newMemberPendingReview`; a pending one asks "Become an owner?" (the profile goes to all subscribers; the device connects directly to , revealing its IP address) → accept; on `directConnection` confirm and repeat with `allowDirect`, or when `prohibited` explain the setting | `ChatTypes.swift:6054+`, `ChatItemView.swift:153-157` | `ChatModel.kt:5447+`, `ChatItemView.kt:734-748` | +| Role picker: Owner for a current owner and a current non-owner member with a key; unverified → verification (`APIVerifyGroupMember`); else confirm (member is asked; owners can remove owners and delete the channel) → `apiInviteGroupOwner`; no picker for owner targets | `ChatTypes.swift:3382-3394`, `GroupMemberInfoView.swift:327-331, 731-768` | `ChatModel.kt:2944-2955`, `GroupMemberInfoView.kt:149-158, 947-991` (roles list keyed to recompute) | +| Remove: "Remove owner" label and confirmation | `GroupMemberInfoView.swift:697-707`, `GroupChatInfoView.swift:995-1044` | `GroupMemberInfoView.kt:253-334, 843-854`, `GroupChatInfoView.kt:278+` (`removeMemberAlert`) | | Leave in the channel info view when another current owner exists; core refusals shown (the chat list keeps today's rule: it has no members loaded) | `GroupChatInfoView.swift:249-254`, `SimpleXAPI.swift:2047-2060` | `GroupChatInfoView.kt:828-838`, `SimpleXAPI.kt:2493-2498` | | Link screen: an owner without a `GroupLink` sees the published link; never creates a link for a channel; SimpleX-name setting for any current owner | `GroupChatInfoView.swift:113-127, 162-170, 402-403`, `GroupLinkView.swift:164-166` | `GroupChatInfoView.kt:599-611, 642-653, 670-681`, `GroupLinkView.kt:119-123` | | Channel open: owners also call `apiGetUpdatedGroupLinkData` | `ChatView.swift:762-780` | `ChatView.kt:225-242` | @@ -378,36 +373,36 @@ Not compiled in the container; built on the host. **simplexmq** (`tests/ServerTests.hs` per store, `tests/CoreTests/StoreLogTests.hs`, `tests/CoreTests/MsgStoreTests.hs`, `tests/AgentTests/FunctionalAPITests.hs`): - `LSWP`: right hash replaces user data and keeps fixed data; wrong hash `CONFLICT`, nothing changed; no link `AUTH`; messaging queue `AUTH`; via proxy `CMD PROHIBITED`; v23 session cannot send it; 52 Ed25519 keys fit. -- With keys: the removed key gets `AUTH`, the added key writes; `LSET` on a two-key queue gets `AUTH`; concurrent swaps: one `OK`, one `CONFLICT`, the second then succeeds (Postgres-only store included). +- With keys: the removed key gets `AUTH`, the added key writes; `LSET` on a two-key queue, and one racing a key-adding swap, get `AUTH`; concurrent swaps: one `OK`, one `CONFLICT`, then success (Postgres-only store included). - Restart: a key-changing swap replays data and keys; a data-only swap replays as `LINK`; a log without `LKEYS` lines still restores the keys. -- Agent: `LDATA` and the sync read carry the hash both swaps accept; a root-signed and an owner-signed swap are read and verified by `getConnShortLink`; a removed signer gets `AUTH`; the async swap notifies `OK`, reports `CONFLICT` as `ERR`, retries while the server is down and resumes after a restart; `deleteLinkQueueWithCreds`; `getConnLinkQueueCreds`; `linkServerSupportsSwap` false on a v23 server. +- Agent: `LDATA` and the sync read carry the hash the swaps accept; root- and owner-signed swaps verify in `getConnShortLink`; a removed signer gets `AUTH`; the async swap notifies `OK`, reports `CONFLICT` as `ERR`, retries while the server is down, resumes after a restart; `deleteLinkQueueWithCreds`; `getConnLinkQueueCreds`; `linkServerSupportsSwap` false on v23. **chat**, `describe "multiple owners"` under `describe "channels"` (`tests/ChatTests/Groups.hs:281`), relays as in `createChannel2Relays` (`:9616`): | # | Test | |---|---| -| T1 | verify, invite, accept; an earlier subscriber accepts M's signed post; a member that knew M through a relay-asserted key ends with the chain key; M edits the profile and changes a role | +| T1 | verify, invite, accept; an earlier subscriber accepts M's signed post; a member that knew M by a relay-asserted key ends with the chain key; M edits the profile and changes a role | | T2 | refusals: unverified; a relay below v23; a link server at SMP v23; M ignores a chain entry with another key | -| T3 | creator promotes M and leaves; M posts through every relay, edits the profile, adds and removes a relay, promotes a verified subscriber, destroys the channel (relays and subscribers process it); a later joiner sees M as owner, the creator departed, verifies the roster, gets the creator's history unsigned | -| T4 | O1 removes O2: O2's device applies the removal on receipt and erases its keys, its next write fails; relays, members and a new joiner treat O2 as departed | -| T5 | with the hook holding O1 between read and swap: O2's profile edit lands, O1's is refused; O1's edit while O2's `XGrpInfo` has not reached it yet is refused; O1 adds and O2 removes relays, both kept; O1 and O2 change roles of different members, every relay and member ends with both | +| T3 | the creator promotes M and leaves; M posts through every relay, edits the profile, adds and removes a relay, promotes a verified subscriber, destroys the channel; a later joiner sees M as owner, the creator departed, verifies the roster, gets the creator's history unsigned | +| T4 | O1 removes O2: O2 applies it on receipt and erases its keys, its next write fails; relays, members and a new joiner treat O2 as departed | +| T5 | the hook holds O1 between read and swap: O2's profile edit lands, O1's is refused, as is O1's edit before O2's `XGrpInfo` reaches it; O1 adds and O2 removes relays, both kept; O1 and O2 change roles of different members (O1 retrying after `CLERosterSyncing`), all end with both; O2 changes two members in one command and O1, holding one delta, is refused until it has the blob | | 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 and the link revokes M | -| T9 | O1 demotes the only admin and leaves while O2 is offline; O2 returns and accepts O1's roster from a relay; members that receive O1's role events after its leave apply them; a joiner gets the 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, from the first chain entry only | -| T12 | `linkVersion`: a lower `XGrpInfo` is not applied by relays and members; a stale read does not revert the profile | -| T13 | a single-owner async write queued before the first promotion lands after it and gets `AUTH`; the link keeps M | +| T8 | removal wins: O2 removes subscriber M while the hook holds O1's promotion of M; M ends departed everywhere, revoked in the link | +| T9 | O1 demotes the only admin and leaves while O2 is offline; O2 returns and gets O1's roster from a relay; members receiving O1's role events after its leave apply them; a joiner gets the roster; the demotion holds | +| T10 | catch-up: earlier subscribers appear on M with keys (it verifies and promotes one); a restart restarts and skips held members; a silent relay is replaced after the timeout, no member skipped | +| T11 | count: after the creator leaves, joins and leaves change the count within the (shortened) interval, from the first chain entry only | +| T12 | a lower or unversioned `XGrpInfo` is not applied by relays and members; a stale read does not revert the profile | +| T13 | single-owner async writes queued before the creator's first swap land after it with `AUTH`; the link keeps the swap's data and M | | 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; 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 (test-shortened) TTL and an unanswered request, and the next claim proceeds | -| T17 | background work: an acceptance processed while the link server is down promotes once it is back; a restart between the swap and the next read sends the commit once; a relay acknowledgement publishes the relay; a sync whose mailbox relay was removed is redone at chat start | -| T18 | an admin removes M while O1's promotion of M is held: M ends departed everywhere, relays and M's device applying the commit and the removal in either order; an admin's removal of an owner older than the (test-shortened) window is refused | -| T19 | destroy by a device that left after its swap sends no `x.grp.del`; the creator's destroy is refused while its link read fails | +| 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 | +| 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 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 (re-sign, wait, leave) | +| 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 | +| T22 | relays not ready when O1 demotes an admin: O1's chat start, and a relay connection becoming ready, re-sign at the next version and members fetch it; the same with O1 leaving (re-sign, wait, leave) | 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). @@ -415,8 +410,8 @@ Existing tests that must stay green: the whole `-m "channels"` group, `ProtocolT ## 8. Risks -- **Riskiest assumption:** members' and relays' unchanged roster protocol converges when versions are claimed through the link and departed owners' roster messages are accepted by the gate. T5, T9, T15, T21 and T22 in S10; S1–S2 first prove the swap's atomicity and size. -- Background syncs re-derive every callback from the latest read and rows; a callback condition that is not idempotent would send twice or never. T17 in S8 exercises lost events and restarts early. +- **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 @@ -425,11 +420,12 @@ Questions 1–3 are decided: role changes need the link server; consent by verif 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 for `promotionRaceWindow` (1 day) after a device learns of a promotion it applies an admin's removal of that owner. 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, 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. +7. Blocking and unblocking a member stay unordered between owners, admins and moderators, as in groups today. Recommended: as planned (parity); otherwise restrictions carry the roster version and are claimed through the link like role changes. ## 10. Out of scope -Pre-existing single-owner issues: relay forwarding order (`Store/Delivery.hs:178-224`); messages to a backlogged relay dropped (`Internal.hs:2840-2846`); a queued async link write overwriting a newer one (`Agent.hs:1191-1200`); the async `LSET`'s client data lost by its parser (`Agent/Protocol.hs:2322`, `:2075-2079`); admin promotion using relay-reported keys (`Commands.hs:3053`); receivers letting an admin remove an admin in relay groups (`Subscriber.hs:3783-3786`); block and unblock not ordered. +Pre-existing issues: relay forwarding order (`Store/Delivery.hs:178-224`); messages to a backlogged relay dropped (`Internal.hs:2840-2846`); a queued async link write overwriting a newer one (`Agent.hs:1191-1200`); the async `LSET`'s client data lost by its parser (`Agent/Protocol.hs:2322`); admin promotion with relay-reported keys (`Commands.hs:3053`); receivers letting an admin remove an admin in relay groups (`Subscriber.hs:3783-3786`). ## 11. Follow-up runs diff --git a/plans/2026-10-15-channel-multi-owner-s3min.md b/plans/2026-10-15-channel-multi-owner-s3min.md index d36f48f041..899608dde9 100644 --- a/plans/2026-10-15-channel-multi-owner-s3min.md +++ b/plans/2026-10-15-channel-multi-owner-s3min.md @@ -9,46 +9,47 @@ The implementation plan is `2026-10-15-channel-multi-owner-s3min-implementation. | Action | How it works for users | |---|---| | 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; it, relays and members apply the removal as they receive it (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; with several owners it needs the link server reachable | -| See the members | a new owner downloads the member list from a relay in the background, without profile pictures | -| Subscriber count | keeps updating after the creator leaves; it may lag joins by up to a minute | +| Make a member an owner | after verifying its 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, first recorded on the link server | +| Remove an owner | any owner; the removed device can no longer change the link at once, and it, relays and members apply the removal on receipt | +| Leave | while another owner remains; with several owners the last one cannot leave | +| Destroy the channel | any owner; with several owners it needs the link server reachable | +| See the members | a new owner downloads the member list from a relay, without profile pictures | +| Subscriber count | keeps updating after the creator leaves, lagging joins by up to a minute | -Single-owner channels keep working as today. +Single-owner channels, and channels whose owner invitations nobody accepted, keep working as today. ## How it works The channel's link data lives on one SMP server, the link server, which already orders every write to it. The design makes it order everything the owners share: -- **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 unchanged, and in the same step replaces the owner keys allowed to write; otherwise the owner reads again and re-applies its change. No write is lost, and owners and keys change together. The old plain write is refused on a link with several owners. -- **Role changes are numbered on the link server.** An owner claims the next roster version with the same compare-and-swap before sending the change, so each version has one author who had applied all earlier ones, as members and relays expect today. They only keep applying a role change whose author left before it arrived. -- **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, since such a relay could also sign the acceptance. -- **Background work survives restarts.** What an owner does without a user action (finishing a promotion, publishing relays and the count, repairing the link after a race, re-sending its roster) runs as agent commands that the agent stores and retries; each step works out what is left from the latest link data and the database. -- **Member list and count.** A new owner pulls the member list from a relay page by page (a relay drops messages to an owner's full queue, so pushing would lose data). The first current owner in the link's list publishes the subscriber count, at most once a minute. +- **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 it only if the data is unchanged, replacing the owner keys in the same step. Otherwise the owner reads again and re-applies its change. +- **Role changes are numbered on the link server**, claimed with the same command from the complete current roster, so each version has one author who had all earlier ones, as members and relays expect today. +- **Consent.** The promoter invites the member in its support chat; the member accepts; one write adds it. The verified security code stops a relay that substituted the member's key. +- **Background work** (finishing a promotion, publishing relays and the count, repairing the link after a race, re-sending a roster) runs as agent commands the agent stores and retries; each step works out what is left from the latest link data. +- **Member list and count.** A new owner pulls the member list from a relay page by page. The first current owner in the link's list publishes the count, at most once a minute. ## Decisions | Decision | Choice | |---|---| | Role changes need the link server | accepted: 1–3 s per change in a channel with several owners | -| Consent and key-substitution defence | verified security code plus an invitation in the channel; works for any member | +| Consent and key-substitution defence | verified security code plus an invitation in the channel | | Member list for a new owner | pulled from one relay, no profile pictures | | Optional parts | left out: invitation cancel, field-by-field profile merge, equal-version tie-break, retiring the root key | -| Server command shape | sends only the new user data and keys (room for about 52 owners); refuses the old plain write on a link with several owners | +| Server command | sends only the new user data and keys (room for about 50 owners) | | Relays and owner actions | as today, not checked against the link: owners are trusted, and relays cannot forge their actions | | A removal at the moment of a promotion | the removal wins everywhere | +| Blocking and unblocking | not ordered, as in groups today | ## Trade-offs users notice -- **Owner actions need the link server.** Role changes, removal, leaving, promotion and destroying the channel need it reachable; while it is down an owner can still post, and background work completes once it is back. A role change right after another owner's waits a few seconds ("catching up, try again"). -- **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, as the first owner, for the count, revealing its IP address to that server's operator. The invitation says so and names the server; private-routing settings need an explicit confirmation to accept, and settings that prohibit direct connections block owner writes. Link reads stay private. -- **The link server learns when owners act.** -- **An admin's removal wins over a promotion made at the same moment.** For a day after a device learns that a member became an owner, it accepts an admin's removal of that member. -- **Old apps and servers.** Promotion needs every relay on the new version and the link server on the new SMP version. Subscribers on older apps ignore new owners until they upgrade, so they reject a new owner's signed actions. +- **Owner actions need the link server** (role changes, removal, leaving, promotion, destroying); while it is down owners can still post, and background work completes once it is back. A role change right after another owner's can be refused for a few seconds ("catching up, try again"). +- **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. +- **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. ## Residual risks @@ -56,18 +57,18 @@ The channel's link data lives on one SMP server, the link server, which already None of these is a race between honest parties: - **A link server that ignores the compare-and-swap** can make honest owners lose writes or diverge on roles. It cannot forge anything. -- **A removed owner running modified software**, until a relay learns of the removal (the remover's announcement, normally seconds, or the relay's link check every 15 minutes), can still act through that relay as an owner, including sending role changes numbered so high that later ones are dropped there. Afterwards the relay accepts from it only its departure and the role changes it made while still an owner. With a colluding link server it can serve forged link data to readers. -- **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 removed owner running modified software**, until a relay learns of the removal (the remover's announcement, normally seconds, or the relay's 15-minute link check), can act through that relay as an owner, including role changes numbered so high that later ones are dropped there and at its members; afterwards the relay accepts from it only its departure and role changes up to the link's roster version. With a colluding link server it can serve forged link data. +- **Any current owner can destroy the channel or lock the others out**: removal is administration, not a defence. - **An admin running modified software** can remove an owner within a day of that owner's promotion. -- **A role change can be abandoned** when nobody received it: its author's device is lost in the second between recording and sending it, its author is removed before any relay got it, or it reached only a relay that then failed. While a lost author is still an owner the others cannot change roles until they remove it; then 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 too, although some devices received them. -- **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. +- **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. - **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 -App code is not compiled and Postgres tests do not run in this environment (both on the host). The 52-key headroom is computed from the encoders (the first slices test it); catch-up for 100,000 members (about 2,000 messages) is an estimate. +App code is not compiled and Postgres tests do not run in this environment (both 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. ## Size -About 2,150 lines of production Haskell (~480 in simplexmq, ~1,670 in chat) and about 380 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,290 lines of production Haskell (~490 in simplexmq, ~1,800 in chat) and about 380 per app platform. Five new chat events and one changed; five optional link-data fields; nine columns and no new table; one SMP command and one SMP error.