mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 10:12:25 +00:00
docs: channel multi-owner S3-min plans: review round 2 fixes - undelivered roster marked when stored, admin removal window on each party's own arrival times, non-owners follow the link for departed chain owners, owners refetch a duplicate roster header, sync swaps bounded per chain, multi-owner mode only on current owners, restored creators read before leaving
This commit is contained in:
1 parent
bedcb3a5cc
commit
52ffef9e17
2 files changed
+43
-50
No files matched your search
@@ -30,12 +30,9 @@ Protection against current or removed owners (they are trusted as the single own
|
||||
|
||||
| Where | Today |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `Internal.hs:1618-1685`, `:2642-2654`, `Commands.hs:2994-2995` | one owner, the creator: owners `[self]` signed by root, the count republished by async `LSET` per join or leave, roster version = gate + 1; the owner role refused in channels |
|
||||
| `Store/Groups.hs:2353-2373`, `Subscriber.hs:3783-3786`, `:4019-4020` | relays read owners only at invitation and drop an admin's removal of an owner; 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; reads keep the encrypted bytes local; async commands belong to a connection, retried by the agent |
|
||||
|
||||
## 4. Design
|
||||
|
||||
@@ -47,7 +44,7 @@ The link server orders everything owners share. One new SMP command compares the
|
||||
- **Types and error** (`Protocol.hs`): `newtype LinkDataHash = LinkDataHash ByteString`, the SHA-256 (32 bytes) of the stored `EncDataBytes` contents (nonce and ciphertext, no wire length prefix). `CONFLICT` added to `ErrorType` (string and binary encodings `:1605-1703`, `:2145-2182`; JSON by TH); only `LSWP` returns it and it is not delayed like `AUTH`.
|
||||
- **Command:** `LSWP :: LinkDataHash -> Maybe (NonEmpty RcvPublicAuthKey) -> EncUserDataBytes -> Command Recipient`, tag `"LSWP"`, `e (LSWP_, ' ', h, keys_, ud)`, parsed `LSWP <$> _smpP <*> smpP <*> smpP`. The fixed data and link ID are not sent; the server keeps them. A transmission (16,365 bytes, `Protocol.hs:2359-2360`) carries the 13,826-byte user data and up to 52 Ed25519 keys; an `LSET`-shaped command would fit 6.
|
||||
- **Handler** (`Server.hs`, near `:1574`): `LSWP h ks_ ud -> withQueue $ \q qr -> checkMode QMContact qr $ OK <$$ liftIO (swapQueueLinkData (queueStore ms) q h ks_ ud)`. Not in the proxy allow-list (`:2198-2211`).
|
||||
- **`LSET` on a queue with more than one recipient key gets `AUTH`** (`Server.hs:1575-1579`), checked inside the store's transaction as the swap checks its hash, not on the handler's snapshot, which a concurrent `LSWP` can change; store-log replay of `CreateLink` skips the check. Only multi-owner links have several keys (nothing sends `RKEY` today), so a stale single-owner write, or a creator downgraded to an app without `LSWP`, cannot overwrite multi-owner link data.
|
||||
- **`LSET` on a queue with more than one recipient key gets `AUTH`** (`Server.hs:1575-1579`), checked inside the store's transaction as the swap checks its hash, not on the handler's snapshot, which a concurrent `LSWP` can change; store-log replay of `CreateLink` and `LKEYS` skips the check. Only multi-owner links have several keys (nothing sends `RKEY` today), so 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`); 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`).
|
||||
@@ -63,16 +60,16 @@ Every owner, the creator included, writes with explicit credentials: `LinkOwnerC
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `setShortLinkSwapAsync :: AgentClient -> ACorrId -> ConnId -> LinkOwnerCreds -> LinkDataHash -> Maybe (NonEmpty RcvPublicAuthKey) -> UserContactData -> AE ()` | the same as an async command on `(connId, Just server)`, `connId` a mailbox only (`Agent.hs:1206-1210`); event `OK` |
|
||||
| `deleteLinkQueueWithCreds :: AgentClient -> NetworkRequestMode -> UserId -> LinkOwnerCreds -> AE ()` | `DEL` with `rcvAuthKey` |
|
||||
| `getConnLinkQueueCreds :: AgentClient -> ConnId -> AE (SMP.RecipientId, SMP.RcvPrivateAuthKey)` | the creator's link queue ID and key, read locally as `getConnLinkPrivKey` reads the signing key (`Agent.hs:494-496`) |
|
||||
| `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` | the settings route to this server privately and the connection is not IP-protected (`shouldUseProxy`, `ipAddressProtected`, `Agent/Client.hs:1190-1211`) |
|
||||
|
||||
- **Async read with the hash.** `LDATA` gains a `LinkDataHash` field (`Agent/Protocol.hs:407`); `getConnShortLink'` computes it from the bytes it read (`:1339`), the sync `getConnShortLink` drops it, and the chat match (`Subscriber.hs:1245`) and agent tests take the field. `getConnShortLinkAsync` is unchanged.
|
||||
- **Async swap command.** `ACommand` gains `LSWP LinkOwnerCreds LinkDataHash (Maybe (NonEmpty RcvPublicAuthKey)) UserContactData` (`Agent/Protocol.hs:466-475`), tag `"LSWP"`, `strEncode` with the keys as `C.StoredPrivateKey` and the user data last, since its parser consumes the rest (`:2075-2079`); worker branch `withServer' . tryCommand $ setShortLinkSwap' c NRMBackground userId creds h ks_ ud >> notify OK` (as `LSET`, `Agent.hs:2255-2258`), reporting `TEVersion` as `ERR` instead of retrying it as a host error (`Agent/Client.hs:1627-1637`). The keys stay in the command table until it completes or its mailbox connection is deleted. Commands are blobs without a version (`Agent/Store.hs:509-520`): no migration.
|
||||
- **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`.
|
||||
- **Direct connections.** The creds calls and `linkServerSupportsSwap` fail with `CMD PROHIBITED` when `linkServerPrivateRouted` holds and the agent's policy allows no direct fallback (`directAllowed`, `Agent/Client.hs:1204-1208`). Owner validation fails with `AGENT A_LINK`.
|
||||
- `Crypto/ShortLink.hs`: extract and export `mkOwnerAuth :: OwnerId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> OwnerAuth`; `newOwnerAuth` (`:81-85`) uses it.
|
||||
- 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`).
|
||||
|
||||
@@ -92,7 +89,7 @@ Keys, IDs and `OwnerAuth` encode in JSON as base64url of their `smpEncode`. A wr
|
||||
|
||||
**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), 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`.
|
||||
**Multi-owner mode** (`isMultiOwnerChannel`), on a device whose membership is a current owner: it 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).
|
||||
|
||||
@@ -112,11 +109,11 @@ Keys, IDs and `OwnerAuth` encode in JSON as base64url of their `smpEncode`. A wr
|
||||
|
||||
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), 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_`).
|
||||
**Link sync**, the background path: `enqueueLinkSync :: User -> GroupInfo -> LinkSyncKind -> CM ()` (`LSSync | LSResign`, §4.10), main app only (§4.15). The count timer's sync is skipped while a sync read row for the mailbox, under 10 minutes old, is pending; other triggers always enqueue. The mailbox is the owner's `ConnReady` connection to a current relay (an `ERR` does not fail it, `Subscriber.hs:179`; its events take the group lock), preferably one whose receive queue is not on the link server, as the agent runs one worker per connection and server (`Agent.hs:2226-2228`), then the lowest member ID; without one there is no sync until the next trigger. Deleting the mailbox connection, as departure does, deletes its pending commands and their keys once the agent completes it (a swap that runs first gets `AUTH`). New `CommandFunction`s (`Types.hs:2162-2225`): `CFChannelLinkSync`, `CFChannelLinkResign` (expect `LDATA_`), `CFChannelLinkSwap` (expects `OK_`).
|
||||
|
||||
1. `createCommand db user (Just mailbox) fn`, then `getConnShortLinkAsync` with the mailbox (`Internal.hs:3160-3165`).
|
||||
2. `LDATA` continuation (`processGroupMessage`, `Subscriber.hs:1245`, under the group lock): decode; `channelLinkWriteHook`; `applyChannelLinkData`; if this device is departed, stop. Run the callbacks the read shows due, then apply each pending change in turn (`applyLinkChange`; a refusal is handled per change). If the result differs from the read: key set, creator fields and `linkVersion` as in steps 3–4, `createCommand … CFChannelLinkSwap`, `setShortLinkSwapAsync` with the read's hash.
|
||||
3. Swap continuation (`Subscriber.hs:1234-1236`, `:1316-1320`, dispatched on the function before any view event): `OK` → 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.
|
||||
3. Swap continuation (`Subscriber.hs:1234-1236`, `:1316-1320`, dispatched on the function before any view event): `OK`, `CONFLICT` or `AUTH` → an `LSSync` (whose read runs the callbacks) while `ChatController.linkSyncSwaps :: TMap GroupId Int`, counting this chain's swaps, is below `channelLinkWriteAttempts`; only a trigger resets it, so a link server refusing swaps, or accepting them and serving old data, cannot keep a device looping. `CONFLICT` and `AUTH` make no view event; a landed swap the agent retried after a network error also gets `CONFLICT`. Other `ERR`: a view event, stop.
|
||||
|
||||
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.
|
||||
|
||||
@@ -127,11 +124,11 @@ Only the command row is carried between steps; every decision comes from the lat
|
||||
| `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 | 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 |
|
||||
| `CLCRelays added removed` | read relays + added − removed − relays held as left | no change → no write | an acknowledged relay (`RSAcknowledgedRoster`) missing, or a left one listed → `activateGroupRelays` (§4.13) |
|
||||
| `CLCCount n` | `publicGroupData.publicMemberCount = n` | not the first current chain entry, or read count = `n` → no write | `members_catchup = 0`, tally ≠ read count → none |
|
||||
| `CLCRoster claim` | `roster` (§4.10) | §4.10 | §4.10 rules (b), (c) → rule (a) |
|
||||
|
||||
Triggers: an acceptance (§4.6), a commit or read showing a departed member as a chain owner, an owner applying the removal or leave of a member it held as an owner (§4.8), a relay's acknowledgement or its own leave (§4.13), the count timer (§4.11), a relay connection becoming ready or resuming and relay set-up (§4.10, `LSResign`), a user write or channel open whose read shows pending work, and chat start (§4.15).
|
||||
Triggers: acceptance (§4.6); removal wins (§4.8); a relay's acknowledgement or own leave (§4.13); the count timer (§4.11); a relay connection ready or resuming, relay set-up (§4.10, `LSResign`); a user write or channel open whose read shows pending work; chat start (§4.15).
|
||||
|
||||
Chain rules: signing order is kept (`validateLinkOwners`); the creator signs new entries with the root key (`Commands.hs:2757`), others with their member key (`mkOwnerAuth`); a revoked entry is dropped once no current entry depends on it, transitively, except the writer's own entry in its leave write (a non-root writer must be in the chain, `Agent/Protocol.hs:1996-2001`). An entry is about 124 bytes of the 13,784-byte user data.
|
||||
|
||||
@@ -140,7 +137,7 @@ Chain rules: signing order is kept (`validateLinkOwners`); the creator signs new
|
||||
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.
|
||||
4. Non-revoked chain entries: a member not held as owner becomes owner with `ownerKey` (created with a placeholder profile if unknown), a departed one included, so non-owners follow the link; on an owner a departed one instead enqueues a link sync (§4.8); its own entry with its own key and a current membership: become owner (§4.6); with another key: alert item, stay a subscriber. A read only adds owners and marks revoked ones departed, so one older than a promotion cannot undo it.
|
||||
5. `roster` → `link_roster_version`; `link_roster_seen_at` = now when the version changes.
|
||||
6. Owners: join each listed relay without a relay member (`connectToRelayAsync`, `Internal.hs:1606-1616`); `RSInactive` for active `group_relays` rows missing from the list (as `Subscriber.hs:1466-1477`). When the read `roster.version` is above `stored_roster_version`, `XGrpRosterRequest` to every current relay (§4.10).
|
||||
|
||||
@@ -182,9 +179,9 @@ It sends `x.grp.owner.inv {memberId}` and stores the message's shared msg id in
|
||||
2. `CLCAddOwner M (mkOwnerAuth …)`.
|
||||
3. Callback, when a read shows M's entry with M's key while M's `owner_inv_accepted = 1` (a repeated read finds it cleared and sends nothing): clear `owner_inv_msg_id` and `owner_inv_accepted`; M's role owner (its key is the chain key), `owner_since` = now; send `x.grp.owner.add {memberId, memberKey, profile}` with M's redacted profile (`redactedMemberProfile`, `Internal.hs:1361`); item `SGEMemberRole … GROwner`; `CEvtMemberRole`.
|
||||
|
||||
**Commit.** Relays, members and owners apply `x.grp.owner.add` when the sender is a current owner and `memberId` is not held as departed: role owner, `member_pub_key = memberKey` (warning if it replaces a different key), the member created from `profile` if unknown, `owner_since` = the commit's 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`).
|
||||
**Commit.** Relays, members and owners apply `x.grp.owner.add` when the sender is a current owner and `memberId` is not held as departed: role owner, `member_pub_key = memberKey` (warning if it replaces a different key), the member created from `profile` if unknown, `owner_since` = the commit's arrival, the broker timestamp of the message that brought it (`brokerTs`, set by this device's own queue server, `Subscriber.hs:4009`; not the relay-set `fwdBrokerTs`), the role item as `xGrpMemRole` creates it. A relay sends its capabilities (`XGrpRelayCap`) to the new owner. An owner holding `memberId` as departed enqueues a link sync (§4.8). It is not `x.grp.mem.role … owner`, so older clients ignore it and no relay-asserted key is promoted (`Subscriber.hs:3465-3468`).
|
||||
|
||||
**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.
|
||||
**M becomes owner** when it applies the commit about itself or reads its own entry (§4.4), with its own key and a current membership: role owner, `owner_since` = the commit's arrival, or now when it reads its entry first, item `RGEUserRole GROwner`, `members_catchup = 1`, member catch-up (§4.7). It stores no credential: each write builds them.
|
||||
|
||||
### 4.7 New owner's member list
|
||||
|
||||
@@ -197,13 +194,13 @@ It sends `x.grp.owner.inv {memberId}` and stores the message's shared msg id in
|
||||
- **Command.** `APIRemoveMembers` with a current owner as the only target, by a current owner, in a channel: `writeChannelLink` with `CLCRevokeOwner X`; callback: mark X removed (rows kept, §4.14), send `x.grp.mem.del X False Nothing` (`deleteMemsSend`, `Commands.hs:3191-3229`). Removing oneself is a leave. After a crash between swap and callback, parties learn from link reads.
|
||||
- **Receivers** apply it as today (`checkRole`, `Subscriber.hs:3783-3786`); a relay forwards it to X first, then deletes X's connection (`:3755-3761`).
|
||||
- **The removed owner's device** applies it on receipt (own-membership branch, `Subscriber.hs:3734-3746`), as it does a read showing itself revoked (§4.4 step 3): membership removed, keys erased (§4.9 step 3), catch-up stopped.
|
||||
- **A removal racing a promotion** reaches parties in either order. 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.
|
||||
- **A removal racing a promotion** reaches parties in either order. One that applied the removal refuses the commit (§4.6); one that applied the commit applies an owner's removal of an owner as usual, and an admin's removal arriving less than `promotionRaceWindow` (1 day) after `owner_since` as an owner's (`checkRole` gains this case in both branches of `xGrpMemDel`), on every party. Both are arrivals at the party's own queue server, nearly equal everywhere; a relay can only delay its copy, and the first copy wins. Later removals, and owners learned from the chain, are refused as today. Owners revoke an owner so removed (below), and any party that decided otherwise, the removing admin's device included, follows the link at its next read (§4.4 step 4).
|
||||
- **Removal wins.** An owner that holds M departed and sees M as a non-revoked chain entry (a commit or a read), or applies the removal or leave of a member it held as an owner, enqueues a link sync, which revokes M if the read still lists it. No announcement: the removal itself reached every party.
|
||||
- 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`):
|
||||
A single-owner creator first reads the link: a read with `creatorQueueKey` (a restore from an older backup) is applied, making it multi-owner; otherwise, or if the read fails, it keeps `Commands.hs:3233-3277`. `APILeaveGroup` by an owner in multi-owner mode:
|
||||
|
||||
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.
|
||||
@@ -226,7 +223,7 @@ Single-owner channels keep `broadcastRoster`. Owners outside the roster: `proces
|
||||
|
||||
**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.
|
||||
**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 served header reuses its shared msg id (`Internal.hs:1293-1307`) and is dropped as a duplicate (`:2956-2959`), so on an owner a duplicate `XGrpRoster` header still goes to `xGrpRoster` when no transfer from that relay is pending and `s` is below its version, letting another relay complete a stalled fetch. 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:
|
||||
|
||||
@@ -236,23 +233,23 @@ Single-owner channels keep `broadcastRoster`. Owners outside the roster: `proces
|
||||
|
||||
An `RCResign` claim writes `roster {v, self}` with no delta; in a sync rule (a) then sends it, and through `writeChannelLink` its callback does the same. A sync never sends an event at a version it is re-sending.
|
||||
|
||||
**Abandonment.** A version `r` by a departed author, with `s < r`, is abandoned (claim `r + 2` from its own state: no relay serves `r`'s blob, and the skipped version makes devices that applied some of `r`'s events see a gap and fetch the new roster) when `link_roster_seen_at` is older than `rosterAbandonTTL` (1 day) and a request to every current relay at least `rosterRequestTimeout` (5 minutes) earlier brought no blob at `r`: `ChatController.rosterRequests :: TMap GroupId UTCTime` keeps the time of the first unanswered request (set when a read, 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.
|
||||
**Abandonment.** A version `r` by a departed author, with `s < r`, is abandoned (claim `r + 2` from its own state: no relay serves `r`'s blob, and the skipped version makes devices that applied some of `r`'s events see a gap and fetch the new roster) when `link_roster_seen_at` is older than `rosterAbandonTTL` (1 day) and a request to every current relay at least `rosterRequestTimeout` (5 minutes) earlier brought no blob at `r`: `ChatController.rosterRequests :: TMap GroupId UTCTime` keeps the time of the first unanswered request (set when a read or claim sends one to every current relay while behind, cleared when `s` reaches the read version). Claims and syncs check it when they need it; no timer. An owner that also missed that author's earlier versions (`s < r − 1`) drops them too, though some devices received them; otherwise roles could never change again. A current author's version is never abandoned: its device sends it when back (rules (a), (c)); a lost device is removed first.
|
||||
|
||||
**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.
|
||||
**Undelivered roster.** `setOwnGroupRoster` sets `groups.roster_pending_msg_id` to 0 ("not sent") in its transaction; `sendStoredRoster` of a roster this owner authored sets it to the last chunk's message; the relay `SENT` handler (`Subscriber.hs:1189-1194`) clears it for that message, and applying another owner's blob clears it. A message to a relay that is not ready is dropped (`memberSendAction`, `Internal.hs:2840-2846`), leaving it set. At main-app chat start, on a relay's `CON` (`:889`) and when sending resumes (`continueSending` on `SENT` or `QCONT`, `:1197-1199`, `:1291-1295`), an owner whose own stored roster is at `link_roster_version` with the column set enqueues an `LSResign` sync.
|
||||
|
||||
### 4.11 Subscriber count
|
||||
|
||||
In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:1618-1631`) recounts (`updatePublicMemberCount`) and, when `members_catchup = 0` in the main app, schedules a publish: the first trigger inserts an entry in `ChatController.countPublishPending :: TMap GroupId ()` and forks a thread that sleeps `channelCountPublishInterval` (60 s), deletes it and enqueues a link sync (which writes the count only on the first current chain entry). The fork is a delay bounding count writes, which would make other owners' swaps conflict during a burst of joins; the agent has no delayed commands. The relay-announcement threshold in relay activation becomes `publicMemberCount > number of current owners` (`Subscriber.hs:1445-1449`).
|
||||
In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:1618-1631`) recounts and, when `members_catchup = 0` in the main app, schedules a publish: the first trigger inserts an entry in `ChatController.countPublishPending :: TMap GroupId ()` and forks a thread that sleeps `channelCountPublishInterval` (60 s), deletes it and enqueues a link sync. The fork is a delay, bounding count writes that would make other owners' swaps conflict in a burst of joins; the agent has no delayed commands. The relay-announcement threshold becomes `publicMemberCount > number of current owners` (`Subscriber.hs:1445-1449`).
|
||||
|
||||
### 4.12 Profile edits and the link counter
|
||||
|
||||
- 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, 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.
|
||||
- `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 a current owner's `XGrpInfo` whatever their own gate decides. 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).
|
||||
- **Owner events** as today, plus the commit (§4.6) and the admin-removal window (§4.8); relays apply the link at invitation and in relay checks (§4.4).
|
||||
- **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).
|
||||
@@ -269,11 +266,11 @@ In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:16
|
||||
- **`APIShareChatMsgContent`** signs with any current owner's member key (`Commands.hs:1238-1240`).
|
||||
- **Connect plan and join** exclude `revokedOwners` from `glOwners`, `verifyLinkOwner` and owner binding (`Commands.hs:4615-4653`, `:4806-4815`); `APIConnectPreparedGroup` creates owners for non-revoked entries, departed rows for revoked ones (`:2324-2332`).
|
||||
- **History for joiners.** A signed as-channel item by an author no longer a current owner is served unsigned (`FwdChannel`, `itemForwardMsgs`, `Internal.hs:1540-1548`).
|
||||
- **Destroying.** In multi-owner mode `APIDeleteChat` by an owner (`Commands.hs:1384-1401`) first reads and applies the link: a departed device completes its departure and sends no `x.grp.del`; `AUTH` (no link: this device's earlier delete landed) goes on to `x.grp.del`; any other failure refuses. Every owner, the creator included, then deletes the link queue with `deleteLinkQueueWithCreds` before sending `x.grp.del`, so the link server orders a destroy against a concurrent removal of its owner; on failure it returns the error and deletes nothing. The creator then runs `deleteGroupLinkIfExists` for its local records.
|
||||
- **Destroying.** A creator first reads the link as for leaving (§4.9). In multi-owner mode `APIDeleteChat` by an owner (`Commands.hs:1384-1401`) first reads and applies the link: a departed device completes its departure and sends no `x.grp.del`; `AUTH` (no link: this device's earlier delete landed) goes on to `x.grp.del`; any other failure refuses. Every owner, the creator included, then deletes the link queue with `deleteLinkQueueWithCreds` before sending `x.grp.del`, so the link server orders a destroy against a concurrent removal of its owner; on failure it returns the error and deletes nothing. The creator then runs `deleteGroupLinkIfExists` for its local records.
|
||||
|
||||
### 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 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.
|
||||
`ChatController.mainApp :: TVar Bool`, set by `startChatController` (`Commands.hs:232-269`). Syncs, count timers and catch-up run only in the main app; the iOS notification extension resumes no agent commands (`Commands.hs:255`, `:336`), and an event it processes leaves its trigger in a row. Main-app chat start (`startChannelOwnerWork`) and `APIActivateChat` with `restoreChat` (`:599-610`, iOS's return to the foreground) enqueue an `LSResign` sync per 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
|
||||
|
||||
@@ -284,10 +281,10 @@ In multi-owner mode the owner branch of `updatePublicGroupData` (`Internal.hs:16
|
||||
| item | `RGEOwnerInvitation {invStatus :: OwnerInvitationStatus}` | `OISPending`, `OISAccepted` |
|
||||
| command function | `CFChannelLinkSync`, `CFChannelLinkResign`, `CFChannelLinkSwap` | text `channel_link_sync`, `channel_link_resign`, `channel_link_swap` |
|
||||
| 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 |
|
||||
| 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 arrival (§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`, `linkSyncRefusals :: TMap GroupId Int`, `countPublishPending :: TMap GroupId ()`, `memberCatchups :: TMap GroupId (Int, Maybe MemberId)`, `rosterRequests :: TMap GroupId UTCTime`, `mainApp :: TVar Bool` | `ChatController` |
|
||||
| state | `linkWriters :: TMap GroupId Lock`, `linkSyncSwaps :: 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.
|
||||
|
||||
@@ -318,10 +315,10 @@ A crash or a lost agent event in a sync, and a party that was offline, converge
|
||||
- **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.
|
||||
- Link server: cannot forge link data; a stale read is ignored by `linkVersion` and otherwise only adds owners, so a non-owner can take a removed owner as current until its next read (an honest removed device sends nothing); ignoring the compare-and-swap brings back lost writes and roster divergence, no forgery.
|
||||
- Owners and removed owners are trusted (non-goal). The link orders profile edits, roster role changes and removals, owner additions and removals, leaving, destroying and the relay list: honest software sends these only after its link write lands (destroy: after its read and `DEL`). Posts, moderation, blocks, subscriber removals, relay invitations and owner invitations are sent as today. Until a relay learns of a removal (the remover's announcement, or its link check every 15 minutes), a removed device running modified software can act there as an owner, including role events at inflated versions that stop later role changes at that relay and its members; afterwards the relay accepts from it only its departure, roster transfers and role events up to the link's roster version, new ones at that version included, which members accept above their gate. With a colluding link server it can serve forged link data to readers.
|
||||
- Any current owner can destroy the channel or replace the key set with `RKEY`. An admin can remove an owner 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 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.
|
||||
- Any current owner can destroy the channel or replace the key set with `RKEY`. An admin can remove an owner whose promotion reached the relays less than `promotionRaceWindow` earlier (§4.8).
|
||||
- **Privacy.** A promoted owner connects directly to the link server (recipient commands are not proxied), with the consent and refusals of §4.6; link commands use a session of their own, link reads stay proxied. The link server learns when owners act and how many there are. `linkRcvId` and `creatorQueueKey` authorise nothing; the member-page cursor is a memberId. A pending async swap keeps the owner's keys in the agent's command table (the encrypted database of its queue keys) until it completes or its mailbox goes.
|
||||
|
||||
## 6. Slices
|
||||
|
||||
@@ -374,7 +371,7 @@ Not compiled in the container; built on the host.
|
||||
|
||||
- `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, 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.
|
||||
- Restart: two key-changing swaps replay 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 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`):
|
||||
@@ -385,7 +382,7 @@ Not compiled in the container; built on the host.
|
||||
| T2 | refusals: unverified; a relay below v23; a link server at SMP v23; M ignores a chain entry with another key |
|
||||
| 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 |
|
||||
| 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, which it completes from the second relay when the first stops after the header |
|
||||
| T6 | mutual removal: one owner remains |
|
||||
| T7 | the last two owners leave: one refused |
|
||||
| T8 | removal wins: O2 removes subscriber M while the hook holds O1's promotion of M; M ends departed everywhere, revoked in the link |
|
||||
@@ -398,7 +395,7 @@ Not compiled in the container; built on the host.
|
||||
| T15 | a relay reads the creator's departure before processing its queued blob and still accepts it; a role event from that connection above the link's roster version is refused |
|
||||
| T16 | a leave stopped after its swap (hook) completes at the next chat start; a departed author's undelivered version is abandoned after the (shortened) TTL and an unanswered request |
|
||||
| T17 | an acceptance processed while the link server is down promotes once it is back; a restart between swap and read sends the commit once; a relay acknowledgement publishes the relay and a join changes the count; removal wins completes after a restart; a sync whose mailbox was removed is redone at chat start; a server answering every swap `CONFLICT` stops the sync at the bound |
|
||||
| T18 | an admin removes M while the hook holds O1's promotion of M: M ends departed everywhere, relays and M's device applying both in either order, a relay offline across both included; an admin's removal later than the (shortened) window is refused |
|
||||
| T18 | an admin removes M while the hook holds O1's promotion of M: M ends departed everywhere, relays and M's device applying both in either order, a relay offline across both included; an admin's removal later than the (shortened) window is refused, and the admin's device takes M as owner at its next read |
|
||||
| T19 | a device that left after its swap destroys without `x.grp.del`; the creator's destroy is refused while its read fails; a destroy racing a removal of its owner: one fails |
|
||||
| T20 | a channel-open read held (hook) across a promotion is applied after the commit: M stays an owner everywhere |
|
||||
| T21 | O2 adds a relay right after O1's role change, and O1 one right after its own: each relay gets the current blob, acknowledges and is published |
|
||||
@@ -406,7 +403,7 @@ Not compiled in the container; built on the host.
|
||||
|
||||
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).
|
||||
|
||||
**Manual app checks** (one layout; nothing here depends on layout): invite and accept on two devices with the direct-connection prompt, Owner option and verification routing, Remove owner, last-owner leave refusal, a promoted owner's link screen, composer, relays and web settings, a profile edit refused after another owner's edit.
|
||||
**Manual app checks** (one layout; none depends on it): invite and accept with the direct-connection prompt, Owner option and verification, Remove owner, last-owner leave refusal, a promoted owner's link screen, composer, relays and web settings, a refused profile edit.
|
||||
|
||||
## 8. Risks
|
||||
|
||||
@@ -421,7 +418,7 @@ 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 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.
|
||||
7. Blocking and unblocking a member stay unordered between owners, admins and moderators, as in groups today. Recommended: as planned (parity; admins and moderators block too and cannot write the link); otherwise owners' restrictions carry the roster version and are claimed through the link.
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
@@ -429,4 +426,4 @@ Pre-existing issues: relay forwarding order (`Store/Delivery.hs:178-224`); messa
|
||||
|
||||
## 11. Follow-up runs
|
||||
|
||||
None: the whole feature is planned here.
|
||||
None.
|
||||
@@ -39,8 +39,8 @@ The channel's link data lives on one SMP server, the link server, which already
|
||||
| Optional parts | left out: invitation cancel, field-by-field profile merge, equal-version tie-break, retiring the root key |
|
||||
| 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 |
|
||||
| A removal at the moment of a promotion | the removal wins everywhere (recommended; open question 6) |
|
||||
| Blocking and unblocking | not ordered, as in groups today (recommended; open question 7) |
|
||||
|
||||
## Trade-offs users notice
|
||||
|
||||
@@ -65,10 +65,6 @@ None of these is a race between honest parties:
|
||||
- **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
|
||||
## Not verified, size
|
||||
|
||||
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,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.
|
||||
App code and Postgres tests run only on the host. The 50-owner headroom is computed from the encoders (the first slices test it); catch-up for 100,000 members (about 2,000 messages) is an estimate. About 2,290 lines of production Haskell (~490 simplexmq, ~1,800 chat) and 380 per app platform; five new chat events and one changed; five optional link-data fields; nine columns, no new table; one SMP command and one error.
|
||||
Reference in new issue
Block a user