diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/1-components.svg b/plans/2026-10-15-channel-multi-owner-s3min-sketches/1-components.svg new file mode 100644 index 0000000000..e46f153815 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/1-components.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + 1 · Components and what each holds + The link server orders every change the owners share; relays and members keep their roles of today. + + + Link server · the channel's one SMP queue + + Recipient key set + the creator's queue key, while + it is an owner, + the member + key of every non-revoked owner + may send LSWP and DEL; + LSWP replaces the set atomically + Link data, signed and encrypted + owners chain, relays, profile, subscriber count + revokedOwners: departed owners, never shrinks + linkVersion · roster {version, author} + creatorQueueKey · linkRcvId + once multi-owner, written only by LSWP + + + Owner devices + Creator + root key: signs the link data + queue key: authorises LSWP, DEL + member key: signs events + Any other owner + member key: signs the link data, + authorises LSWP, signs events + each keeps a copy of the roster + + + Relays + members and their roles + serve the roster to members, + and to owners on request + forward owner-signed events + profile gate: linkVersion + member pages to a new owner + cannot forge an owner + + + Members + owners and their keys, + from chain and commits + roster gate, as today + profile gate: linkVersion + hear owners via relays + + + LSWP + direct, a session + per link + + read + hash + (proxied) + + + read at invitation + and every 15 min + + + read at channel open + and join (proxied) + + signed + events, roster + + forwarded + events, roster, + member pages + + + forwarded + events, roster + + + Solid arrow: write or message · dashed arrow: read · red: new in this plan. Every owner writes with credentials built for each write. + + diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/2-owner-write.svg b/plans/2026-10-15-channel-multi-owner-s3min-sketches/2-owner-write.svg new file mode 100644 index 0000000000..e5e07f173c --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/2-owner-write.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + 2 · An owner write: read, change, swap; conflict and retry + The swap lands only if the link data is still what the owner read; otherwise the owner reads again and re-applies its one change. + + + + + + Owner O1 + + Link server + + Owner O2 + + + stores D1 · hash h1 + + + read + + read + + D1, h1 + + D1, h1 + + + applies its change A to D1 + + applies its change B to D1 + + + LSWP h1, D1 + B + + h1 matches: stores D2 · h2 + + OK + + stores its state, then + announces B to relays + + + LSWP h1, D1 + A + + h2 ≠ h1: nothing stored + + CONFLICT + + + read again + + D2, h2 + + re-applies A to D2, or refuses + if B changed what A edits + + + LSWP h2, D2 + A + + h2 matches: stores D3 · h3 + + OK + + stores its state, then + announces A to relays + + + Rules of every multi-owner write [§4.4] + CONFLICT: read and re-apply; after 5 attempts CLELinkBusy + already applied (say, a lost response): no write + AUTH: this owner was removed, so CLENotOwner + every swap writes linkVersion = max(read, stored) + 1 + a swap that changes owners also replaces the key set + background work swaps the same way, as agent commands + + diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/3-roster-claims.svg b/plans/2026-10-15-channel-multi-owner-s3min-sketches/3-roster-claims.svg new file mode 100644 index 0000000000..c3c7069a07 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/3-roster-claims.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + 3 · Role changes claim roster versions through the link + An owner claims version v + 1 only while it holds the complete roster at v, the version the link names. Relays and members keep today's roster protocol. + + + + + + + + Owner O1 + + Link server + + Owner O2 + + Relays + + Members + + + holds 7 + + roster {7, O1} + + holds 7 + + gate 7 + + + read + + roster {7, O1} + = held 7 + + LSWP roster {8, O2} + + roster {8, O2} + + OK + + roster 8 + role @8 + + role @8 + + 8 ≥ gate: applied + + role @8 + + 8 > held 7: asks every + relay for the roster + + roster request (holds 7) + + + read + user's change + + roster {8, O2} + + still behind: refused, + CLERosterSyncing + + + roster 8 + + holds 8 + + + read + user retries + + roster {8, O2} + + LSWP roster {9, O1} + + roster {9, O1} + + OK + + roster 9 + role @9 + + role @9 + + + Why it converges [§4.10] + · Each version has one author who held every earlier one: what relays and members already assume (gate; a gap → fetch from a relay). + · The roster lists members, moderators and admins; owners come from the link chain, outside it. + · A departed author's roster messages still pass the gate; its version that no relay serves is abandoned after a day (next claim r + 2). + · An owner whose roster didn't reach a relay re-signs it at the next version in a background sync. + + diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/4-promotion.svg b/plans/2026-10-15-channel-multi-owner-s3min-sketches/4-promotion.svg new file mode 100644 index 0000000000..151a503f39 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/4-promotion.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + 4 · Adding an owner, end to end + Consent in the member's support chat, one link write in a background sync, then the commit to everyone [§4.6, §4.7]. + + + + + + + + Link server + + Owner O1 + + Relays + + Member M + + Everyone else + + + 1 · Invite: the promoter checks, then invites + + checks: M's code verified, relays ≥ v23, + link server ≥ v24, chain under 50 entries + + x.grp.owner.inv {memberId} + + to M's support chat + + “Become an owner?” + its profile goes to every subscriber; + a direct connection reveals its IP + + + 2 · Accept: the member consents + + x.grp.owner.acpt {invId} + + from M's support chat + + owner_inv_accepted = 1, + enqueues a link sync + + + 3 · Link sync: agent async commands queued on O1's connection to a relay (its mailbox); each step re-derived from the latest read + + async read + + LDATA + hash + + continuation finds M accepted: + CLCAddOwner M + entry signed by O1, M's key added + + async LSWP + + chain + M's entry, + key set + M's key + + OK + + async read + + shows M's entry + + callback: clears the invitation, + takes M as owner + + + 4 · Commit: everyone takes M as owner + + x.grp.owner.add + {memberId, memberKey, + profile} + + forwarded to everyone, M included + + apply it: M owner; + send M capabilities + + M: owner; starts the + member catch-up + + from a current owner, + M not departed: owner + + + 5 · Catch-up: the new owner pulls the member list from one relay + + x.grp.members.req {cursor} + + x.grp.members.page + + next page until the last; no page + for a minute: ask another relay + + + Transferring ownership is this promotion, then the creator leaves (diagram 5): the channel needs no creator. + The creator's first swap also writes creatorQueueKey, linkRcvId and the roster commitment; from then on the queue has several keys. + + diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/5-removal-and-leave.svg b/plans/2026-10-15-channel-multi-owner-s3min-sketches/5-removal-and-leave.svg new file mode 100644 index 0000000000..9da62a22a2 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/5-removal-and-leave.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + 5 · Removing an owner, and leaving + Both land on the link first: the swap drops the owner's key at once, and the announcement follows. + + + A · An owner removes owner X [§4.8] + + + + + + + Link server + + Owner O1 + + Relays + + Owner X + + Everyone else + + + read + + data, hash + + CLCRevokeOwner X; refused if + no other owner would remain + + LSWP + + X in revokedOwners, + X's key out of the set + + OK + + x.grp.mem.del X + + first to X + + then to everyone + + delete their + connection to X + + applies it on receipt, + erases its keys + + X departed, + as today + + any later LSWP or DEL by X: AUTH (its key is gone) + Two owners removing each other: the first swap drops the other's key; the second gets AUTH, or CONFLICT and then reads itself revoked. + One owner remains. A removed owner's rows are kept. + + + B · An owner leaves, the creator included [§4.9] + + + + + + Link server + + Owner L + + Relays + + Everyone else + + + read + + data, hash + + no other current owner: CLELastOwner + + its own last roster claim not stored + or not delivered: re-sign it first + + read + LSWP roster {v+1, L} + + OK + + roster v+1 + a role event + + waits ≤ 30 s for its agent's SENT of the + last chunk; else CLERelaysUnreachable + + read + LSWP: revoke L + + L revoked, key out; + entry kept as a signer + + OK + + x.grp.leave + + forwarded + + erases its member key; the creator + keeps only the root public key and + deletes the link locally (DEL: AUTH) + + L departed + + + A leave stopped after its swap completes at the next link read. A single-owner creator reads the link first; a failed read refuses its leave. + A relay that learns of a departure from a read keeps that owner's connection for its queued roster, but accepts only its leave and + roster messages up to the link's roster version [§4.13]. Destroying: any owner reads, deletes the link queue, then sends x.grp.del [§4.14]. + + diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/6-promotion-removal-race.svg b/plans/2026-10-15-channel-multi-owner-s3min-sketches/6-promotion-removal-race.svg new file mode 100644 index 0000000000..dbe3257351 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/6-promotion-removal-race.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + 6 · The accepted race: a promotion and a removal of the same member + The link decides who is listed as an owner. Which message a party applies first decides its state until its next read, + except that a member device that left stays gone, and relays that applied the removal first have no connection to M [§4.8]. + + + Owner O1 promotes M + its swap lands first + + A remover, admin or owner + hasn't seen the promotion + + Link server + M in the chain, not revoked + + Every party + relays, members, owners, M's device: + applies whichever arrives first + + + LSWP + + commit: x.grp.owner.add M + + removal: x.grp.mem.del M + + + Applied the removal first + + Applied the commit first + + + Relays, members, + owners, the + remover's device + (always on the left) + + refuses the commit: it holds M as departed + (a relay also deleted its connection to M) + at its next link read: M is an owner + (the link lists M, not revoked) + + M is an owner + an admin's later removal is refused + (checkRole, as today); an owner's is + applied, and the next read takes M back + + + M's own device + + has left the channel, and relays that applied + the removal first have no connection to M + M stays listed as an owner without a + device until another owner removes it + + admin remover: M stays an owner (T18) + owner remover: M's device applies the + removal here too (an owner may remove + an owner), so it leaves, as on the left + + + Accepted on 2026-10-19 as a rare edge case, with nothing done for it [Ov: Decisions, §4.17]. A member that leaves while being promoted + ends the same way. A promoter that applied the removal before its swap refuses to add M: CLCAddOwner refuses a removed or + departed member [§4.4]. The commit-first owner-remover cells follow today's checkRole, which the plan keeps; no plan + text or test states them (T8 covers the removal-first order). + + diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/7-data-model.svg b/plans/2026-10-15-channel-multi-owner-s3min-sketches/7-data-model.svg new file mode 100644 index 0000000000..480e9b9924 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/7-data-model.svg @@ -0,0 +1,85 @@ + + + + + 7 · Data model, before and after + Red marks what the plan adds [§4.1, §4.2, §4.3, §4.16]. Single-owner channels write the same link data as today. + + Where + Today + With several owners + + + + + Link queue: + recipient keys + one: the creator's queue key + the creator's queue key, while it is an owner, + + the member key of every non-revoked chain owner; + LSWP replaces the set together with the data + + + Link data + on the link server + owners [creator], signed by the root key; + relays, profile, subscriber count + written with LSET: last writer wins + owners chain [creator, M, …], each entry signed by + the root key or an earlier entry; relays, profile, count + + linkVersion, revokedOwners, roster {version, author}, + + creatorQueueKey, linkRcvId (all optional JSON) + written only by LSWP once multi-owner; LSET gets AUTH + on a queue with several keys + + + Server store log + LINK, KEYS, … records + + LKEYS: link data with its keys, then KEYS; + a restart never restores data without its keys + + + + groups + chat database + roster_version, stored_roster_version, + applied_complete_roster_version, + roster_blob, … + + link_data_version, link_roster_version, + + link_roster_seen_at, members_catchup, + + roster_pending_msg_id + + + group_members + chat database + member_role, member_status, + member_pub_key, roster_served_version, … + + owner_inv_msg_id, owner_inv_accepted, + + relay_web_domain + + + + Agent + async commands NEW, LSET, LGET, JOIN, … + LDATA: the link data, no hash + + LSWP command, stored as a blob: no migration + LDATA carries the hash a swap needs + + + + Chat commands + continuations + CFSetShortLink, CFGetRelayData…, … + + CFChannelLinkSync, CFChannelLinkResign, + + CFChannelLinkSwap + + + Chat memory + ChatController + none of these + + linkWriters, linkSyncSwaps, countPublishPending, + + memberCatchups, rosterRequests, mainApp + + One chat migration, M20261015_channel_multi_owner (SQLite and Postgres, with its down migration); no agent or server schema change. + + diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.html b/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.html new file mode 100644 index 0000000000..14bfa73a89 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.html @@ -0,0 +1,958 @@ + +Channel multi-owner (S3-min) + + + +
+
+ simplex-chat · plan summary · f/channel-multi-owner +

Channel multi-owner (S3-min)

+

A short technical summary of the channel multi-owner plan for programmers who won't read all of it.

+

Sources: the overview, 2026-10-15-channel-multi-owner-s3min.md, and the implementation plan, 2026-10-15-channel-multi-owner-s3min-implementation.md, in plans/. [§4.4] cites the implementation plan, [Ov: Decisions] the overview. Nothing is implemented yet.

+ +
+ +

Approach and why

+

Today a channel has one owner, its creator. The link data (profile, owner chain, relays, subscriber count) lives in one SMP queue on the link server, and the creator overwrites it with LSET: last writer wins. The creator alone numbers the roster, the signed list of member, moderator and admin roles that relays and members apply in version order [§3].

+

The plan gives a channel several equal owners, each able to do what the creator does today: a member becomes an owner after it accepts, owners can remove each other, and the channel survives any owner leaving, the creator included. Owners are trusted as the single owner is today; the design defends against malicious relays and a link server that withholds or serves stale data [Ov: intro, §1, §2].

+

The link server already orders every write to its queue, so the plan makes it order everything the owners share [Ov: How it works, §4]:

+ + +
+
+ + + + + + + + + + + + + 1 · Components and what each holds + The link server orders every change the owners share; relays and members keep their roles of today. + + + Link server · the channel's one SMP queue + + Recipient key set + the creator's queue key, while + it is an owner, + the member + key of every non-revoked owner + may send LSWP and DEL; + LSWP replaces the set atomically + Link data, signed and encrypted + owners chain, relays, profile, subscriber count + revokedOwners: departed owners, never shrinks + linkVersion · roster {version, author} + creatorQueueKey · linkRcvId + once multi-owner, written only by LSWP + + + Owner devices + Creator + root key: signs the link data + queue key: authorises LSWP, DEL + member key: signs events + Any other owner + member key: signs the link data, + authorises LSWP, signs events + each keeps a copy of the roster + + + Relays + members and their roles + serve the roster to members, + and to owners on request + forward owner-signed events + profile gate: linkVersion + member pages to a new owner + cannot forge an owner + + + Members + owners and their keys, + from chain and commits + roster gate, as today + profile gate: linkVersion + hear owners via relays + + + LSWP + direct, a session + per link + + read + hash + (proxied) + + + read at invitation + and every 15 min + + + read at channel open + and join (proxied) + + signed + events, roster + + forwarded + events, roster, + member pages + + + forwarded + events, roster + + + Solid arrow: write or message · dashed arrow: read · red: new in this plan. Every owner writes with credentials built for each write. + + +
+
Diagram 1. Who holds what, and who talks to whom.
+
+
+
+ + + + + + + + + + + + + + + + 2 · An owner write: read, change, swap; conflict and retry + The swap lands only if the link data is still what the owner read; otherwise the owner reads again and re-applies its one change. + + + + + + Owner O1 + + Link server + + Owner O2 + + + stores D1 · hash h1 + + + read + + read + + D1, h1 + + D1, h1 + + + applies its change A to D1 + + applies its change B to D1 + + + LSWP h1, D1 + B + + h1 matches: stores D2 · h2 + + OK + + stores its state, then + announces B to relays + + + LSWP h1, D1 + A + + h2 ≠ h1: nothing stored + + CONFLICT + + + read again + + D2, h2 + + re-applies A to D2, or refuses + if B changed what A edits + + + LSWP h2, D2 + A + + h2 matches: stores D3 · h3 + + OK + + stores its state, then + announces A to relays + + + Rules of every multi-owner write [§4.4] + CONFLICT: read and re-apply; after 5 attempts CLELinkBusy + already applied (say, a lost response): no write + AUTH: this owner was removed, so CLENotOwner + every swap writes linkVersion = max(read, stored) + 1 + a swap that changes owners also replaces the key set + background work swaps the same way, as agent commands + + +
+
Diagram 2. Two owners write at once; the stale swap gets CONFLICT, reads again and re-applies.
+
+
+
+ + + + + + + + + + + + + 3 · Role changes claim roster versions through the link + An owner claims version v + 1 only while it holds the complete roster at v, the version the link names. Relays and members keep today's roster protocol. + + + + + + + + Owner O1 + + Link server + + Owner O2 + + Relays + + Members + + + holds 7 + + roster {7, O1} + + holds 7 + + gate 7 + + + read + + roster {7, O1} + = held 7 + + LSWP roster {8, O2} + + roster {8, O2} + + OK + + roster 8 + role @8 + + role @8 + + 8 ≥ gate: applied + + role @8 + + 8 > held 7: asks every + relay for the roster + + roster request (holds 7) + + + read + user's change + + roster {8, O2} + + still behind: refused, + CLERosterSyncing + + + roster 8 + + holds 8 + + + read + user retries + + roster {8, O2} + + LSWP roster {9, O1} + + roster {9, O1} + + OK + + roster 9 + role @9 + + role @9 + + + Why it converges [§4.10] + · Each version has one author who held every earlier one: what relays and members already assume (gate; a gap → fetch from a relay). + · The roster lists members, moderators and admins; owners come from the link chain, outside it. + · A departed author's roster messages still pass the gate; its version that no relay serves is abandoned after a day (next claim r + 2). + · An owner whose roster didn't reach a relay re-signs it at the next version in a background sync. + + +
+
Diagram 3. An owner claims roster version v + 1 only while it holds the complete roster at v.
+
+ +

Protocol changes

+
+ + + + + + + + +
LayerChangePlan
SMP v24LSWP hash [keys] userData, a recipient command, not proxied: stores the new user data, and the whole key set if given, only if the SHA-256 of the stored encrypted user data equals hash. No fixed data is sent, so 52 Ed25519 keys fit (about 50 owners, computed); an LSET-shaped command would fit 6.§4.1
SMP v24Error CONFLICT, from LSWP only. LSET gets AUTH on a queue with several keys, so stale or downgraded single-owner writes can't overwrite multi-owner data. Store-log record LKEYS (data with its keys).§4.1
Link dataGroupShortLinkData gains optional linkVersion (write counter), revokedOwners (never shrinks), creatorQueueKey (kept in the key set while the creator is an owner), linkRcvId (the queue address other owners write to) and roster {version, author}. Writes keep unknown fields. New chain entries are signed by the root key (creator) or the signer's member key.§4.3, §4.4
Chat v23x.grp.owner.inv {memberId} and x.grp.owner.acpt {invId} through the invitee's support chat; the commit x.grp.owner.add {memberId, memberKey, profile} to everyone; unsigned x.grp.members.req {cursor} and x.grp.members.page between a new owner and one relay; optional linkVersion in x.grp.info.§4.5
+
+

Plan §9 leaves two of these open, both recommended as planned: sending only the user data (q.4) and refusing LSET on multi-key queues, a server behaviour change (q.5).

+

Compatibility [§4.3, §5]. Promotion needs every relay on chat v23 and the link server on SMP v24. Older members ignore the commit and reject a new owner's messages until they upgrade; older joiners take revoked chain entries as owners, whose keys authorise nothing on the queue. A channel switches to multi-owner writes at its first accepted invitation; after the creator's first swap its queued single-owner writes get AUTH. Other channels keep today's code paths.

+ +

Types and functions by module

+
+ + + + + + + + + + + +
RepoModuleAdded or changedPlan
simplexmqProtocol, TransportLinkDataHash, CONFLICT, LSWP; SMP 23 → 24 (proxied stays 23)§4.1
simplexmqServer, QueueStore/*, StoreLog, ClientLSWP handler; swapQueueLinkData (STM, Postgres, journal); multi-key LSET refusal; CreateLinkKeys; swapSMPQueueLink§4.1
simplexmqagentLinkOwnerCreds; read with hash, setShortLinkSwap (sync, async), deleteLinkQueueWithCreds, creator queue credentials, v24 and private-routing checks; hash in LDATA; a session per link§4.2
chatProtocollink data fields, LinkRoster, CreatorQueueKey; five events; chat 22 → 23§4.3, §4.5
chatControllerAPIInviteGroupOwner, APIAcceptGroupOwnerInvitation, CROwnerInvitationSent; CEChannelLink (11 cases); six config limits; a test hook; in-memory state; Maybe GroupLink in CEvtGroupLinkDataUpdated and CRGroupRelaysAdded§4.13, §4.16
chatTypes, Messages/CIContent/EventsCFChannelLinkSync, CFChannelLinkResign, CFChannelLinkSwap; RGEOwnerInvitation§4.4, §4.6
chatLibrary/*writeChannelLink (user writes), enqueueLinkSync (background), ChannelLinkChange (profile, add or revoke owner, relays, count, roster); applyChannelLinkData (every read); claimRosterVersion; activateGroupRelays§4.4, §4.10, §4.13
+
+ +

Database

+
+ + + + + + +
TableNew columnsMeaning
groupslink_data_version, link_roster_version, link_roster_seen_at, members_catchup, roster_pending_msg_idhighest linkVersion seen; last roster commitment and when it appeared; catch-up pending; own roster not yet sent
group_membersowner_inv_msg_id, owner_inv_accepted, relay_web_domainpending invitation and its acceptance; a relay's web domain without a relay row
+
+

One chat migration, M20261015_channel_multi_owner (SQLite and Postgres, with its down migration), columns only; no agent or server schema change [§4.16, §5].

+
+
+ + + + + 7 · Data model, before and after + Red marks what the plan adds [§4.1, §4.2, §4.3, §4.16]. Single-owner channels write the same link data as today. + + Where + Today + With several owners + + + + + Link queue: + recipient keys + one: the creator's queue key + the creator's queue key, while it is an owner, + + the member key of every non-revoked chain owner; + LSWP replaces the set together with the data + + + Link data + on the link server + owners [creator], signed by the root key; + relays, profile, subscriber count + written with LSET: last writer wins + owners chain [creator, M, …], each entry signed by + the root key or an earlier entry; relays, profile, count + + linkVersion, revokedOwners, roster {version, author}, + + creatorQueueKey, linkRcvId (all optional JSON) + written only by LSWP once multi-owner; LSET gets AUTH + on a queue with several keys + + + Server store log + LINK, KEYS, … records + + LKEYS: link data with its keys, then KEYS; + a restart never restores data without its keys + + + + groups + chat database + roster_version, stored_roster_version, + applied_complete_roster_version, + roster_blob, … + + link_data_version, link_roster_version, + + link_roster_seen_at, members_catchup, + + roster_pending_msg_id + + + group_members + chat database + member_role, member_status, + member_pub_key, roster_served_version, … + + owner_inv_msg_id, owner_inv_accepted, + + relay_web_domain + + + + Agent + async commands NEW, LSET, LGET, JOIN, … + LDATA: the link data, no hash + + LSWP command, stored as a blob: no migration + LDATA carries the hash a swap needs + + + + Chat commands + continuations + CFSetShortLink, CFGetRelayData…, … + + CFChannelLinkSync, CFChannelLinkResign, + + CFChannelLinkSwap + + + Chat memory + ChatController + none of these + + linkWriters, linkSyncSwaps, countPublishPending, + + memberCatchups, rosterRequests, mainApp + + One chat migration, M20261015_channel_multi_owner (SQLite and Postgres, with its down migration); no agent or server schema change. + + +
+
Diagram 7. What the plan adds, by store.
+
+ +

API, commands and apps

+ +
+
+ + + + + + + + + + + + + 4 · Adding an owner, end to end + Consent in the member's support chat, one link write in a background sync, then the commit to everyone [§4.6, §4.7]. + + + + + + + + Link server + + Owner O1 + + Relays + + Member M + + Everyone else + + + 1 · Invite: the promoter checks, then invites + + checks: M's code verified, relays ≥ v23, + link server ≥ v24, chain under 50 entries + + x.grp.owner.inv {memberId} + + to M's support chat + + “Become an owner?” + its profile goes to every subscriber; + a direct connection reveals its IP + + + 2 · Accept: the member consents + + x.grp.owner.acpt {invId} + + from M's support chat + + owner_inv_accepted = 1, + enqueues a link sync + + + 3 · Link sync: agent async commands queued on O1's connection to a relay (its mailbox); each step re-derived from the latest read + + async read + + LDATA + hash + + continuation finds M accepted: + CLCAddOwner M + entry signed by O1, M's key added + + async LSWP + + chain + M's entry, + key set + M's key + + OK + + async read + + shows M's entry + + callback: clears the invitation, + takes M as owner + + + 4 · Commit: everyone takes M as owner + + x.grp.owner.add + {memberId, memberKey, + profile} + + forwarded to everyone, M included + + apply it: M owner; + send M capabilities + + M: owner; starts the + member catch-up + + from a current owner, + M not departed: owner + + + 5 · Catch-up: the new owner pulls the member list from one relay + + x.grp.members.req {cursor} + + x.grp.members.page + + next page until the last; no page + for a minute: ask another relay + + + Transferring ownership is this promotion, then the creator leaves (diagram 5): the channel needs no creator. + The creator's first swap also writes creatorQueueKey, linkRcvId and the roster commitment; from then on the queue has several keys. + + +
+
Diagram 4. Promotion end to end. Transferring ownership is this, then the creator leaves (diagram 5).
+
+
+
+ + + + + + + + + + + + + + + + 5 · Removing an owner, and leaving + Both land on the link first: the swap drops the owner's key at once, and the announcement follows. + + + A · An owner removes owner X [§4.8] + + + + + + + Link server + + Owner O1 + + Relays + + Owner X + + Everyone else + + + read + + data, hash + + CLCRevokeOwner X; refused if + no other owner would remain + + LSWP + + X in revokedOwners, + X's key out of the set + + OK + + x.grp.mem.del X + + first to X + + then to everyone + + delete their + connection to X + + applies it on receipt, + erases its keys + + X departed, + as today + + any later LSWP or DEL by X: AUTH (its key is gone) + Two owners removing each other: the first swap drops the other's key; the second gets AUTH, or CONFLICT and then reads itself revoked. + One owner remains. A removed owner's rows are kept. + + + B · An owner leaves, the creator included [§4.9] + + + + + + Link server + + Owner L + + Relays + + Everyone else + + + read + + data, hash + + no other current owner: CLELastOwner + + its own last roster claim not stored + or not delivered: re-sign it first + + read + LSWP roster {v+1, L} + + OK + + roster v+1 + a role event + + waits ≤ 30 s for its agent's SENT of the + last chunk; else CLERelaysUnreachable + + read + LSWP: revoke L + + L revoked, key out; + entry kept as a signer + + OK + + x.grp.leave + + forwarded + + erases its member key; the creator + keeps only the root public key and + deletes the link locally (DEL: AUTH) + + L departed + + + A leave stopped after its swap completes at the next link read. A single-owner creator reads the link first; a failed read refuses its leave. + A relay that learns of a departure from a read keeps that owner's connection for its queued roster, but accepts only its leave and + roster messages up to the link's roster version [§4.13]. Destroying: any owner reads, deletes the link queue, then sends x.grp.del [§4.14]. + + +
+
Diagram 5. A removal and a leave land on the link first; the swap drops the owner's key, then the announcement follows.
+
+ +

How owners, relays and members stay consistent

+ + +

Accepted trade-offs and residual risks

+

Trade-offs [Ov: Trade-offs users notice, Ov: Decisions]:

+ +
+
+ + + + + + + + + + + + + + + + 6 · The accepted race: a promotion and a removal of the same member + The link decides who is listed as an owner. Which message a party applies first decides its state until its next read, + except that a member device that left stays gone, and relays that applied the removal first have no connection to M [§4.8]. + + + Owner O1 promotes M + its swap lands first + + A remover, admin or owner + hasn't seen the promotion + + Link server + M in the chain, not revoked + + Every party + relays, members, owners, M's device: + applies whichever arrives first + + + LSWP + + commit: x.grp.owner.add M + + removal: x.grp.mem.del M + + + Applied the removal first + + Applied the commit first + + + Relays, members, + owners, the + remover's device + (always on the left) + + refuses the commit: it holds M as departed + (a relay also deleted its connection to M) + at its next link read: M is an owner + (the link lists M, not revoked) + + M is an owner + an admin's later removal is refused + (checkRole, as today); an owner's is + applied, and the next read takes M back + + + M's own device + + has left the channel, and relays that applied + the removal first have no connection to M + M stays listed as an owner without a + device until another owner removes it + + admin remover: M stays an owner (T18) + owner remover: M's device applies the + removal here too (an owner may remove + an owner), so it leaves, as on the left + + + Accepted on 2026-10-19 as a rare edge case, with nothing done for it [Ov: Decisions, §4.17]. A member that leaves while being promoted + ends the same way. A promoter that applied the removal before its swap refuses to add M: CLCAddOwner refuses a removed or + departed member [§4.4]. The commit-first owner-remover cells follow today's checkRole, which the plan keeps; no plan + text or test states them (T8 covers the removal-first order). + + +
+
Diagram 6. The link decides who is listed as an owner; arrival order decides each party's state until its next read, except that a member device that left stays gone and relays that applied the removal keep no connection to the member.
+
+

Residual risks [Ov: Residual risks, §5]:

+ + +

Slices

+

~2,260 production Haskell lines (simplexmq ~490, chat ~1,770) plus tests, and ~380 per app platform; each slice compiles and passes its tests on its own [§6].

+
+ + + + + + + + + + + + + + + + + + +
#SliceLines
S1simplexmq store: hash, CONFLICT, swap per store, LSET refusal, LKEYS~190
S2simplexmq protocol: v24, LSWP, handler, client~100
S3simplexmq agent~200
S4chat on the new simplexmq, structural~90
S5chat wire and data, inert~300
S6link writes and reads, multi-owner mode~200
S7link syncs~170
S8callers: profile, relays, count~190
S9promotion~220
S10member catch-up~120
S11roster through the link~250
S12removal, leave, destroy~170
S13guards~60
S14–S17iOS, then Kotlin mirroring it~380 per platform
+
+

Risks [§8, Ov: Not verified, size]: the riskiest assumption is that the unchanged roster protocol converges with link-claimed versions (S11, S12 test it); the second, that every background callback re-derived from the latest read is idempotent (T17). S1 and S2 first prove the swap's atomicity and the computed 50-owner size; the 100,000-member catch-up is an estimate. App code and Postgres tests run only on the host.

+
diff --git a/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.md b/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.md new file mode 100644 index 0000000000..53c53d7166 --- /dev/null +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.md @@ -0,0 +1,131 @@ +# Channel multi-owner (S3-min): summary + +A short technical summary of the channel multi-owner plan for programmers who won't read all of it. Sources: the overview, [2026-10-15-channel-multi-owner-s3min.md](../2026-10-15-channel-multi-owner-s3min.md), and the implementation plan, [2026-10-15-channel-multi-owner-s3min-implementation.md](../2026-10-15-channel-multi-owner-s3min-implementation.md). [§4.4] cites the implementation plan, [Ov: Decisions] the overview. Nothing is implemented yet. + +## Approach and why + +Today a channel has one owner, its creator. The link data (profile, owner chain, relays, subscriber count) lives in one SMP queue on the link server, and the creator overwrites it with `LSET`: last writer wins. The creator alone numbers the roster, the signed list of member, moderator and admin roles that relays and members apply in version order [§3]. + +The plan gives a channel several equal owners, each able to do what the creator does today: a member becomes an owner after it accepts, owners can remove each other, and the channel survives any owner leaving, the creator included. Owners are trusted as the single owner is today; the design defends against malicious relays and a link server that withholds or serves stale data [Ov: intro, §1, §2]. + +The link server already orders every write to its queue, so the plan makes it order everything the owners share [Ov: How it works, §4]: + +- **Compare-and-swap.** An owner reads the link data, applies its one change and writes it back with `LSWP` and the hash of what it read. If the data changed meanwhile, the server answers `CONFLICT`, and the owner reads and re-applies. The same step can replace the queue's recipient keys, so keys and data change together [§4.1, §4.4]. +- **Role changes are numbered on the link.** The link names the roster version and its author, and an owner claims the next version only while it holds the complete roster of the current one. Each version has one author who had all earlier ones, as relays and members already assume, so they keep today's roster protocol [§4.10]. +- **Relays treat owner actions as today**, without checking them against the link: they can't forge owner signatures, and owners are trusted [Ov: Decisions, §4.13]. +- **Background work is a "link sync"**: agent async commands queued on the owner's connection to a relay (its "mailbox"), each step re-deriving what is left from the latest read and the database, so it survives restarts. User commands write synchronously [§4.4]. + +![Components and what each holds](1-components.svg) + +*Diagram 1. Who holds what, and who talks to whom.* + +![An owner write: read, change, swap, conflict, retry](2-owner-write.svg) + +*Diagram 2. Two owners write at once; the stale swap gets `CONFLICT`, reads again and re-applies.* + +![Role changes claim roster versions through the link](3-roster-claims.svg) + +*Diagram 3. An owner claims roster version v + 1 only while it holds the complete roster at v.* + +## Protocol changes + +| Layer | Change | Plan | +|---|---|---| +| SMP v24 | `LSWP hash [keys] userData`, a recipient command, not proxied: stores the new user data, and the whole key set if given, only if the SHA-256 of the stored encrypted user data equals `hash`. No fixed data is sent, so 52 Ed25519 keys fit (about 50 owners, computed); an `LSET`-shaped command would fit 6. | §4.1 | +| SMP v24 | Error `CONFLICT`, from `LSWP` only. `LSET` gets `AUTH` on a queue with several keys, so stale or downgraded single-owner writes can't overwrite multi-owner data. Store-log record `LKEYS` (data with its keys). | §4.1 | +| Link data | `GroupShortLinkData` gains optional `linkVersion` (write counter), `revokedOwners` (never shrinks), `creatorQueueKey` (kept in the key set while the creator is an owner), `linkRcvId` (the queue address other owners write to) and `roster {version, author}`. Writes keep unknown fields. New chain entries are signed by the root key (creator) or the signer's member key. | §4.3, §4.4 | +| Chat v23 | `x.grp.owner.inv {memberId}` and `x.grp.owner.acpt {invId}` through the invitee's support chat; the commit `x.grp.owner.add {memberId, memberKey, profile}` to everyone; unsigned `x.grp.members.req {cursor}` and `x.grp.members.page` between a new owner and one relay; optional `linkVersion` in `x.grp.info`. | §4.5 | + +Plan §9 leaves two of these open, both recommended as planned: sending only the user data (q.4) and refusing `LSET` on multi-key queues, a server behaviour change (q.5). + +**Compatibility** [§4.3, §5]. Promotion needs every relay on chat v23 and the link server on SMP v24. Older members ignore the commit and reject a new owner's messages until they upgrade; older joiners take revoked chain entries as owners, whose keys authorise nothing on the queue. A channel switches to multi-owner writes at its first accepted invitation; after the creator's first swap its queued single-owner writes get `AUTH`. Other channels keep today's code paths. + +## Types and functions by module + +| Repo | Module | Added or changed | Plan | +|---|---|---|---| +| simplexmq | `Protocol`, `Transport` | `LinkDataHash`, `CONFLICT`, `LSWP`; SMP 23 → 24 (proxied stays 23) | §4.1 | +| simplexmq | `Server`, `QueueStore/*`, `StoreLog`, `Client` | `LSWP` handler; `swapQueueLinkData` (STM, Postgres, journal); multi-key `LSET` refusal; `CreateLinkKeys`; `swapSMPQueueLink` | §4.1 | +| simplexmq | agent | `LinkOwnerCreds`; read with hash, `setShortLinkSwap` (sync, async), `deleteLinkQueueWithCreds`, creator queue credentials, v24 and private-routing checks; hash in `LDATA`; a session per link | §4.2 | +| chat | `Protocol` | link data fields, `LinkRoster`, `CreatorQueueKey`; five events; chat 22 → 23 | §4.3, §4.5 | +| chat | `Controller` | `APIInviteGroupOwner`, `APIAcceptGroupOwnerInvitation`, `CROwnerInvitationSent`; `CEChannelLink` (11 cases); six config limits; a test hook; in-memory state; `Maybe GroupLink` in `CEvtGroupLinkDataUpdated` and `CRGroupRelaysAdded` | §4.13, §4.16 | +| chat | `Types`, `Messages/CIContent/Events` | `CFChannelLinkSync`, `CFChannelLinkResign`, `CFChannelLinkSwap`; `RGEOwnerInvitation` | §4.4, §4.6 | +| chat | `Library/*` | `writeChannelLink` (user writes), `enqueueLinkSync` (background), `ChannelLinkChange` (profile, add or revoke owner, relays, count, roster); `applyChannelLinkData` (every read); `claimRosterVersion`; `activateGroupRelays` | §4.4, §4.10, §4.13 | + +## Database + +| Table | New columns | Meaning | +|---|---|---| +| `groups` | `link_data_version`, `link_roster_version`, `link_roster_seen_at`, `members_catchup`, `roster_pending_msg_id` | highest `linkVersion` seen; last roster commitment and when it appeared; catch-up pending; own roster not yet sent | +| `group_members` | `owner_inv_msg_id`, `owner_inv_accepted`, `relay_web_domain` | pending invitation and its acceptance; a relay's web domain without a relay row | + +One chat migration, `M20261015_channel_multi_owner` (SQLite and Postgres, with its down migration), columns only; no agent or server schema change [§4.16, §5]. + +![Data model before and after](7-data-model.svg) + +*Diagram 7. What the plan adds, by store.* + +## API, commands and apps + +- **New** [§4.6]: `/owner invite # ` (`APIInviteGroupOwner`), refused unless the member's security code is verified (the only check that catches a relay substituting its key), every relay is on chat v23, the link server on SMP v24, and the chain under 50 entries. `/_owner accept # [direct=on]` (`APIAcceptGroupOwnerInvitation`) runs on the member's device, which from then on connects to the link server directly, revealing its IP address to that operator; the invitation says so, private-routing settings need an explicit confirmation, and settings that forbid direct connections refuse it. +- **Changed in multi-owner mode**: role changes, and removals of members, moderators and admins, claim a roster version [§4.10]; removing an owner and leaving (not the last owner) are link writes, and destroying deletes the link queue first [§4.8, §4.9, §4.14]; a profile edit is refused when the link's profile differs from the one the device held when the edit started [§4.12]; owners read the link on channel open [§4.4]. Group-link commands are refused for every channel [§4.14]. +- **Apps**, iOS and Kotlin mirrored, ~380 lines each [§6, §6.1]: Owner in the role picker via verification, the invitation item, "Remove owner", Leave while another owner exists, link screen, composer and relay settings for non-creator owners, and editors that refuse a stale save. + +![Adding an owner end to end](4-promotion.svg) + +*Diagram 4. Promotion end to end. Transferring ownership is this, then the creator leaves (diagram 5).* + +![Removing an owner and leaving](5-removal-and-leave.svg) + +*Diagram 5. A removal and a leave land on the link first; the swap drops the owner's key, then the announcement follows.* + +## How owners, relays and members stay consistent + +- **Owners** announce profile, role, owner and relay changes and leaves only after their link write lands; posts, moderation, blocks, subscriber removals and invitations are sent as today [§5]. Every read goes through `applyChannelLinkData`: an older `linkVersion` is ignored, and reads only add owners or mark revoked ones departed, so a stale read can't undo a promotion but can show a removed owner as current until the next read [§4.4, §5]. +- **Roster**: an owner that sees another owner's role event above its version fetches the roster from the relays, and a claim before it arrives gets `CLERosterSyncing` ("catching up, try again"). Departed owners' roster messages still pass the gate. An undelivered roster is re-signed at the next version; a departed author's version that no relay serves is abandoned after a day (claim `r + 2`) [§4.10]. +- **Relays** apply owner events as today, plus the commit, read the link at invitation and every 15 minutes, and accept from a departed owner only its leave and roster messages up to the link's roster version [§4.13, §5]. **Relays and members** ignore an `x.grp.info` older than their `linkVersion` (relays still forward it) [§4.12]. +- **Background work** runs in the main app only, at most five swaps per channel per trigger; chat start re-enqueues it [§4.4, §4.15]. A new owner pages the member list from one relay, without pictures; the first current owner in the chain publishes the count at most once a minute [§4.4, §4.7, §4.11]. + +## Accepted trade-offs and residual risks + +Trade-offs [Ov: Trade-offs users notice, Ov: Decisions]: + +- Owner actions need the link server (posting doesn't); a role change takes 1–3 s and can be refused for a few seconds after another owner's. The link server learns when owners act and how many there are; a new owner's profile reaches every subscriber. +- **A promotion wins over a concurrent removal or leave of the member** (decided 2026-10-19): parties that applied the removal first take the member as an owner at their next read; if the member's device applied the removal first, it has left, and the member stays listed as an owner until an owner removes it [§4.8]. With an owner as remover the device applies the removal in either order (today's `checkRole`, which the plan keeps; T8 tests the removal-first order). +- Blocking stays unordered, as in groups; no invitation cancel, field-level profile merge, equal-version tie-break or root-key retirement [§2]. + +![A promotion racing a removal](6-promotion-removal-race.svg) + +*Diagram 6. The link decides who is listed as an owner; arrival order decides each party's state until its next read, except that a member device that left stays gone and relays that applied the removal keep no connection to the member.* + +Residual risks [Ov: Residual risks, §5]: + +- A link server ignoring the compare-and-swap can lose writes or split roles, but can't forge. +- A removed owner on modified software acts as an owner through a relay until the relay learns of the removal (the announcement, or its next 15-minute check), and can send inflated role versions that block later role changes at that relay and its members; with a colluding link server it can serve forged link data. +- Any current owner can destroy the channel or lock the others out. +- A role change no relay serves (its author lost or removed first, or its only relay failed) stops every other owner's role changes while a lost author is still an owner; once it is removed, the version is abandoned within a day; devices that applied part of it revert, and earlier versions the abandoning owner missed are dropped. +- A remover that crashes after its swap sends no announcement; others learn at their next read. +- A creator restored from an old backup acts as single owner until it leaves or destroys, and its role changes can collide. A downgraded server loses recent link data and stops multi-owner writes; a creator on an older app can't change the link. + +## Slices + +~2,260 production Haskell lines (simplexmq ~490, chat ~1,770) plus tests, and ~380 per app platform; each slice compiles and passes its tests on its own [§6]. + +| # | Slice | Lines | +|---|---|---| +| S1 | simplexmq store: hash, `CONFLICT`, swap per store, `LSET` refusal, `LKEYS` | ~190 | +| S2 | simplexmq protocol: v24, `LSWP`, handler, client | ~100 | +| S3 | simplexmq agent | ~200 | +| S4 | chat on the new simplexmq, structural | ~90 | +| S5 | chat wire and data, inert | ~300 | +| S6 | link writes and reads, multi-owner mode | ~200 | +| S7 | link syncs | ~170 | +| S8 | callers: profile, relays, count | ~190 | +| S9 | promotion | ~220 | +| S10 | member catch-up | ~120 | +| S11 | roster through the link | ~250 | +| S12 | removal, leave, destroy | ~170 | +| S13 | guards | ~60 | +| S14–S17 | iOS, then Kotlin mirroring it | ~380 per platform | + +**Risks** [§8, Ov: Not verified, size]: the riskiest assumption is that the unchanged roster protocol converges with link-claimed versions (S11, S12 test it); the second, that every background callback re-derived from the latest read is idempotent (T17). S1 and S2 first prove the swap's atomicity and the computed 50-owner size; the 100,000-member catch-up is an estimate. App code and Postgres tests run only on the host. diff --git a/plans/2026-10-15-channel-multi-owner-s3min.md b/plans/2026-10-15-channel-multi-owner-s3min.md index 34f6ef8724..7c414b8a6e 100644 --- a/plans/2026-10-15-channel-multi-owner-s3min.md +++ b/plans/2026-10-15-channel-multi-owner-s3min.md @@ -4,6 +4,8 @@ A channel can have several equal owners. Any owner can do what the single owner The implementation plan is `2026-10-15-channel-multi-owner-s3min-implementation.md`. The earlier designs are kept as `…-original.md` (the full design at `596d5f98a`) and `…-s1.md` (the minimal scope at `661adb5f6`). +A short technical summary with diagrams, for programmers who won't read the plans: [summary.md](2026-10-15-channel-multi-owner-s3min-sketches/summary.md). + ## What owners can do | Action | How it works for users | @@ -11,8 +13,8 @@ The implementation plan is `2026-10-15-channel-multi-owner-s3min-implementation. | Post, moderate, edit the channel, manage relays, share the link | as the creator does today, from any owner's device | | Make a member an owner | 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 | +| 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 ([diagram 5](2026-10-15-channel-multi-owner-s3min-sketches/5-removal-and-leave.svg)) | +| Leave | while another owner remains; with several owners the last one cannot leave ([diagram 5](2026-10-15-channel-multi-owner-s3min-sketches/5-removal-and-leave.svg)) | | 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 | @@ -21,11 +23,11 @@ Single-owner channels, and channels whose owner invitations nobody accepted, kee ## 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: +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 ([diagram 1](2026-10-15-channel-multi-owner-s3min-sketches/1-components.svg), [diagram 7](2026-10-15-channel-multi-owner-s3min-sketches/7-data-model.svg)): -- **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. +- **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 ([diagram 2](2026-10-15-channel-multi-owner-s3min-sketches/2-owner-write.svg)). +- **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 ([diagram 3](2026-10-15-channel-multi-owner-s3min-sketches/3-roster-claims.svg)). +- **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 ([diagram 4](2026-10-15-channel-multi-owner-s3min-sketches/4-promotion.svg)). - **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. @@ -48,7 +50,7 @@ The channel's link data lives on one SMP server, the link server, which already - **Edits over a changed profile are refused**, and the current profile is shown. - **A new owner's device connects to the link server directly**, revealing its IP address to that server's operator; the invitation says so, and private-routing settings need an explicit confirmation. Link reads stay private. - **The link server learns when owners act and how many there are.** -- **A promotion wins over a removal at the same moment**: devices that applied the removal first take the member as an owner at their next link read. If the member's own device applied it first, it has left, and the member stays listed as an owner without a device until another owner removes it. +- **A promotion wins over a removal at the same moment**: devices that applied the removal first take the member as an owner at their next link read. If the member's own device applied it first, it has left, and the member stays listed as an owner without a device until another owner removes it ([diagram 6](2026-10-15-channel-multi-owner-s3min-sketches/6-promotion-removal-race.svg)). - **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.