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 index e46f153815..109afc94c6 100644 --- 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 @@ -1,91 +1,74 @@ - + - + - - + + - 1 · Components and what each holds - The link server orders every change the owners share; relays and members keep their roles of today. + Who holds what, and who talks to whom + Owners write the link; relays and members only read it. Blue marks what the plan adds. - - 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 + + Link server: the channel's link queue + + May write it: recipient keys + the key the creator made the queue + with, while the creator is an owner + + every current owner's member key, + the creator's included + replaced together with the data + Link data, signed and encrypted + owner chain, relays, profile, count + + revoked owners, write counter, + + roster version and its author + 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 + + LSWP + direct + + read + hash + (proxied) + + read at invitation + and every 15 min + + read at channel + open and join - - 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 + + Owners, all equal + write the link + sign link data and events + keep the full roster - - Members - owners and their keys, - from chain and commits - roster gate, as today - profile gate: linkVersion - hear owners via relays + + Relays + forward owner-signed events + serve the roster + serve member pages + skip older profile updates + cannot forge an owner - - LSWP - direct, a session - per link - - read + hash - (proxied) + + Members + apply roles in version order + skip older profile updates + learn owners from the link + and from x.grp.owner.add - - read at invitation - and every 15 min + events, roster + member pages + + forwarded + events, roster + - - 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. + Solid arrow: write or message · dashed arrow: read · blue: new in this plan 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 index e5e07f173c..b64a0d2042 100644 --- 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 @@ -1,4 +1,4 @@ - + @@ -6,85 +6,66 @@ - - - - - + + - 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. + Two owners change the link at the same time + A swap lands only if the link data is still what the owner read; otherwise the owner reads again and re-applies. - - - - - Owner O1 - - Link server - - Owner O2 + + + + + Owner O1 + + Link server + + Owner O2 - - stores D1 · hash h1 + + holds D1 · hash h1 - - read - - read - - D1, h1 - - D1, h1 + + read + + read + + D1, h1 + + D1, h1 + applies change A + applies change B - - applies its change A to D1 - - applies its change B to D1 + + LSWP h1: D1 + B + + h1 matches: stores D2 · h2 + + OK + announces B to relays - - 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 - - LSWP h1, D1 + A - - h2 ≠ h1: nothing stored - - CONFLICT + + read again + + D2, h2 + re-applies A to D2 - - 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 + announces A to relays - - 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 + Solid arrow: request · dashed arrow: reply · red: refused 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 index c3c7069a07..6d0ba32ea4 100644 --- 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 @@ -1,103 +1,73 @@ - + - - - - - + + - 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. + Two owners change roles: each claims the next roster version on the link + An owner claims version v + 1 only while it holds the complete roster at v, the version the link names. - - - - - - - Owner O1 - - Link server - - Owner O2 - - Relays - - Members + + + + + + Owner O1 + + Link server + + Owner O2 + + Relays + forward to members, + applied in version order - - holds 7 - - roster {7, O1} - - holds 7 - - gate 7 + holds 7 + + roster 7 by O1 + holds 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 + + roster 7 by O1 + holds 7: may claim 8 + + LSWP: roster 8 by O2 + + roster 8 by O2 + + OK + + roster 8, role change at 8 - - read - user's change - - roster {8, O2} - - still behind: refused, - CLERosterSyncing + + read (user changes a role) + + roster 8 by O2 + + holds 7, link names 8: refused, + “catching up, try again” + + roster request (holds 7) + + roster 8 + holds 8 - - roster 8 - - holds 8 + + read (user retries) + + roster 8 by O2 + + LSWP: roster 9 by O1 + + OK + + roster 9, role change at 9 - - 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. + Solid arrow: request or message · dashed arrow: reply · red: refused 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 index 151a503f39..a5f5cd378c 100644 --- 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 @@ -1,113 +1,82 @@ - + - - - - - + + - 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]. + Adding an owner: consent, one link write, then the commit + Owner O1 promotes member M. Every message between owners and M goes through the relays. - - - - - - - Link server - - Owner O1 - - Relays - - Member M - - Everyone else + + + + + + Link server + + Owner O1 + + Relays + + Member M - - 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 + Invite + + checks: M's code verified, + relays and link server upgraded + + x.grp.owner.inv + + to M's support chat + + “Become an owner?” + everyone will see its profile; + the link server will see 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 + Accept + + x.grp.owner.acpt + + from M's support chat - - 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 + Link sync + in the background + + read + + data, hash + + LSWP: add M and its key + + chain + M's entry, signed by O1; + key set + M's key + + OK + + read + + shows M's entry - - 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 + Commit + + x.grp.owner.add + + to everyone, M included + + apply it: M is an owner + + is now an 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 + Catch-up + + x.grp.members.req + + x.grp.members.page + until the last - - 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. + Solid arrow: request or message · dashed arrow: reply 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 index 9da62a22a2..4e631a981a 100644 --- 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 @@ -1,4 +1,4 @@ - + @@ -6,122 +6,73 @@ - - - - - + + - 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. + Removing an owner, and leaving + Both land on the link first: the swap drops the owner's key at once, then the announcement follows. - - A · An owner removes owner X [§4.8] - - - - - - - Link server - - Owner O1 - - Relays - - Owner X - - Everyone else + An owner removes owner X + + + + + + + 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. + + read + + data, hash + + LSWP: revoke X + + X revoked; + X's key out of the key set + + OK + + x.grp.mem.del X + + first to X + + then to everyone else + + delete their + connection to X + + applies it, + erases its keys + + X departed + + AUTH to any later LSWP or DEL by X - - B · An owner leaves, the creator included [§4.9] - - - - - - Link server - - Owner L - - Relays - - Everyone else + An owner leaves, the creator included + + refused if no other + current owner remains + + + if its own last roster isn't stored + or sent: re-sign it with one more LSWP + and wait up to 30 s for it to be sent; + if it isn't, the leave is refused + + + LSWP: revoke itself, drop its key; + then x.grp.leave, erase its keys - - 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]. + Solid arrow: request, message or next step · dashed arrow: reply · red: refused 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 deleted file mode 100644 index dbe3257351..0000000000 --- a/plans/2026-10-15-channel-multi-owner-s3min-sketches/6-promotion-removal-race.svg +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - 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 deleted file mode 100644 index 480e9b9924..0000000000 --- a/plans/2026-10-15-channel-multi-owner-s3min-sketches/7-data-model.svg +++ /dev/null @@ -1,85 +0,0 @@ - - - - - 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 index 14bfa73a89..5014f6cedb 100644 --- a/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.html +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.html @@ -49,7 +49,7 @@ .page { max-width: 1040px; margin: 0 auto; - padding-block: 40px 72px; + padding-block: 28px 64px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); display: flex; @@ -57,7 +57,7 @@ gap: 14px; } .page > p, .page > ul, .page > h1, .page > h2, .page > header { max-width: 74ch; } - header { display: flex; flex-direction: column; gap: 10px; margin-bottom: 8px; } + header { display: flex; flex-direction: column; gap: 8px; } .eyebrow { font-family: var(--font-mono); font-size: 0.75rem; @@ -66,12 +66,12 @@ color: var(--accent); } h1, h2 { font-family: var(--font-display); font-weight: 700; text-wrap: balance; margin: 0; line-height: 1.2; } - h1 { font-size: 2.1rem; } + h1 { font-size: 1.9rem; } h2 { font-size: 1.45rem; margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--rule); } p, ul { margin: 0; } ul { padding-left: 1.3em; display: flex; flex-direction: column; gap: 8px; } .lede { font-size: 1.05rem; } - .muted { color: var(--muted); } + .muted { color: var(--muted); font-size: 0.9rem; } nav.toc { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 0.9rem; } a { color: var(--accent); text-underline-offset: 2px; } a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; } @@ -89,14 +89,13 @@ th, td { text-align: left; vertical-align: top; padding: 9px 12px; border-bottom: 1px solid var(--rule); } tr:last-child td { border-bottom: 0; } th { font-weight: 600; color: var(--muted); font-size: 0.8rem; letter-spacing: 0.04em; text-transform: uppercase; } - td.num { font-variant-numeric: tabular-nums; white-space: nowrap; } - figure { margin: 10px 0; display: flex; flex-direction: column; gap: 8px; } + td.nowrap { white-space: nowrap; } + figure { margin: 6px 0; display: flex; flex-direction: column; gap: 8px; } .fig-scroll { overflow-x: auto; border-radius: 8px; } - .fig-scroll svg { display: block; width: 100%; min-width: 680px; height: auto; } + .fig-scroll svg { display: block; width: 100%; max-width: 840px; min-width: 680px; height: auto; } figcaption { color: var(--muted); font-size: 0.9rem; max-width: 74ch; } - figcaption b { color: var(--fg); } @media (max-width: 520px) { - h1 { font-size: 1.7rem; } + h1 { font-size: 1.6rem; } body { font-size: 15px; } } @@ -105,130 +104,15 @@
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.

- +

Several equal owners per channel, each owner change ordered by the link server; nothing is implemented yet. S3-min is the plan's name for this design: the minimal one that keeps every feature of the original, larger design.

-

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]:

- +

Problem. Today a channel's creator is its only owner: it overwrites the link data on the link server with LSET, last writer wins, and alone numbers the roster, the signed list of member, moderator and admin roles that relays and members apply in version order [§3]. Several owners writing this way would undo each other's changes.

+

Idea. The link server already orders writes to the link, so each owner change becomes a compare-and-swap: an owner reads the link data, applies its one change and writes it back with a new command, LSWP, carrying the hash of what it read. If another owner wrote in between, the server answers CONFLICT, and the owner reads again and re-applies, five attempts at most [§4.1, §4.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.
-
-
-
- + @@ -236,460 +120,260 @@ - - - - - + + - 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. + Two owners change the link at the same time + A swap lands only if the link data is still what the owner read; otherwise the owner reads again and re-applies. - - - - - Owner O1 - - Link server - - Owner O2 + + + + + Owner O1 + + Link server + + Owner O2 - - stores D1 · hash h1 + + holds D1 · hash h1 - - read - - read - - D1, h1 - - D1, h1 + + read + + read + + D1, h1 + + D1, h1 + applies change A + applies change B - - applies its change A to D1 - - applies its change B to D1 + + LSWP h1: D1 + B + + h1 matches: stores D2 · h2 + + OK + announces B to relays - - 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 - - LSWP h1, D1 + A - - h2 ≠ h1: nothing stored - - CONFLICT + + read again + + D2, h2 + re-applies A to D2 - - 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 + announces A to relays - - 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 + Solid arrow: request · dashed arrow: reply · red: refused
-
Diagram 2. Two owners write at once; the stale swap gets CONFLICT, reads again and re-applies.
-
+ +

Sources: the overview and the implementation plan. Brackets cite them: [Ov: heading] is a heading of the overview, [§4.4] a section of the implementation plan; T8 and T17 are its tests (§7).

+

Terms: the link is the channel's link data on the link server; its write counter, linkVersion, goes up with every swap; the roster version numbers role changes; a link sync is an owner's background work on the link, defined below.

+ + +

Approach and why

+

A member becomes an owner only by accepting; owners can remove each other, and any owner, the creator included, can leave. Owners are trusted as the single owner is today, and the design defends against malicious relays and a link server that withholds or serves stale data. So relays handle owner actions as today, without checking them against the link, and relays and members keep today's roster protocol [Ov: intro, Ov: Decisions, §4]. Work an owner does in the background, such as finishing a promotion, is a link sync: agent commands queued on the owner's connection to a relay, each step re-derived from the latest read, so it survives restarts [§4.4].

+ +
- + - + - - + + - - + + - 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. + Who holds what, and who talks to whom + Owners write the link; relays and members only read it. Blue marks what the plan adds. - - - - - - - Owner O1 - - Link server - - Owner O2 - - Relays - - Members + + Link server: the channel's link queue + + May write it: recipient keys + the key the creator made the queue + with, while the creator is an owner + + every current owner's member key, + the creator's included + replaced together with the data + Link data, signed and encrypted + owner chain, relays, profile, count + + revoked owners, write counter, + + roster version and its author + multi-owner: written only by LSWP - - holds 7 - - roster {7, O1} - - holds 7 - - gate 7 + + LSWP + direct + + read + hash + (proxied) + + read at invitation + and every 15 min + + read at channel + open and join - - 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) + + Owners, all equal + write the link + sign link data and events + keep the full roster - - read - user's change - - roster {8, O2} - - still behind: refused, - CLERosterSyncing + + Relays + forward owner-signed events + serve the roster + serve member pages + skip older profile updates + cannot forge an owner - - roster 8 - - holds 8 + + Members + apply roles in version order + skip older profile updates + learn owners from the link + and from x.grp.owner.add - - read - user retries - - roster {8, O2} - - LSWP roster {9, O1} - - roster {9, O1} - - OK - - roster 9 + role @9 - - role @9 + events, roster + member pages + + forwarded + events, roster + - - 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. + Solid arrow: write or message · dashed arrow: read · blue: new in this plan
-
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].

-
+

Adding and removing owners

+
- - - - - 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

-
    -
  • New [§4.6]: /owner invite #<group> <member> (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 #<groupId> <chatItemId> [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.
  • -
-
-
- + - - - - - + + - 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]. + Adding an owner: consent, one link write, then the commit + Owner O1 promotes member M. Every message between owners and M goes through the relays. - - - - - - - Link server - - Owner O1 - - Relays - - Member M - - Everyone else + + + + + + Link server + + Owner O1 + + Relays + + Member M - - 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 + Invite + + checks: M's code verified, + relays and link server upgraded + + x.grp.owner.inv + + to M's support chat + + “Become an owner?” + everyone will see its profile; + the link server will see 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 + Accept + + x.grp.owner.acpt + + from M's support chat - - 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 + Link sync + in the background + + read + + data, hash + + LSWP: add M and its key + + chain + M's entry, signed by O1; + key set + M's key + + OK + + read + + shows M's entry - - 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 + Commit + + x.grp.owner.add + + to everyone, M included + + apply it: M is an owner + + is now an 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 + Catch-up + + x.grp.members.req + + x.grp.members.page + until the last - - 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. + Solid arrow: request or message · dashed arrow: reply
-
Diagram 4. Promotion end to end. Transferring ownership is this, then the creator leaves (diagram 5).
+
Transferring ownership is this promotion, then the creator leaves.
-
+ +
- + @@ -697,262 +381,247 @@ - - - - - + + - 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. + Removing an owner, and leaving + Both land on the link first: the swap drops the owner's key at once, then the announcement follows. - - A · An owner removes owner X [§4.8] - - - - - - - Link server - - Owner O1 - - Relays - - Owner X - - Everyone else + An owner removes owner X + + + + + + + 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. + + read + + data, hash + + LSWP: revoke X + + X revoked; + X's key out of the key set + + OK + + x.grp.mem.del X + + first to X + + then to everyone else + + delete their + connection to X + + applies it, + erases its keys + + X departed + + AUTH to any later LSWP or DEL by X - - B · An owner leaves, the creator included [§4.9] - - - - - - Link server - - Owner L - - Relays - - Everyone else + An owner leaves, the creator included + + refused if no other + current owner remains + + + if its own last roster isn't stored + or sent: re-sign it with one more LSWP + and wait up to 30 s for it to be sent; + if it isn't, the leave is refused + + + LSWP: revoke itself, drop its key; + then x.grp.leave, erase its keys - - 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]. + Solid arrow: request, message or next step · dashed arrow: reply · red: refused
-
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].
  • +
  • Reads ignore link data older than the stored linkVersion and only add owners or mark revoked ones departed: a stale read can't undo a promotion, but can make a party take a removed owner as current until its next read; that device can no longer write the link, and an honest one sends nothing [§4.4, §5].
  • +
  • Roster: an owner may claim the next roster version on the link only while it holds the complete roster at the current one (picture below). An owner that sees another owner's role change above its version fetches the roster from the relays; roster messages from an owner who has since left or been removed are still applied in version order; an undelivered own roster is re-signed at the next version [§4.10].
  • +
  • Relays accept from a departed owner only its leave and roster messages up to the link's roster version. Relays and members skip an x.grp.info older than their linkVersion; relays still forward it [§4.12, §4.13].
  • +
  • Link syncs run in the main app only, at most five swaps per channel per trigger. The first current owner in the chain publishes the subscriber count, batching joins over a minute [§4.4, §4.11, §4.15].
-

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].
  • -
-
+
- + - + - - - - - - - - + + - 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]. + Two owners change roles: each claims the next roster version on the link + An owner claims version v + 1 only while it holds the complete roster at v, the version the link names. - - 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 + + + + + + Owner O1 + + Link server + + Owner O2 + + Relays + forward to members, + applied in version order - - LSWP - - commit: x.grp.owner.add M - - removal: x.grp.mem.del M + holds 7 + + roster 7 by O1 + holds 7 - - Applied the removal first - - Applied the commit first + + read + + roster 7 by O1 + holds 7: may claim 8 + + LSWP: roster 8 by O2 + + roster 8 by O2 + + OK + + roster 8, role change at 8 - - 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 + + read (user changes a role) + + roster 8 by O2 + + holds 7, link names 8: refused, + “catching up, try again” + + roster request (holds 7) + + roster 8 + holds 8 - - 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 + + read (user retries) + + roster 8 by O2 + + LSWP: roster 9 by O1 + + OK + + roster 9, role change at 9 - - 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). + Solid arrow: request or message · dashed arrow: reply · red: refused
-
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].

+

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, and its member list comes from one relay, without profile pictures (a malicious relay can add made-up subscribers to it, never members with roles).
  • +
  • The link orders profile, role, owner and relay changes, leaving and destroying, each announced after its write lands; posts, moderation, blocks, subscriber removals and invitations are sent as today, outside the link [§5]. Not built: cancelling an invitation, merging two profile edits field by field, a tie-break between equal roster versions, retiring the creator's root key.
  • +
  • Older apps and servers: promotion needs every relay on chat protocol v23 and the link server on SMP v24. Older members reject a new owner's messages until they upgrade; older joiners take every owner the link lists as an owner, removed ones included, though those keys can't write the link. A channel's link writes switch to the swap at its first accepted invitation and stay as today until then; a single-owner creator also reads the link before leaving or destroying, and a failed read refuses the leave [§4.3, §4.9, §4.14, §5].
  • +
  • A promotion wins over a concurrent removal or leave of member M (decided 2026-10-19 as a rare case, with no mitigation). The link decides who is listed as an owner; what each party does depends on which message it applied first [§4.8]:
  • +
- + - - - - - - - - - - - - - - + +
#SliceLines
PartyApplied the removal firstApplied the commit first
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
Relays, members, ownersrefuse the commit; take M as an owner at the next read; relays have dropped their connection to MM is an owner; an admin's later removal is refused, an owner's is applied and the next read makes M an owner again
M's own devicehas left the channel; the link still lists M as an owner, with no device behind it, until an owner removes itadmin remover: M stays an owner; owner remover: applies the removal and leaves
-

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.

+

The remover's device always applied the removal first. The owner-remover cells follow today's checkRole, which the plan keeps; test T8 covers only the removal-first order.

+

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), including inflated role versions that block later role changes there and at its members. A relay that learned from its check still accepts its new role events up to the link's roster version, and members apply them; with a colluding link server the removed owner can serve forged link data.
  • +
  • Any current owner can destroy the channel or lock the others out.
  • +
  • A role change no relay serves (author lost or removed first, or its only relay failed) blocks other owners' role changes while a lost author is still an owner. Once it is removed, the others wait up to a day, then one owner skips its version and carries on from its own roster, which every device then takes: role changes from that version that some devices had already applied are undone there, and so are that author's earlier changes the skipping owner never received, wherever they were applied.
  • +
  • A remover crashing after its swap sends no announcement; others learn at their next read.
  • +
  • A creator restored from a backup older than its first promotion acts as the single owner until it leaves or destroys, and its role changes can collide. A downgraded server keeps the owner keys but loses link data written since its last start, stops multi-owner writes and accepts LSET again; on an upgraded server a creator on an older app can't change the link.
  • +
+ +

Slices

+

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

+
+ + + + + + + + + + +
SlicesWhat
S1–S3simplexmq: store and swap, SMP protocol v24, agent
S4–S8chat: wire and data, link writes and reads, link syncs, profile, relays, count
S9–S10promotion, member catch-up
S11roster through the link
S12–S13removal, leave, destroy; guards
S14–S17iOS, then Kotlin
+
+

Risks [§8, Ov: Not verified, size]: the riskiest assumption, which tests in S11 and S12 check, is that today's roster protocol converges when versions are claimed through the link from complete rosters and departed owners' roster messages are still applied. The next is that background callbacks, re-derived from the latest read and the database, are idempotent; one that isn't would send twice or never (test T17, in S9). S1–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.

+

Open in plan §9, both taken as recommended unless a review shows otherwise, and built in S1–S2: LSWP sending only the user data, which leaves room for about 50 owner keys (computed from the encoders, not yet tested) instead of 6, and refusing LSET on multi-key queues, a server behaviour change that stops stale or downgraded single-owner writes from erasing other owners.

+

The rest of the page is reference for the programmers who build it.

+ +

Protocol changes

+
+ + + + + + + + +
LayerChangePlan
SMP v24LSWP hash [keys] userData: stores the user data, and the key set if given, only if hash is the SHA-256 of the stored user data; not proxied§4.1
SMP v24CONFLICT; LSET refused (AUTH) on multi-key queues; store-log record LKEYS (link data with its keys)§4.1
Link dataoptional linkVersion, revokedOwners, roster {version, author}, creatorQueueKey and linkRcvId (the creator's queue key, and the queue address other owners write to)§4.3
Chat protocol v23x.grp.owner.inv, x.grp.owner.acpt, x.grp.owner.add (invite, consent, commit); x.grp.members.req, x.grp.members.page (member list); linkVersion in x.grp.info§4.5
+
+ +

API, commands and apps

+
    +
  • /owner invite #<group> <member> (APIInviteGroupOwner) needs the member's security code verified (only this catches a relay substituting the member's key), relays on chat v23, the link server on SMP v24 and a chain under 50 entries [§4.6].
  • +
  • /_owner accept #<groupId> <chatItemId> [direct=on] (APIAcceptGroupOwnerInvitation): the device then talks to the link server directly, revealing its IP address; private routing asks first, and settings forbidding direct connections refuse [§4.6].
  • +
  • In multi-owner mode role changes, and removals of members, moderators and admins, claim the next roster version on the link; removing an owner and leaving write the link first; destroying deletes the link queue first. A profile edit is refused if the link's profile differs from the device's at the edit's start; the apps also refuse a save over a profile changed while editing [§4.8–§4.14].
  • +
  • Apps, iOS then Kotlin, ~380 lines each: Owner in the role picker via verification, the invitation, Remove owner, Leave, and the link and relay screens for every owner. Group-link commands are refused in channels [§4.14, §6.1].
  • +
+ +

Types by module

+
+ + + + + + + + +
ModuleAdded or changedPlan
simplexmq Protocol, Server, stores, ClientLinkDataHash, CONFLICT, LSWP, swapQueueLinkData, CreateLinkKeys; SMP version 23 → 24§4.1
simplexmq agentLinkOwnerCreds, setShortLinkSwap (sync, async), deleteLinkQueueWithCreds, hash in LDATA§4.2
chat Protocol, Controller, Typeslink fields, events, chat protocol 22 → 23; two commands, CEChannelLink, CFChannelLinkSync, CFChannelLinkResign, CFChannelLinkSwap§4.3, §4.5, §4.16
chat Library/*writeChannelLink, enqueueLinkSync, applyChannelLinkData, claimRosterVersion§4.4, §4.10
+
+ +

Database

+

groups gains link_data_version, link_roster_version, link_roster_seen_at, members_catchup and roster_pending_msg_id; group_members gains owner_inv_msg_id, owner_inv_accepted and relay_web_domain. One migration, M20261015_channel_multi_owner, SQLite and Postgres, columns only; no agent or server schema change [§4.16, §5].

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 index 53c53d7166..20872edf20 100644 --- a/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.md +++ b/plans/2026-10-15-channel-multi-owner-s3min-sketches/summary.md @@ -1,131 +1,109 @@ # 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. +Several equal owners per channel, each owner change ordered by the link server; nothing is implemented yet. S3-min is the plan's name for this design: the minimal one that keeps every feature of the original, larger design. + +**Problem.** Today a channel's creator is its only owner: it overwrites the link data on the link server with `LSET`, last writer wins, and alone numbers the roster, the signed list of member, moderator and admin roles that relays and members apply in version order [§3]. Several owners writing this way would undo each other's changes. + +**Idea.** The link server already orders writes to the link, so each owner change becomes a compare-and-swap: an owner reads the link data, applies its one change and writes it back with a new command, `LSWP`, carrying the hash of what it read. If another owner wrote in between, the server answers `CONFLICT`, and the owner reads again and re-applies, five attempts at most [§4.1, §4.4]. + +![Two owners writing at once](2-owner-write.svg) + +Sources: the [overview](../2026-10-15-channel-multi-owner-s3min.md) and the [implementation plan](../2026-10-15-channel-multi-owner-s3min-implementation.md). Brackets cite them: [Ov: heading] is a heading of the overview, [§4.4] a section of the implementation plan; T8 and T17 are its tests (§7). + +Terms: the link is the channel's link data on the link server; its write counter, `linkVersion`, goes up with every swap; the roster version numbers role changes; a link sync is an owner's background work on the link, defined below. ## 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]. +A member becomes an owner only by accepting; owners can remove each other, and any owner, the creator included, can leave. Owners are trusted as the single owner is today, and the design defends against malicious relays and a link server that withholds or serves stale data. So relays handle owner actions as today, without checking them against the link, and relays and members keep today's roster protocol [Ov: intro, Ov: Decisions, §4]. Work an owner does in the background, such as finishing a promotion, is a **link sync**: agent commands queued on the owner's connection to a relay, each step re-derived from the latest read, so it survives restarts [§4.4]. -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]. +![Who holds what](1-components.svg) -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]: +## Adding and removing owners -- **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]. +![Adding an owner](4-promotion.svg) -![Components and what each holds](1-components.svg) +Transferring ownership is this promotion, then the creator leaves. -*Diagram 1. Who holds what, and who talks to whom.* +![Removing an owner, and leaving](5-removal-and-leave.svg) -![An owner write: read, change, swap, conflict, retry](2-owner-write.svg) +## How owners, relays and members stay consistent -*Diagram 2. Two owners write at once; the stale swap gets `CONFLICT`, reads again and re-applies.* +- **Reads** ignore link data older than the stored `linkVersion` and only add owners or mark revoked ones departed: a stale read can't undo a promotion, but can make a party take a removed owner as current until its next read; that device can no longer write the link, and an honest one sends nothing [§4.4, §5]. +- **Roster**: an owner may claim the next roster version on the link only while it holds the complete roster at the current one (picture below). An owner that sees another owner's role change above its version fetches the roster from the relays; roster messages from an owner who has since left or been removed are still applied in version order; an undelivered own roster is re-signed at the next version [§4.10]. +- **Relays** accept from a departed owner only its leave and roster messages up to the link's roster version. Relays and members skip an `x.grp.info` older than their `linkVersion`; relays still forward it [§4.12, §4.13]. +- **Link syncs** run in the main app only, at most five swaps per channel per trigger. The first current owner in the chain publishes the subscriber count, batching joins over a minute [§4.4, §4.11, §4.15]. -![Role changes claim roster versions through the link](3-roster-claims.svg) +![Two owners changing roles](3-roster-claims.svg) -*Diagram 3. An owner claims roster version v + 1 only while it holds the complete roster at v.* +## 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, and its member list comes from one relay, without profile pictures (a malicious relay can add made-up subscribers to it, never members with roles). +- The link orders profile, role, owner and relay changes, leaving and destroying, each announced after its write lands; posts, moderation, blocks, subscriber removals and invitations are sent as today, outside the link [§5]. Not built: cancelling an invitation, merging two profile edits field by field, a tie-break between equal roster versions, retiring the creator's root key. +- Older apps and servers: promotion needs every relay on chat protocol v23 and the link server on SMP v24. Older members reject a new owner's messages until they upgrade; older joiners take every owner the link lists as an owner, removed ones included, though those keys can't write the link. A channel's link writes switch to the swap at its first accepted invitation and stay as today until then; a single-owner creator also reads the link before leaving or destroying, and a failed read refuses the leave [§4.3, §4.9, §4.14, §5]. +- A promotion wins over a concurrent removal or leave of member M (decided 2026-10-19 as a rare case, with no mitigation). The link decides who is listed as an owner; what each party does depends on which message it applied first [§4.8]: + +| Party | Applied the removal first | Applied the commit first | +|---|---|---| +| Relays, members, owners | refuse the commit; take M as an owner at the next read; relays have dropped their connection to M | M is an owner; an admin's later removal is refused, an owner's is applied and the next read makes M an owner again | +| M's own device | has left the channel; the link still lists M as an owner, with no device behind it, until an owner removes it | admin remover: M stays an owner; owner remover: applies the removal and leaves | + +The remover's device always applied the removal first. The owner-remover cells follow today's `checkRole`, which the plan keeps; test T8 covers only the removal-first order. + +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), including inflated role versions that block later role changes there and at its members. A relay that learned from its check still accepts its new role events up to the link's roster version, and members apply them; with a colluding link server the removed owner can serve forged link data. +- Any current owner can destroy the channel or lock the others out. +- A role change no relay serves (author lost or removed first, or its only relay failed) blocks other owners' role changes while a lost author is still an owner. Once it is removed, the others wait up to a day, then one owner skips its version and carries on from its own roster, which every device then takes: role changes from that version that some devices had already applied are undone there, and so are that author's earlier changes the skipping owner never received, wherever they were applied. +- A remover crashing after its swap sends no announcement; others learn at their next read. +- A creator restored from a backup older than its first promotion acts as the single owner until it leaves or destroys, and its role changes can collide. A downgraded server keeps the owner keys but loses link data written since its last start, stops multi-owner writes and accepts `LSET` again; on an upgraded server a creator on an older app can't change the link. + +## Slices + +~2,260 production Haskell lines (simplexmq ~490 plus chat ~1,770) plus tests, and ~380 per app platform; each slice compiles and passes its tests alone [§6]. + +| Slices | What | +|---|---| +| S1–S3 | simplexmq: store and swap, SMP protocol v24, agent | +| S4–S8 | chat: wire and data, link writes and reads, link syncs, profile, relays, count | +| S9–S10 | promotion, member catch-up | +| S11 | roster through the link | +| S12–S13 | removal, leave, destroy; guards | +| S14–S17 | iOS, then Kotlin | + +**Risks** [§8, Ov: Not verified, size]: the riskiest assumption, which tests in S11 and S12 check, is that today's roster protocol converges when versions are claimed through the link from complete rosters and departed owners' roster messages are still applied. The next is that background callbacks, re-derived from the latest read and the database, are idempotent; one that isn't would send twice or never (test T17, in S9). S1–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. + +**Open in plan §9**, both taken as recommended unless a review shows otherwise, and built in S1–S2: `LSWP` sending only the user data, which leaves room for about 50 owner keys (computed from the encoders, not yet tested) instead of 6, and refusing `LSET` on multi-key queues, a server behaviour change that stops stale or downgraded single-owner writes from erasing other owners. + +The rest of the page is reference for the programmers who build it. ## 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 | +| SMP v24 | `LSWP hash [keys] userData`: stores the user data, and the key set if given, only if `hash` is the SHA-256 of the stored user data; not proxied | §4.1 | +| SMP v24 | `CONFLICT`; `LSET` refused (`AUTH`) on multi-key queues; store-log record `LKEYS` (link data with its keys) | §4.1 | +| Link data | optional `linkVersion`, `revokedOwners`, `roster {version, author}`, `creatorQueueKey` and `linkRcvId` (the creator's queue key, and the queue address other owners write to) | §4.3 | +| Chat protocol v23 | `x.grp.owner.inv`, `x.grp.owner.acpt`, `x.grp.owner.add` (invite, consent, commit); `x.grp.members.req`, `x.grp.members.page` (member list); `linkVersion` in `x.grp.info` | §4.5 | +## API, commands and apps -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). +- **`/owner invite # `** (`APIInviteGroupOwner`) needs the member's security code verified (only this catches a relay substituting the member's key), relays on chat v23, the link server on SMP v24 and a chain under 50 entries [§4.6]. +- **`/_owner accept # [direct=on]`** (`APIAcceptGroupOwnerInvitation`): the device then talks to the link server directly, revealing its IP address; private routing asks first, and settings forbidding direct connections refuse [§4.6]. +- **In multi-owner mode** role changes, and removals of members, moderators and admins, claim the next roster version on the link; removing an owner and leaving write the link first; destroying deletes the link queue first. A profile edit is refused if the link's profile differs from the device's at the edit's start; the apps also refuse a save over a profile changed while editing [§4.8–§4.14]. +- **Apps**, iOS then Kotlin, ~380 lines each: Owner in the role picker via verification, the invitation, Remove owner, Leave, and the link and relay screens for every owner. Group-link commands are refused in channels [§4.14, §6.1]. -**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 by module -## 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 | +| Module | Added or changed | Plan | +|---|---|---| +| simplexmq `Protocol`, `Server`, stores, `Client` | `LinkDataHash`, `CONFLICT`, `LSWP`, `swapQueueLinkData`, `CreateLinkKeys`; SMP version 23 → 24 | §4.1 | +| simplexmq agent | `LinkOwnerCreds`, `setShortLinkSwap` (sync, async), `deleteLinkQueueWithCreds`, hash in `LDATA` | §4.2 | +| chat `Protocol`, `Controller`, `Types` | link fields, events, chat protocol 22 → 23; two commands, `CEChannelLink`, `CFChannelLinkSync`, `CFChannelLinkResign`, `CFChannelLinkSwap` | §4.3, §4.5, §4.16 | +| chat `Library/*` | `writeChannelLink`, `enqueueLinkSync`, `applyChannelLinkData`, `claimRosterVersion` | §4.4, §4.10 | ## 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. +`groups` gains `link_data_version`, `link_roster_version`, `link_roster_seen_at`, `members_catchup` and `roster_pending_msg_id`; `group_members` gains `owner_inv_msg_id`, `owner_inv_accepted` and `relay_web_domain`. One migration, `M20261015_channel_multi_owner`, SQLite and Postgres, columns only; no agent or server schema change [§4.16, §5]. diff --git a/plans/2026-10-15-channel-multi-owner-s3min.md b/plans/2026-10-15-channel-multi-owner-s3min.md index 7c414b8a6e..64c575512f 100644 --- a/plans/2026-10-15-channel-multi-owner-s3min.md +++ b/plans/2026-10-15-channel-multi-owner-s3min.md @@ -23,7 +23,7 @@ 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 ([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)): +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)): - **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)). @@ -50,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 ([diagram 6](2026-10-15-channel-multi-owner-s3min-sketches/6-promotion-removal-race.svg)). +- **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. - **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.