docs: channel multi-owner S3-min summary: simpler page, five smaller diagrams

The summary page and summary.md are reordered for a reader new to the
plan: the problem, the compare-and-swap idea and its picture first,
then the pictures of adding and removing owners, consistency,
trade-offs and risks, and slices, with the protocol, API, types and
database reference last. Terms and citations are explained on the
page, and two fresh accuracy checks against the plans were applied.

The diagrams are redrawn at 840 wide and at most 600 tall, without
rule panels: owner write, components, roster claims, promotion, and
removal with the leave. The promotion-versus-removal race becomes a
table, and the data model diagram is dropped, its facts being in the
tables. The overview loses its links to those two files; its wording
is unchanged.
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-22 15:31:03 +00:00
1 parent 7bd5c20cd7
commit e669bc3759
10 files changed
+804 -1469

No files matched your search

@@ -1,91 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="600" viewBox="0 0 960 600" role="img" aria-label="Components of a multi-owner channel: owner devices write the link server's single SMP queue with LSWP and read it through a proxy; relays forward owner-signed events, serve the roster to members and owners, and serve member pages to a new owner; relays and members read the link.">
<svg xmlns="http://www.w3.org/2000/svg" width="840" height="498" viewBox="0 0 840 498" role="img" aria-label="Who holds what in a multi-owner channel. The link server holds the channel's link queue: the recipient keys that may write it (the key the creator made the queue with, while the creator is an owner, plus every current owner's member key, the creator's included) and the signed link data (owner chain, relays, profile, count, plus revoked owners, a write counter and the roster version and author). Owners write it with LSWP over a direct connection and read it through a proxy; relays read it at invitation and every 15 minutes; members read it at channel open and join. Owners and relays exchange events and the roster, and relays send a new owner member pages; relays forward events and the roster to members. Relays and members skip profile updates older than the ones they hold.">
<defs>
<marker id="d1-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#24292f"/>
</marker>
<marker id="d1-arrow-new" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#b42318"/>
<path d="M0,0 L10,5 L0,10 z" fill="#0b5cad"/>
</marker>
</defs>
<rect width="960" height="600" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="959" height="599" fill="none" stroke="#d9d5cc"/>
<rect width="840" height="498" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="839" height="497" fill="none" stroke="#d9d5cc"/>
<g font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="12" fill="#1f2328">
<text x="24" y="34" font-size="17" font-weight="700">1 · Components and what each holds</text>
<text x="24" y="56" fill="#5b636e">The link server orders every change the owners share; relays and members keep their roles of today.</text>
<text x="20" y="30" font-size="16" font-weight="700">Who holds what, and who talks to whom</text>
<text x="20" y="50" fill="#5b636e">Owners write the link; relays and members only read it. <tspan fill="#0b5cad">Blue marks what the plan adds.</tspan></text>
<rect x="180" y="78" width="600" height="176" rx="10" fill="#fdf2e4" stroke="#b8661a" stroke-width="1.5"/>
<text x="480" y="102" text-anchor="middle" font-size="14" font-weight="700">Link server · the channel's one SMP queue</text>
<line x1="420" y1="116" x2="420" y2="242" stroke="#d7b48c"/>
<text x="198" y="134" font-weight="700">Recipient key set</text>
<text x="198" y="154">the creator's queue key, while</text>
<text x="198" y="172">it is an owner, + the member</text>
<text x="198" y="190">key of every non-revoked owner</text>
<text x="198" y="216" fill="#5b636e">may send LSWP and DEL;</text>
<text x="198" y="234" fill="#5b636e">LSWP replaces the set atomically</text>
<text x="438" y="134" font-weight="700">Link data, signed and encrypted</text>
<text x="438" y="154">owners chain, relays, profile, subscriber count</text>
<text x="438" y="172" fill="#b42318">revokedOwners: departed owners, never shrinks</text>
<text x="438" y="190" fill="#b42318">linkVersion · roster {version, author}</text>
<text x="438" y="208" fill="#b42318">creatorQueueKey · linkRcvId</text>
<text x="438" y="234" fill="#5b636e">once multi-owner, written only by LSWP</text>
<rect x="130" y="66" width="580" height="156" rx="8" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="420" y="88" text-anchor="middle" font-size="14" font-weight="700">Link server: the channel's link queue</text>
<line x1="420" y1="100" x2="420" y2="212" stroke="#d9d5cc"/>
<text x="146" y="118" font-weight="700">May write it: recipient keys</text>
<text x="146" y="138">the key the creator made the queue</text>
<text x="146" y="154">with, <tspan fill="#0b5cad">while the creator is an owner</tspan></text>
<text x="146" y="172" fill="#0b5cad">+ every current owner's member key,</text>
<text x="146" y="188" fill="#0b5cad">the creator's included</text>
<text x="146" y="206" fill="#0b5cad">replaced together with the data</text>
<text x="436" y="118" font-weight="700">Link data, signed and encrypted</text>
<text x="436" y="138">owner chain, relays, profile, count</text>
<text x="436" y="156" fill="#0b5cad">+ revoked owners, write counter,</text>
<text x="436" y="172" fill="#0b5cad">+ roster version and its author</text>
<text x="436" y="190" fill="#0b5cad">multi-owner: written only by LSWP</text>
<rect x="20" y="340" width="290" height="200" rx="10" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="165" y="364" text-anchor="middle" font-size="14" font-weight="700">Owner devices</text>
<text x="36" y="390" font-weight="700">Creator</text>
<text x="48" y="408">root key: signs the link data</text>
<text x="48" y="426">queue key: authorises LSWP, DEL</text>
<text x="48" y="444">member key: signs events</text>
<text x="36" y="470" font-weight="700">Any other owner</text>
<text x="48" y="488">member key: signs the link data,</text>
<text x="48" y="506">authorises LSWP, signs events</text>
<text x="36" y="530" fill="#5b636e">each keeps a copy of the roster</text>
<line x1="150" y1="316" x2="150" y2="224" stroke="#0b5cad" stroke-width="2" marker-end="url(#d1-arrow-new)"/>
<text x="142" y="260" text-anchor="end" fill="#0b5cad" font-weight="700">LSWP</text>
<text x="142" y="276" text-anchor="end" fill="#0b5cad">direct</text>
<line x1="200" y1="224" x2="200" y2="314" stroke="#0b5cad" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d1-arrow-new)"/>
<text x="208" y="260" fill="#0b5cad">read + hash</text>
<text x="208" y="276" fill="#0b5cad">(proxied)</text>
<line x1="420" y1="224" x2="420" y2="314" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d1-arrow)"/>
<text x="428" y="260">read at invitation</text>
<text x="428" y="276">and every 15 min</text>
<line x1="680" y1="224" x2="680" y2="314" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d1-arrow)"/>
<text x="688" y="260">read at channel</text>
<text x="688" y="276">open and join</text>
<rect x="415" y="340" width="230" height="200" rx="10" fill="#eaf6ec" stroke="#2e7d32" stroke-width="1.5"/>
<text x="530" y="364" text-anchor="middle" font-size="14" font-weight="700">Relays</text>
<text x="431" y="390">members and their roles</text>
<text x="431" y="410">serve the roster to members,</text>
<text x="431" y="426" fill="#b42318">and to owners on request</text>
<text x="431" y="446">forward owner-signed events</text>
<text x="431" y="466" fill="#b42318">profile gate: linkVersion</text>
<text x="431" y="486" fill="#b42318">member pages to a new owner</text>
<text x="431" y="526" fill="#5b636e">cannot forge an owner</text>
<rect x="20" y="316" width="200" height="132" rx="8" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="120" y="338" text-anchor="middle" font-size="14" font-weight="700">Owners, all equal</text>
<text x="34" y="362">write the link</text>
<text x="34" y="380">sign link data and events</text>
<text x="34" y="398">keep the full roster</text>
<rect x="740" y="340" width="200" height="200" rx="10" fill="#f2edfa" stroke="#6a4bb0" stroke-width="1.5"/>
<text x="840" y="364" text-anchor="middle" font-size="14" font-weight="700">Members</text>
<text x="754" y="390">owners and their keys,</text>
<text x="754" y="406">from chain and commits</text>
<text x="754" y="430">roster gate, as today</text>
<text x="754" y="454" fill="#b42318">profile gate: linkVersion</text>
<text x="754" y="526" fill="#5b636e">hear owners via relays</text>
<rect x="320" y="316" width="200" height="132" rx="8" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="420" y="338" text-anchor="middle" font-size="14" font-weight="700">Relays</text>
<text x="334" y="362">forward owner-signed events</text>
<text x="334" y="380">serve the roster</text>
<text x="334" y="398" fill="#0b5cad">serve member pages</text>
<text x="334" y="416" fill="#0b5cad">skip older profile updates</text>
<text x="334" y="436" fill="#5b636e">cannot forge an owner</text>
<line x1="230" y1="340" x2="230" y2="256" stroke="#b42318" stroke-width="2" marker-end="url(#d1-arrow-new)"/>
<text x="222" y="292" text-anchor="end" fill="#b42318" font-weight="700">LSWP</text>
<text x="222" y="308" text-anchor="end" fill="#b42318">direct, a session</text>
<text x="222" y="324" text-anchor="end" fill="#b42318">per link</text>
<line x1="290" y1="256" x2="290" y2="338" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d1-arrow)"/>
<text x="298" y="292">read + hash</text>
<text x="298" y="308">(proxied)</text>
<rect x="620" y="316" width="200" height="132" rx="8" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="720" y="338" text-anchor="middle" font-size="14" font-weight="700">Members</text>
<text x="634" y="362">apply roles in version order</text>
<text x="634" y="380" fill="#0b5cad">skip older profile updates</text>
<text x="634" y="398">learn owners from the link</text>
<text x="634" y="416" fill="#0b5cad">and from <tspan font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.owner.add</tspan></text>
<line x1="530" y1="256" x2="530" y2="338" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d1-arrow)"/>
<text x="538" y="292">read at invitation</text>
<text x="538" y="308">and every 15 min</text>
<text x="270" y="350" text-anchor="middle" font-size="11">events, roster</text>
<text x="270" y="364" text-anchor="middle" font-size="11" fill="#0b5cad">member pages</text>
<line x1="223" y1="378" x2="317" y2="378" stroke="#24292f" stroke-width="1.5" marker-start="url(#d1-arrow)" marker-end="url(#d1-arrow)"/>
<text x="570" y="350" text-anchor="middle" font-size="11">forwarded</text>
<text x="570" y="364" text-anchor="middle" font-size="11">events, roster</text>
<line x1="523" y1="378" x2="617" y2="378" stroke="#24292f" stroke-width="1.5" marker-end="url(#d1-arrow)"/>
<line x1="760" y1="256" x2="760" y2="338" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d1-arrow)"/>
<text x="768" y="292">read at channel open</text>
<text x="768" y="308">and join (proxied)</text>
<text x="362" y="392" text-anchor="middle" font-size="11">signed</text>
<text x="362" y="406" text-anchor="middle" font-size="11">events, roster</text>
<line x1="312" y1="416" x2="413" y2="416" stroke="#24292f" stroke-width="1.5" marker-end="url(#d1-arrow)"/>
<text x="362" y="452" text-anchor="middle" font-size="11">forwarded</text>
<text x="362" y="466" text-anchor="middle" font-size="11">events, roster,</text>
<text x="362" y="480" text-anchor="middle" font-size="11" fill="#b42318">member pages</text>
<line x1="413" y1="490" x2="312" y2="490" stroke="#24292f" stroke-width="1.5" marker-end="url(#d1-arrow)"/>
<text x="692" y="416" text-anchor="middle" font-size="11">forwarded</text>
<text x="692" y="430" text-anchor="middle" font-size="11">events, roster</text>
<line x1="647" y1="440" x2="738" y2="440" stroke="#24292f" stroke-width="1.5" marker-end="url(#d1-arrow)"/>
<text x="24" y="572" fill="#5b636e">Solid arrow: write or message · dashed arrow: read · red: new in this plan. Every owner writes with credentials built for each write.</text>
<text x="20" y="478" fill="#5b636e">Solid arrow: write or message · dashed arrow: read · blue: new in this plan</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="800" viewBox="0 0 960 800" role="img" aria-label="Two owners write the link at once. Both read data D1 with hash h1. O2 swaps first and the server stores D2. O1's swap still carries h1 and gets CONFLICT; O1 reads D2, re-applies its change and swaps with h2, which lands. Each owner announces its change only after its own swap lands.">
<svg xmlns="http://www.w3.org/2000/svg" width="840" height="514" viewBox="0 0 840 514" role="img" aria-label="Two owners change the link at the same time. Both read data D1 with hash h1. O2 swaps first with LSWP and hash h1, the server stores D2 and answers OK, and O2 announces its change. O1's swap still carries h1, so the server stores nothing and answers CONFLICT; O1 reads D2 with hash h2, re-applies its change and swaps again, which lands, and then announces its change.">
<defs>
<marker id="d2-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#24292f"/>
@@ -6,85 +6,66 @@
<marker id="d2-arrow-red" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#b42318"/>
</marker>
<marker id="d2-arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1a7f37"/>
</marker>
</defs>
<rect width="960" height="800" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="959" height="799" fill="none" stroke="#d9d5cc"/>
<rect width="840" height="514" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="839" height="513" fill="none" stroke="#d9d5cc"/>
<g font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="12" fill="#1f2328">
<text x="24" y="34" font-size="17" font-weight="700">2 · An owner write: read, change, swap; conflict and retry</text>
<text x="24" y="56" fill="#5b636e">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.</text>
<text x="20" y="30" font-size="16" font-weight="700">Two owners change the link at the same time</text>
<text x="20" y="50" fill="#5b636e">A swap lands only if the link data is still what the owner read; otherwise the owner reads again and re-applies.</text>
<line x1="230" y1="106" x2="230" y2="664" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="480" y1="106" x2="480" y2="664" stroke="#d7b48c" stroke-dasharray="4 4"/>
<line x1="730" y1="106" x2="730" y2="664" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<rect x="160" y="74" width="140" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="230" y="95" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="405" y="74" width="150" height="32" rx="7" fill="#fdf2e4" stroke="#b8661a" stroke-width="1.5"/>
<text x="480" y="95" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="660" y="74" width="140" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="730" y="95" text-anchor="middle" font-size="13" font-weight="700">Owner O2</text>
<line x1="180" y1="96" x2="180" y2="476" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="420" y1="96" x2="420" y2="476" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="660" y1="96" x2="660" y2="476" stroke="#c9ced6" stroke-dasharray="4 4"/>
<rect x="110" y="66" width="140" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="180" y="86" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="345" y="66" width="150" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="420" y="86" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="590" y="66" width="140" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="660" y="86" text-anchor="middle" font-size="13" font-weight="700">Owner O2</text>
<rect x="405" y="118" width="150" height="24" rx="5" fill="#fdf2e4" stroke="#b8661a"/>
<text x="480" y="134" text-anchor="middle">stores D1 · hash h1</text>
<rect x="335" y="108" width="170" height="22" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="420" y="123" text-anchor="middle">holds D1 · hash h1</text>
<line x1="230" y1="170" x2="478" y2="170" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="355" y="163" text-anchor="middle">read</text>
<line x1="730" y1="170" x2="482" y2="170" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="605" y="163" text-anchor="middle">read</text>
<line x1="480" y1="196" x2="232" y2="196" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="355" y="189" text-anchor="middle">D1, h1</text>
<line x1="480" y1="196" x2="728" y2="196" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="605" y="189" text-anchor="middle">D1, h1</text>
<line x1="180" y1="150" x2="418" y2="150" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="300" y="143" text-anchor="middle">read</text>
<line x1="660" y1="150" x2="422" y2="150" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="540" y="143" text-anchor="middle">read</text>
<line x1="420" y1="172" x2="182" y2="172" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="300" y="165" text-anchor="middle">D1, h1</text>
<line x1="420" y1="172" x2="658" y2="172" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="540" y="165" text-anchor="middle">D1, h1</text>
<text x="168" y="176" text-anchor="end" fill="#5b636e">applies change A</text>
<text x="672" y="176" fill="#5b636e">applies change B</text>
<rect x="36" y="210" width="186" height="26" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="129" y="227" text-anchor="middle">applies its change A to D1</text>
<rect x="738" y="210" width="186" height="26" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="831" y="227" text-anchor="middle">applies its change B to D1</text>
<line x1="660" y1="202" x2="422" y2="202" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="540" y="195" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP h1: D1 + B</text>
<rect x="325" y="212" width="190" height="22" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="420" y="227" text-anchor="middle">h1 matches: stores D2 · h2</text>
<line x1="420" y1="254" x2="658" y2="254" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="540" y="247" text-anchor="middle" font-weight="700">OK</text>
<text x="672" y="258" fill="#5b636e">announces B to relays</text>
<line x1="730" y1="268" x2="482" y2="268" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="605" y="261" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">LSWP h1, D1 + B</text>
<rect x="395" y="280" width="170" height="24" rx="5" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="480" y="296" text-anchor="middle" fill="#1a7f37">h1 matches: stores D2 · h2</text>
<line x1="480" y1="326" x2="728" y2="326" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow-green)"/>
<text x="605" y="319" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<rect x="738" y="340" width="186" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="831" y="357" text-anchor="middle">stores its state, then</text>
<text x="831" y="374" text-anchor="middle">announces B to relays</text>
<line x1="180" y1="284" x2="418" y2="284" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="300" y="277" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP h1: D1 + A</text>
<rect x="325" y="294" width="190" height="22" rx="5" fill="#fbe9e7" stroke="#b42318"/>
<text x="420" y="309" text-anchor="middle" fill="#b42318">h2 ≠ h1: nothing stored</text>
<line x1="420" y1="336" x2="182" y2="336" stroke="#b42318" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow-red)"/>
<text x="300" y="329" text-anchor="middle" fill="#b42318" font-weight="700">CONFLICT</text>
<line x1="230" y1="358" x2="478" y2="358" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="355" y="351" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">LSWP h1, D1 + A</text>
<rect x="390" y="370" width="180" height="24" rx="5" fill="#fbe9e7" stroke="#b42318"/>
<text x="480" y="386" text-anchor="middle" fill="#b42318">h2 ≠ h1: nothing stored</text>
<line x1="480" y1="416" x2="232" y2="416" stroke="#b42318" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow-red)"/>
<text x="355" y="409" text-anchor="middle" fill="#b42318" font-weight="700">CONFLICT</text>
<line x1="180" y1="364" x2="418" y2="364" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="300" y="357" text-anchor="middle">read again</text>
<line x1="420" y1="386" x2="182" y2="386" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="300" y="379" text-anchor="middle">D2, h2</text>
<text x="168" y="390" text-anchor="end" fill="#5b636e">re-applies A to D2</text>
<line x1="230" y1="452" x2="478" y2="452" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="355" y="445" text-anchor="middle">read again</text>
<line x1="480" y1="478" x2="232" y2="478" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="355" y="471" text-anchor="middle">D2, h2</text>
<rect x="24" y="492" width="198" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="123" y="509" text-anchor="middle">re-applies A to D2, or refuses</text>
<text x="123" y="526" text-anchor="middle">if B changed what A edits</text>
<line x1="180" y1="414" x2="418" y2="414" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="300" y="407" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP h2: D2 + A</text>
<rect x="325" y="424" width="190" height="22" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="420" y="439" text-anchor="middle">h2 matches: stores D3 · h3</text>
<line x1="420" y1="466" x2="182" y2="466" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow)"/>
<text x="300" y="459" text-anchor="middle" font-weight="700">OK</text>
<text x="168" y="470" text-anchor="end" fill="#5b636e">announces A to relays</text>
<line x1="230" y1="562" x2="478" y2="562" stroke="#24292f" stroke-width="1.5" marker-end="url(#d2-arrow)"/>
<text x="355" y="555" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">LSWP h2, D2 + A</text>
<rect x="395" y="574" width="170" height="24" rx="5" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="480" y="590" text-anchor="middle" fill="#1a7f37">h2 matches: stores D3 · h3</text>
<line x1="480" y1="620" x2="232" y2="620" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d2-arrow-green)"/>
<text x="355" y="613" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<rect x="36" y="630" width="186" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="129" y="647" text-anchor="middle">stores its state, then</text>
<text x="129" y="664" text-anchor="middle">announces A to relays</text>
<rect x="24" y="690" width="912" height="92" rx="8" fill="#f3f1ec" stroke="#d9d5cc"/>
<text x="40" y="712" font-weight="700">Rules of every multi-owner write [§4.4]</text>
<text x="40" y="732">CONFLICT: read and re-apply; after 5 attempts CLELinkBusy</text>
<text x="40" y="750">already applied (say, a lost response): no write</text>
<text x="40" y="768">AUTH: this owner was removed, so CLENotOwner</text>
<text x="490" y="732">every swap writes linkVersion = max(read, stored) + 1</text>
<text x="490" y="750">a swap that changes owners also replaces the key set</text>
<text x="490" y="768">background work swaps the same way, as agent commands</text>
<text x="20" y="502" fill="#5b636e">Solid arrow: request · dashed arrow: reply · red: refused</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -1,103 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="950" viewBox="0 0 960 950" role="img" aria-label="Role changes claim roster versions through the link. O2 holds the complete roster at version 7, which the link names, so it claims 8 with a swap, then sends the roster and its role event. O1 receives the role event at 8 while it holds 7 and asks the relays for the roster; a role change it tries before the roster arrives is refused with CLERosterSyncing. Once it holds 8, a retry reads the link and claims 9. Relays and members keep today's roster protocol.">
<svg xmlns="http://www.w3.org/2000/svg" width="840" height="600" viewBox="0 0 840 600" role="img" aria-label="Two owners change roles. The link names roster version 7 by O1, and both owners hold 7. O2 reads the link, holds the roster it names, claims version 8 with LSWP, and sends roster 8 with its role change to the relays, which forward them to members. O1 then tries a role change: its read names version 8, which it doesn't hold, so it is refused with catching up, try again, and asks the relays for the roster. Once it holds 8, a retry reads the link, claims version 9 and sends roster 9 with its role change.">
<defs>
<marker id="d3-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#24292f"/>
</marker>
<marker id="d3-arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1a7f37"/>
</marker>
</defs>
<rect width="960" height="950" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="959" height="949" fill="none" stroke="#d9d5cc"/>
<rect width="840" height="600" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="839" height="599" fill="none" stroke="#d9d5cc"/>
<g font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="12" fill="#1f2328">
<text x="24" y="34" font-size="17" font-weight="700">3 · Role changes claim roster versions through the link</text>
<text x="24" y="56" fill="#5b636e">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.</text>
<text x="20" y="30" font-size="16" font-weight="700">Two owners change roles: each claims the next roster version on the link</text>
<text x="20" y="50" fill="#5b636e">An owner claims version v + 1 only while it holds the complete roster at v, the version the link names.</text>
<line x1="120" y1="106" x2="120" y2="798" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="320" y1="106" x2="320" y2="798" stroke="#d7b48c" stroke-dasharray="4 4"/>
<line x1="520" y1="106" x2="520" y2="798" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="700" y1="106" x2="700" y2="798" stroke="#9cc9a3" stroke-dasharray="4 4"/>
<line x1="870" y1="106" x2="870" y2="798" stroke="#c3b5e3" stroke-dasharray="4 4"/>
<rect x="55" y="74" width="130" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="120" y="95" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="255" y="74" width="130" height="32" rx="7" fill="#fdf2e4" stroke="#b8661a" stroke-width="1.5"/>
<text x="320" y="95" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="455" y="74" width="130" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="520" y="95" text-anchor="middle" font-size="13" font-weight="700">Owner O2</text>
<rect x="640" y="74" width="120" height="32" rx="7" fill="#eaf6ec" stroke="#2e7d32" stroke-width="1.5"/>
<text x="700" y="95" text-anchor="middle" font-size="13" font-weight="700">Relays</text>
<rect x="810" y="74" width="120" height="32" rx="7" fill="#f2edfa" stroke="#6a4bb0" stroke-width="1.5"/>
<text x="870" y="95" text-anchor="middle" font-size="13" font-weight="700">Members</text>
<line x1="130" y1="96" x2="130" y2="572" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="350" y1="96" x2="350" y2="572" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="560" y1="96" x2="560" y2="572" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="750" y1="136" x2="750" y2="572" stroke="#c9ced6" stroke-dasharray="4 4"/>
<rect x="65" y="66" width="130" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="130" y="86" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="280" y="66" width="140" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="350" y="86" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="495" y="66" width="130" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="560" y="86" text-anchor="middle" font-size="13" font-weight="700">Owner O2</text>
<rect x="690" y="66" width="120" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="750" y="86" text-anchor="middle" font-size="13" font-weight="700">Relays</text>
<text x="750" y="112" text-anchor="middle" font-size="11" fill="#5b636e">forward to members,</text>
<text x="750" y="126" text-anchor="middle" font-size="11" fill="#5b636e">applied in version order</text>
<rect x="70" y="116" width="100" height="22" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="120" y="131" text-anchor="middle">holds 7</text>
<rect x="260" y="116" width="120" height="22" rx="5" fill="#fdf2e4" stroke="#b8661a"/>
<text x="320" y="131" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">roster {7, O1}</text>
<rect x="470" y="116" width="100" height="22" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="520" y="131" text-anchor="middle">holds 7</text>
<rect x="820" y="116" width="100" height="22" rx="5" fill="#ffffff" stroke="#6a4bb0"/>
<text x="870" y="131" text-anchor="middle">gate 7</text>
<text x="118" y="118" text-anchor="end" fill="#5b636e">holds 7</text>
<rect x="285" y="104" width="130" height="22" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="350" y="119" text-anchor="middle">roster 7 by O1</text>
<text x="572" y="118" fill="#5b636e">holds 7</text>
<line x1="520" y1="166" x2="322" y2="166" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="420" y="159" text-anchor="middle">read</text>
<line x1="320" y1="190" x2="518" y2="190" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="420" y="183" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">roster {7, O1}</text>
<text x="530" y="208" fill="#5b636e">= held 7</text>
<line x1="520" y1="226" x2="322" y2="226" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="420" y="219" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP roster {8, O2}</text>
<rect x="260" y="238" width="120" height="22" rx="5" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="320" y="253" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5" fill="#1a7f37">roster {8, O2}</text>
<line x1="320" y1="282" x2="518" y2="282" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow-green)"/>
<text x="420" y="275" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<line x1="520" y1="306" x2="698" y2="306" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="609" y="299" text-anchor="middle">roster 8 + role @8</text>
<line x1="700" y1="330" x2="868" y2="330" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="785" y="323" text-anchor="middle">role @8</text>
<rect x="800" y="342" width="140" height="22" rx="5" fill="#ffffff" stroke="#6a4bb0"/>
<text x="870" y="357" text-anchor="middle">8 ≥ gate: applied</text>
<line x1="700" y1="356" x2="122" y2="356" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="420" y="349" text-anchor="middle">role @8</text>
<rect x="20" y="368" width="200" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="120" y="385" text-anchor="middle">8 &gt; held 7: asks every</text>
<text x="120" y="402" text-anchor="middle">relay for the roster</text>
<line x1="120" y1="434" x2="698" y2="434" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="609" y="427" text-anchor="middle">roster request (holds 7)</text>
<line x1="560" y1="150" x2="352" y2="150" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="455" y="143" text-anchor="middle">read</text>
<line x1="350" y1="172" x2="558" y2="172" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="455" y="165" text-anchor="middle">roster 7 by O1</text>
<text x="572" y="192" fill="#5b636e">holds 7: may claim 8</text>
<line x1="560" y1="216" x2="352" y2="216" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="455" y="209" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP: roster 8 by O2</text>
<rect x="285" y="226" width="130" height="22" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="350" y="241" text-anchor="middle">roster 8 by O2</text>
<line x1="350" y1="268" x2="558" y2="268" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="455" y="261" text-anchor="middle" font-weight="700">OK</text>
<line x1="560" y1="294" x2="748" y2="294" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="655" y="287" text-anchor="middle" font-size="11">roster 8, role change at 8</text>
<line x1="120" y1="466" x2="318" y2="466" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="220" y="459" text-anchor="middle">read</text>
<text x="20" y="470" fill="#5b636e">user's change</text>
<line x1="320" y1="490" x2="122" y2="490" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="220" y="483" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">roster {8, O2}</text>
<rect x="20" y="502" width="200" height="42" rx="5" fill="#fbe9e7" stroke="#b42318"/>
<text x="120" y="519" text-anchor="middle" fill="#b42318">still behind: refused,</text>
<text x="120" y="536" text-anchor="middle" fill="#b42318" font-weight="700">CLERosterSyncing</text>
<line x1="130" y1="324" x2="348" y2="324" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="240" y="317" text-anchor="middle">read (user changes a role)</text>
<line x1="350" y1="346" x2="132" y2="346" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="240" y="339" text-anchor="middle">roster 8 by O2</text>
<rect x="20" y="356" width="220" height="40" rx="5" fill="#fbe9e7" stroke="#b42318"/>
<text x="130" y="372" text-anchor="middle" fill="#b42318">holds 7, link names 8: refused,</text>
<text x="130" y="388" text-anchor="middle" fill="#b42318" font-weight="700">“catching up, try again”</text>
<line x1="130" y1="420" x2="748" y2="420" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="240" y="413" text-anchor="middle">roster request (holds 7)</text>
<line x1="750" y1="444" x2="132" y2="444" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="240" y="437" text-anchor="middle">roster 8</text>
<text x="118" y="466" text-anchor="end" fill="#5b636e">holds 8</text>
<line x1="700" y1="568" x2="122" y2="568" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="609" y="561" text-anchor="middle">roster 8</text>
<rect x="70" y="580" width="100" height="22" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="120" y="595" text-anchor="middle">holds 8</text>
<line x1="130" y1="480" x2="348" y2="480" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="240" y="473" text-anchor="middle">read (user retries)</text>
<line x1="350" y1="500" x2="132" y2="500" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="240" y="493" text-anchor="middle">roster 8 by O2</text>
<line x1="130" y1="522" x2="348" y2="522" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="240" y="515" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP: roster 9 by O1</text>
<line x1="350" y1="542" x2="132" y2="542" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="240" y="535" text-anchor="middle" font-weight="700">OK</text>
<line x1="130" y1="564" x2="748" y2="564" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="455" y="557" text-anchor="middle" font-size="11">roster 9, role change at 9</text>
<line x1="120" y1="630" x2="318" y2="630" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="220" y="623" text-anchor="middle">read</text>
<text x="20" y="634" fill="#5b636e">user retries</text>
<line x1="320" y1="654" x2="122" y2="654" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow)"/>
<text x="220" y="647" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">roster {8, O2}</text>
<line x1="120" y1="682" x2="318" y2="682" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="220" y="675" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP roster {9, O1}</text>
<rect x="260" y="694" width="120" height="22" rx="5" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="320" y="709" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5" fill="#1a7f37">roster {9, O1}</text>
<line x1="320" y1="738" x2="122" y2="738" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d3-arrow-green)"/>
<text x="220" y="731" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<line x1="120" y1="764" x2="698" y2="764" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="609" y="757" text-anchor="middle">roster 9 + role @9</text>
<line x1="700" y1="788" x2="868" y2="788" stroke="#24292f" stroke-width="1.5" marker-end="url(#d3-arrow)"/>
<text x="785" y="781" text-anchor="middle">role @9</text>
<rect x="24" y="818" width="912" height="116" rx="8" fill="#f3f1ec" stroke="#d9d5cc"/>
<text x="40" y="840" font-weight="700">Why it converges [§4.10]</text>
<text x="40" y="860">· Each version has one author who held every earlier one: what relays and members already assume (gate; a gap → fetch from a relay).</text>
<text x="40" y="878">· The roster lists members, moderators and admins; owners come from the link chain, outside it.</text>
<text x="40" y="896">· 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).</text>
<text x="40" y="914">· An owner whose roster didn't reach a relay re-signs it at the next version in a background sync.</text>
<text x="20" y="590" fill="#5b636e">Solid arrow: request or message · dashed arrow: reply · red: refused</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -1,113 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="1216" viewBox="0 0 960 1216" role="img" aria-label="Adding an owner end to end. O1 checks the member's verified code and the relay and link server versions, then invites M through M's support chat. M accepts. O1's background link sync reads the link, adds M's chain entry and key with one swap, reads again and then sends the commit, x.grp.owner.add, which relays forward to everyone. M becomes an owner and pulls the member list from a relay page by page.">
<svg xmlns="http://www.w3.org/2000/svg" width="840" height="576" viewBox="0 0 840 576" role="img" aria-label="Adding an owner. Owner O1 checks that member M's security code is verified and that the relays and the link server are upgraded, then sends x.grp.owner.inv, which relays deliver to M's support chat. M is asked whether to become an owner, told that everyone will see its profile and the link server its IP address, and accepts with x.grp.owner.acpt. In a background link sync O1 reads the link, adds M's chain entry and key with one LSWP, and after a read shows M's entry sends x.grp.owner.add, which relays apply and forward to everyone, M included. M then pulls the member list from a relay page by page.">
<defs>
<marker id="d4-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#24292f"/>
</marker>
<marker id="d4-arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1a7f37"/>
</marker>
</defs>
<rect width="960" height="1216" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="959" height="1215" fill="none" stroke="#d9d5cc"/>
<rect width="840" height="576" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="839" height="575" fill="none" stroke="#d9d5cc"/>
<g font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="12" fill="#1f2328">
<text x="24" y="34" font-size="17" font-weight="700">4 · Adding an owner, end to end</text>
<text x="24" y="56" fill="#5b636e">Consent in the member's support chat, one link write in a background sync, then the commit to everyone [§4.6, §4.7].</text>
<text x="20" y="30" font-size="16" font-weight="700">Adding an owner: consent, one link write, then the commit</text>
<text x="20" y="50" fill="#5b636e">Owner O1 promotes member M. Every message between owners and M goes through the relays.</text>
<line x1="100" y1="106" x2="100" y2="1124" stroke="#d7b48c" stroke-dasharray="4 4"/>
<line x1="290" y1="106" x2="290" y2="1124" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="480" y1="106" x2="480" y2="1124" stroke="#9cc9a3" stroke-dasharray="4 4"/>
<line x1="670" y1="106" x2="670" y2="1124" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="860" y1="106" x2="860" y2="1124" stroke="#c3b5e3" stroke-dasharray="4 4"/>
<rect x="40" y="74" width="120" height="32" rx="7" fill="#fdf2e4" stroke="#b8661a" stroke-width="1.5"/>
<text x="100" y="95" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="225" y="74" width="130" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="290" y="95" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="420" y="74" width="120" height="32" rx="7" fill="#eaf6ec" stroke="#2e7d32" stroke-width="1.5"/>
<text x="480" y="95" text-anchor="middle" font-size="13" font-weight="700">Relays</text>
<rect x="605" y="74" width="130" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="670" y="95" text-anchor="middle" font-size="13" font-weight="700">Member M</text>
<rect x="790" y="74" width="140" height="32" rx="7" fill="#f2edfa" stroke="#6a4bb0" stroke-width="1.5"/>
<text x="860" y="95" text-anchor="middle" font-size="13" font-weight="700">Everyone else</text>
<line x1="200" y1="96" x2="200" y2="542" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="380" y1="96" x2="380" y2="542" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="560" y1="96" x2="560" y2="542" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="720" y1="96" x2="720" y2="542" stroke="#c9ced6" stroke-dasharray="4 4"/>
<rect x="135" y="66" width="130" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="200" y="86" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="315" y="66" width="130" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="380" y="86" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="500" y="66" width="120" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="560" y="86" text-anchor="middle" font-size="13" font-weight="700">Relays</text>
<rect x="655" y="66" width="130" height="30" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="720" y="86" text-anchor="middle" font-size="13" font-weight="700">Member M</text>
<rect x="20" y="120" width="920" height="20" fill="#ece8df"/>
<text x="28" y="134" font-weight="700" fill="#5b636e">1 · Invite: the promoter checks, then invites</text>
<rect x="160" y="152" width="260" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="169" text-anchor="middle">checks: M's code verified, relays ≥ v23,</text>
<text x="290" y="186" text-anchor="middle">link server ≥ v24, chain under 50 entries</text>
<line x1="290" y1="216" x2="478" y2="216" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="385" y="209" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.owner.inv {memberId}</text>
<line x1="480" y1="242" x2="668" y2="242" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="575" y="235" text-anchor="middle">to M's support chat</text>
<rect x="550" y="254" width="240" height="58" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="670" y="271" text-anchor="middle" font-weight="700">“Become an owner?”</text>
<text x="670" y="288" text-anchor="middle">its profile goes to every subscriber;</text>
<text x="670" y="305" text-anchor="middle">a direct connection reveals its IP</text>
<text x="20" y="124" font-weight="700" fill="#5b636e">Invite</text>
<rect x="265" y="106" width="230" height="36" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="380" y="120" text-anchor="middle" font-size="11.5">checks: M's code verified,</text>
<text x="380" y="135" text-anchor="middle" font-size="11.5">relays and link server upgraded</text>
<line x1="380" y1="164" x2="558" y2="164" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="470" y="157" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.owner.inv</text>
<line x1="560" y1="164" x2="718" y2="164" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="640" y="157" text-anchor="middle" font-size="11">to M's support chat</text>
<rect x="606" y="174" width="226" height="50" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="719" y="189" text-anchor="middle" font-size="11.5" font-weight="700">“Become an owner?”</text>
<text x="719" y="203" text-anchor="middle" font-size="11.5">everyone will see its profile;</text>
<text x="719" y="217" text-anchor="middle" font-size="11.5">the link server will see its IP</text>
<rect x="20" y="326" width="920" height="20" fill="#ece8df"/>
<text x="28" y="340" font-weight="700" fill="#5b636e">2 · Accept: the member consents</text>
<line x1="670" y1="372" x2="482" y2="372" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="575" y="365" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.owner.acpt {invId}</text>
<line x1="480" y1="398" x2="292" y2="398" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="385" y="391" text-anchor="middle">from M's support chat</text>
<rect x="175" y="410" width="230" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="427" text-anchor="middle">owner_inv_accepted = 1,</text>
<text x="290" y="444" text-anchor="middle">enqueues a link sync</text>
<text x="20" y="250" font-weight="700" fill="#5b636e">Accept</text>
<line x1="720" y1="246" x2="562" y2="246" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="640" y="239" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.owner.acpt</text>
<line x1="560" y1="246" x2="382" y2="246" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="470" y="239" text-anchor="middle" font-size="11">from M's support chat</text>
<rect x="20" y="468" width="920" height="20" fill="#ece8df"/>
<text x="28" y="482" font-weight="700" fill="#5b636e">3 · Link sync: agent async commands queued on O1's connection to a relay (its mailbox); each step re-derived from the latest read</text>
<line x1="290" y1="514" x2="102" y2="514" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="195" y="507" text-anchor="middle">async read</text>
<line x1="100" y1="540" x2="288" y2="540" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow)"/>
<text x="195" y="533" text-anchor="middle">LDATA + hash</text>
<rect x="160" y="552" width="260" height="58" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="569" text-anchor="middle">continuation finds M accepted:</text>
<text x="290" y="586" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">CLCAddOwner M</text>
<text x="290" y="603" text-anchor="middle">entry signed by O1, M's key added</text>
<line x1="290" y1="634" x2="102" y2="634" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="195" y="627" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">async LSWP</text>
<rect x="20" y="646" width="160" height="42" rx="5" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="100" y="663" text-anchor="middle" fill="#1a7f37">chain + M's entry,</text>
<text x="100" y="680" text-anchor="middle" fill="#1a7f37">key set + M's key</text>
<line x1="100" y1="710" x2="288" y2="710" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow-green)"/>
<text x="195" y="703" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<line x1="290" y1="736" x2="102" y2="736" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="195" y="729" text-anchor="middle">async read</text>
<line x1="100" y1="762" x2="288" y2="762" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow)"/>
<text x="195" y="755" text-anchor="middle">shows M's entry</text>
<rect x="160" y="774" width="260" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="791" text-anchor="middle">callback: clears the invitation,</text>
<text x="290" y="808" text-anchor="middle">takes M as owner</text>
<text x="20" y="278" font-weight="700" fill="#5b636e">Link sync</text>
<text x="20" y="292" font-size="11" fill="#5b636e">in the background</text>
<line x1="380" y1="274" x2="202" y2="274" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="290" y="267" text-anchor="middle">read</text>
<line x1="200" y1="294" x2="378" y2="294" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow)"/>
<text x="290" y="287" text-anchor="middle">data, hash</text>
<line x1="380" y1="318" x2="202" y2="318" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="290" y="311" text-anchor="middle"><tspan font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP</tspan>: add M and its key</text>
<rect x="88" y="328" width="224" height="36" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="200" y="342" text-anchor="middle" font-size="11.5">chain + M's entry, signed by O1;</text>
<text x="200" y="357" text-anchor="middle" font-size="11.5">key set + M's key</text>
<line x1="200" y1="384" x2="378" y2="384" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow)"/>
<text x="290" y="377" text-anchor="middle" font-weight="700">OK</text>
<line x1="380" y1="406" x2="202" y2="406" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="290" y="399" text-anchor="middle">read</text>
<line x1="200" y1="426" x2="378" y2="426" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow)"/>
<text x="290" y="419" text-anchor="middle">shows M's entry</text>
<rect x="20" y="832" width="920" height="20" fill="#ece8df"/>
<text x="28" y="846" font-weight="700" fill="#5b636e">4 · Commit: everyone takes M as owner</text>
<line x1="290" y1="880" x2="478" y2="880" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="385" y="873" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">x.grp.owner.add</text>
<text x="385" y="897" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10.5">{memberId, memberKey,</text>
<text x="385" y="910" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10.5">profile}</text>
<line x1="480" y1="916" x2="858" y2="916" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="670" y="909" text-anchor="middle">forwarded to everyone, M included</text>
<rect x="400" y="928" width="160" height="42" rx="5" fill="#ffffff" stroke="#2e7d32"/>
<text x="480" y="945" text-anchor="middle">apply it: M owner;</text>
<text x="480" y="962" text-anchor="middle">send M capabilities</text>
<rect x="585" y="928" width="170" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="670" y="945" text-anchor="middle">M: owner; starts the</text>
<text x="670" y="962" text-anchor="middle">member catch-up</text>
<rect x="770" y="928" width="180" height="42" rx="5" fill="#ffffff" stroke="#6a4bb0"/>
<text x="860" y="945" text-anchor="middle">from a current owner,</text>
<text x="860" y="962" text-anchor="middle">M not departed: owner</text>
<text x="20" y="456" font-weight="700" fill="#5b636e">Commit</text>
<line x1="380" y1="452" x2="558" y2="452" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="470" y="445" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.owner.add</text>
<line x1="560" y1="452" x2="718" y2="452" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="640" y="445" text-anchor="middle" font-size="11">to everyone, M included</text>
<rect x="480" y="462" width="160" height="22" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="560" y="477" text-anchor="middle" font-size="11.5">apply it: M is an owner</text>
<rect x="655" y="462" width="130" height="22" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="720" y="477" text-anchor="middle" font-size="11.5">is now an owner</text>
<rect x="20" y="986" width="920" height="20" fill="#ece8df"/>
<text x="28" y="1000" font-weight="700" fill="#5b636e">5 · Catch-up: the new owner pulls the member list from one relay</text>
<line x1="670" y1="1032" x2="482" y2="1032" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="575" y="1025" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.members.req {cursor}</text>
<line x1="480" y1="1058" x2="668" y2="1058" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow)"/>
<text x="575" y="1051" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.members.page</text>
<rect x="555" y="1070" width="230" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="670" y="1087" text-anchor="middle">next page until the last; no page</text>
<text x="670" y="1104" text-anchor="middle">for a minute: ask another relay</text>
<text x="20" y="512" font-weight="700" fill="#5b636e">Catch-up</text>
<line x1="720" y1="508" x2="562" y2="508" stroke="#24292f" stroke-width="1.5" marker-end="url(#d4-arrow)"/>
<text x="640" y="501" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.members.req</text>
<line x1="560" y1="530" x2="718" y2="530" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d4-arrow)"/>
<text x="640" y="523" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.members.page</text>
<text x="732" y="534" font-size="11" fill="#5b636e">until the last</text>
<rect x="24" y="1138" width="912" height="62" rx="8" fill="#f3f1ec" stroke="#d9d5cc"/>
<text x="40" y="1160"><tspan font-weight="700">Transferring ownership</tspan> is this promotion, then the creator leaves (diagram 5): the channel needs no creator.</text>
<text x="40" y="1180">The creator's first swap also writes creatorQueueKey, linkRcvId and the roster commitment; from then on the queue has several keys.</text>
<text x="20" y="564" fill="#5b636e">Solid arrow: request or message · dashed arrow: reply</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="1290" viewBox="0 0 960 1290" role="img" aria-label="Removing an owner and leaving. To remove owner X, O1 swaps the link to revoke X and drop X's key, then sends x.grp.mem.del, which relays deliver to X first and then to everyone; X erases its keys and any later write by X gets AUTH. To leave, an owner is refused if no other current owner remains; if its own last roster claim was not stored or delivered, it first re-signs it with its own link write and waits for its agent to report the roster sent; then it swaps to revoke itself, sends x.grp.leave and erases its keys.">
<svg xmlns="http://www.w3.org/2000/svg" width="840" height="560" viewBox="0 0 840 560" role="img" aria-label="Removing an owner, and leaving. To remove owner X, owner O1 reads the link and swaps it with LSWP to revoke X, which drops X's key from the key set; then it sends x.grp.mem.del, which relays deliver first to X and then to everyone else. Relays delete their connection to X, X applies the removal and erases its keys, others hold X as departed, and the link server answers AUTH to any later LSWP or DEL by X. An owner that leaves is refused if no other current owner remains; if its own last roster was not stored or sent, it re-signs it with one more LSWP and waits up to 30 seconds for it to be sent, or the leave is refused; then it swaps to revoke itself, sends x.grp.leave and erases its keys.">
<defs>
<marker id="d5-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#24292f"/>
@@ -6,122 +6,73 @@
<marker id="d5-arrow-red" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#b42318"/>
</marker>
<marker id="d5-arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1a7f37"/>
</marker>
</defs>
<rect width="960" height="1290" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="959" height="1289" fill="none" stroke="#d9d5cc"/>
<rect width="840" height="560" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="839" height="559" fill="none" stroke="#d9d5cc"/>
<g font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="12" fill="#1f2328">
<text x="24" y="34" font-size="17" font-weight="700">5 · Removing an owner, and leaving</text>
<text x="24" y="56" fill="#5b636e">Both land on the link first: the swap drops the owner's key at once, and the announcement follows.</text>
<text x="20" y="30" font-size="16" font-weight="700">Removing an owner, and leaving</text>
<text x="20" y="50" fill="#5b636e">Both land on the link first: the swap drops the owner's key at once, then the announcement follows.</text>
<rect x="20" y="72" width="920" height="22" fill="#ece8df"/>
<text x="28" y="88" font-weight="700" fill="#5b636e">A · An owner removes owner X [§4.8]</text>
<line x1="100" y1="136" x2="100" y2="516" stroke="#d7b48c" stroke-dasharray="4 4"/>
<line x1="290" y1="136" x2="290" y2="516" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="480" y1="136" x2="480" y2="516" stroke="#9cc9a3" stroke-dasharray="4 4"/>
<line x1="670" y1="136" x2="670" y2="516" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="860" y1="136" x2="860" y2="516" stroke="#c3b5e3" stroke-dasharray="4 4"/>
<rect x="40" y="104" width="120" height="32" rx="7" fill="#fdf2e4" stroke="#b8661a" stroke-width="1.5"/>
<text x="100" y="125" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="225" y="104" width="130" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="290" y="125" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="420" y="104" width="120" height="32" rx="7" fill="#eaf6ec" stroke="#2e7d32" stroke-width="1.5"/>
<text x="480" y="125" text-anchor="middle" font-size="13" font-weight="700">Relays</text>
<rect x="605" y="104" width="130" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="670" y="125" text-anchor="middle" font-size="13" font-weight="700">Owner X</text>
<rect x="790" y="104" width="140" height="32" rx="7" fill="#f2edfa" stroke="#6a4bb0" stroke-width="1.5"/>
<text x="860" y="125" text-anchor="middle" font-size="13" font-weight="700">Everyone else</text>
<text x="20" y="78" font-weight="700" fill="#5b636e">An owner removes owner X</text>
<line x1="150" y1="114" x2="150" y2="398" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="310" y1="114" x2="310" y2="398" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="470" y1="114" x2="470" y2="398" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="620" y1="114" x2="620" y2="398" stroke="#c9ced6" stroke-dasharray="4 4"/>
<line x1="760" y1="114" x2="760" y2="398" stroke="#c9ced6" stroke-dasharray="4 4"/>
<rect x="90" y="86" width="120" height="28" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="150" y="105" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="250" y="86" width="120" height="28" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="310" y="105" text-anchor="middle" font-size="13" font-weight="700">Owner O1</text>
<rect x="410" y="86" width="120" height="28" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="470" y="105" text-anchor="middle" font-size="13" font-weight="700">Relays</text>
<rect x="560" y="86" width="120" height="28" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="620" y="105" text-anchor="middle" font-size="13" font-weight="700">Owner X</text>
<rect x="700" y="86" width="120" height="28" rx="6" fill="#ffffff" stroke="#8c959f" stroke-width="1.5"/>
<text x="760" y="105" text-anchor="middle" font-size="13" font-weight="700">Everyone else</text>
<line x1="290" y1="162" x2="102" y2="162" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="195" y="155" text-anchor="middle">read</text>
<line x1="100" y1="186" x2="288" y2="186" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow)"/>
<text x="195" y="179" text-anchor="middle">data, hash</text>
<rect x="165" y="198" width="250" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="215" text-anchor="middle"><tspan font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">CLCRevokeOwner X</tspan>; refused if</text>
<text x="290" y="232" text-anchor="middle">no other owner would remain</text>
<line x1="290" y1="266" x2="102" y2="266" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="195" y="259" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP</text>
<rect x="20" y="278" width="160" height="42" rx="5" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="100" y="295" text-anchor="middle" fill="#1a7f37">X in revokedOwners,</text>
<text x="100" y="312" text-anchor="middle" fill="#1a7f37">X's key out of the set</text>
<line x1="100" y1="342" x2="288" y2="342" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow-green)"/>
<text x="195" y="335" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<line x1="290" y1="368" x2="478" y2="368" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="385" y="361" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">x.grp.mem.del X</text>
<line x1="480" y1="394" x2="668" y2="394" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="575" y="387" text-anchor="middle">first to X</text>
<line x1="480" y1="420" x2="858" y2="420" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="765" y="413" text-anchor="middle">then to everyone</text>
<rect x="395" y="432" width="170" height="42" rx="5" fill="#ffffff" stroke="#2e7d32"/>
<text x="480" y="449" text-anchor="middle">delete their</text>
<text x="480" y="466" text-anchor="middle">connection to X</text>
<rect x="585" y="432" width="170" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="670" y="449" text-anchor="middle">applies it on receipt,</text>
<text x="670" y="466" text-anchor="middle">erases its keys</text>
<rect x="775" y="432" width="170" height="42" rx="5" fill="#ffffff" stroke="#6a4bb0"/>
<text x="860" y="449" text-anchor="middle">X departed,</text>
<text x="860" y="466" text-anchor="middle">as today</text>
<line x1="670" y1="500" x2="102" y2="500" stroke="#b42318" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow-red)"/>
<text x="386" y="493" text-anchor="middle" fill="#b42318">any later LSWP or DEL by X: AUTH (its key is gone)</text>
<text x="28" y="544" fill="#5b636e">Two owners removing each other: the first swap drops the other's key; the second gets AUTH, or CONFLICT and then reads itself revoked.</text>
<text x="28" y="562" fill="#5b636e">One owner remains. A removed owner's rows are kept.</text>
<line x1="310" y1="138" x2="152" y2="138" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="230" y="131" text-anchor="middle">read</text>
<line x1="150" y1="158" x2="308" y2="158" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow)"/>
<text x="230" y="151" text-anchor="middle">data, hash</text>
<line x1="310" y1="182" x2="152" y2="182" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="230" y="175" text-anchor="middle"><tspan font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">LSWP</tspan>: revoke X</text>
<rect x="50" y="192" width="200" height="36" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="150" y="206" text-anchor="middle" font-size="11.5">X revoked;</text>
<text x="150" y="221" text-anchor="middle" font-size="11.5">X's key out of the key set</text>
<line x1="150" y1="248" x2="308" y2="248" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow)"/>
<text x="230" y="241" text-anchor="middle" font-weight="700">OK</text>
<line x1="310" y1="272" x2="468" y2="272" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="390" y="265" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.mem.del X</text>
<line x1="470" y1="294" x2="618" y2="294" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="545" y="287" text-anchor="middle" font-size="11">first to X</text>
<line x1="470" y1="316" x2="758" y2="316" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="690" y="309" text-anchor="middle" font-size="11">then to everyone else</text>
<rect x="398" y="328" width="144" height="34" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="470" y="342" text-anchor="middle" font-size="11.5">delete their</text>
<text x="470" y="356" text-anchor="middle" font-size="11.5">connection to X</text>
<rect x="548" y="328" width="144" height="34" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="620" y="342" text-anchor="middle" font-size="11.5">applies it,</text>
<text x="620" y="356" text-anchor="middle" font-size="11.5">erases its keys</text>
<rect x="704" y="328" width="112" height="34" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="760" y="349" text-anchor="middle" font-size="11.5">X departed</text>
<line x1="150" y1="388" x2="618" y2="388" stroke="#b42318" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow-red)"/>
<text x="390" y="381" text-anchor="middle" fill="#b42318">AUTH to any later LSWP or DEL by X</text>
<rect x="20" y="584" width="920" height="22" fill="#ece8df"/>
<text x="28" y="600" font-weight="700" fill="#5b636e">B · An owner leaves, the creator included [§4.9]</text>
<line x1="100" y1="648" x2="100" y2="1176" stroke="#d7b48c" stroke-dasharray="4 4"/>
<line x1="290" y1="648" x2="290" y2="1176" stroke="#b9c4d0" stroke-dasharray="4 4"/>
<line x1="480" y1="648" x2="480" y2="1176" stroke="#9cc9a3" stroke-dasharray="4 4"/>
<line x1="670" y1="648" x2="670" y2="1176" stroke="#c3b5e3" stroke-dasharray="4 4"/>
<rect x="40" y="616" width="120" height="32" rx="7" fill="#fdf2e4" stroke="#b8661a" stroke-width="1.5"/>
<text x="100" y="637" text-anchor="middle" font-size="13" font-weight="700">Link server</text>
<rect x="225" y="616" width="130" height="32" rx="7" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="290" y="637" text-anchor="middle" font-size="13" font-weight="700">Owner L</text>
<rect x="420" y="616" width="120" height="32" rx="7" fill="#eaf6ec" stroke="#2e7d32" stroke-width="1.5"/>
<text x="480" y="637" text-anchor="middle" font-size="13" font-weight="700">Relays</text>
<rect x="600" y="616" width="140" height="32" rx="7" fill="#f2edfa" stroke="#6a4bb0" stroke-width="1.5"/>
<text x="670" y="637" text-anchor="middle" font-size="13" font-weight="700">Everyone else</text>
<text x="20" y="430" font-weight="700" fill="#5b636e">An owner leaves, the creator included</text>
<rect x="20" y="444" width="200" height="70" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="120" y="475" text-anchor="middle" font-size="11.5">refused if no other</text>
<text x="120" y="490" text-anchor="middle" font-size="11.5">current owner remains</text>
<line x1="222" y1="479" x2="262" y2="479" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<rect x="264" y="444" width="270" height="70" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="399" y="461" text-anchor="middle" font-size="11.5">if its own last roster isn't stored</text>
<text x="399" y="476" text-anchor="middle" font-size="11.5">or sent: re-sign it with one more LSWP</text>
<text x="399" y="491" text-anchor="middle" font-size="11.5">and wait up to 30 s for it to be sent;</text>
<text x="399" y="506" text-anchor="middle" font-size="11.5">if it isn't, the leave is refused</text>
<line x1="536" y1="479" x2="572" y2="479" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<rect x="574" y="444" width="246" height="70" rx="5" fill="#ffffff" stroke="#8c959f"/>
<text x="697" y="475" text-anchor="middle" font-size="11.5"><tspan font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">LSWP</tspan>: revoke itself, drop its key;</text>
<text x="697" y="490" text-anchor="middle" font-size="11.5">then <tspan font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">x.grp.leave</tspan>, erase its keys</text>
<line x1="290" y1="674" x2="102" y2="674" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="195" y="667" text-anchor="middle">read</text>
<line x1="100" y1="698" x2="288" y2="698" stroke="#24292f" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow)"/>
<text x="195" y="691" text-anchor="middle">data, hash</text>
<rect x="160" y="710" width="260" height="26" rx="5" fill="#fbe9e7" stroke="#b42318"/>
<text x="290" y="727" text-anchor="middle" fill="#b42318">no other current owner: CLELastOwner</text>
<rect x="160" y="748" width="260" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="765" text-anchor="middle">its own last roster claim not stored</text>
<text x="290" y="782" text-anchor="middle">or not delivered: re-sign it first</text>
<line x1="290" y1="816" x2="102" y2="816" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="195" y="809" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">read + LSWP roster {v+1, L}</text>
<line x1="100" y1="842" x2="288" y2="842" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow-green)"/>
<text x="195" y="835" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<line x1="290" y1="868" x2="478" y2="868" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="385" y="861" text-anchor="middle">roster v+1 + a role event</text>
<rect x="136" y="880" width="308" height="42" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="897" text-anchor="middle">waits ≤ 30 s for its agent's SENT of the</text>
<text x="290" y="914" text-anchor="middle">last chunk; else CLERelaysUnreachable</text>
<line x1="290" y1="948" x2="102" y2="948" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="195" y="941" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">read + LSWP: revoke L</text>
<rect x="20" y="960" width="160" height="42" rx="5" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="100" y="977" text-anchor="middle" fill="#1a7f37">L revoked, key out;</text>
<text x="100" y="994" text-anchor="middle" fill="#1a7f37">entry kept as a signer</text>
<line x1="100" y1="1024" x2="288" y2="1024" stroke="#1a7f37" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#d5-arrow-green)"/>
<text x="195" y="1017" text-anchor="middle" fill="#1a7f37" font-weight="700">OK</text>
<line x1="290" y1="1050" x2="478" y2="1050" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="385" y="1043" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5">x.grp.leave</text>
<line x1="480" y1="1076" x2="668" y2="1076" stroke="#24292f" stroke-width="1.5" marker-end="url(#d5-arrow)"/>
<text x="575" y="1069" text-anchor="middle">forwarded</text>
<rect x="160" y="1088" width="260" height="58" rx="5" fill="#ffffff" stroke="#2f6db5"/>
<text x="290" y="1105" text-anchor="middle">erases its member key; the creator</text>
<text x="290" y="1122" text-anchor="middle">keeps only the root public key and</text>
<text x="290" y="1139" text-anchor="middle">deletes the link locally (DEL: AUTH)</text>
<rect x="600" y="1088" width="140" height="26" rx="5" fill="#ffffff" stroke="#6a4bb0"/>
<text x="670" y="1105" text-anchor="middle">L departed</text>
<rect x="24" y="1194" width="912" height="80" rx="8" fill="#f3f1ec" stroke="#d9d5cc"/>
<text x="40" y="1216">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.</text>
<text x="40" y="1236">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</text>
<text x="40" y="1254">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].</text>
<text x="20" y="546" fill="#5b636e">Solid arrow: request, message or next step · dashed arrow: reply · red: refused</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -1,81 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="740" viewBox="0 0 960 740" role="img" aria-label="The accepted race between a promotion and a removal of the same member. O1's swap puts M in the chain and O1 sends the commit, while a remover who has not seen the promotion sends a removal of M. A party that applies the removal first refuses the commit but takes M as an owner at its next link read. A party that applies the commit first refuses an admin's later removal, and applies an owner's, taking M back at its next read. If M's own device applies the removal, it leaves for good, and M stays listed as an owner without a device until an owner removes it.">
<defs>
<marker id="d6-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#24292f"/>
</marker>
<marker id="d6-arrow-red" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#b42318"/>
</marker>
<marker id="d6-arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1a7f37"/>
</marker>
</defs>
<rect width="960" height="740" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="959" height="739" fill="none" stroke="#d9d5cc"/>
<g font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="12" fill="#1f2328">
<text x="24" y="34" font-size="17" font-weight="700">6 · The accepted race: a promotion and a removal of the same member</text>
<text x="24" y="56" fill="#5b636e">The link decides who is listed as an owner. Which message a party applies first decides its state until its next read,</text>
<text x="24" y="74" fill="#5b636e">except that a member device that left stays gone, and relays that applied the removal first have no connection to M [§4.8].</text>
<rect x="24" y="102" width="230" height="58" rx="8" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="139" y="124" text-anchor="middle" font-weight="700">Owner O1 promotes M</text>
<text x="139" y="144" text-anchor="middle">its swap lands first</text>
<rect x="24" y="232" width="230" height="58" rx="8" fill="#e9f1fb" stroke="#2f6db5" stroke-width="1.5"/>
<text x="139" y="254" text-anchor="middle" font-weight="700">A remover, admin or owner</text>
<text x="139" y="274" text-anchor="middle">hasn't seen the promotion</text>
<rect x="330" y="102" width="230" height="58" rx="8" fill="#fdf2e4" stroke="#b8661a" stroke-width="1.5"/>
<text x="445" y="124" text-anchor="middle" font-weight="700">Link server</text>
<text x="445" y="144" text-anchor="middle" fill="#1a7f37">M in the chain, not revoked</text>
<rect x="660" y="164" width="276" height="76" rx="8" fill="#f2edfa" stroke="#6a4bb0" stroke-width="1.5"/>
<text x="798" y="188" text-anchor="middle" font-weight="700">Every party</text>
<text x="798" y="208" text-anchor="middle">relays, members, owners, M's device:</text>
<text x="798" y="226" text-anchor="middle">applies whichever arrives first</text>
<line x1="254" y1="131" x2="328" y2="131" stroke="#24292f" stroke-width="1.5" marker-end="url(#d6-arrow)"/>
<text x="291" y="124" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11">LSWP</text>
<path d="M 254 152 C 420 178, 520 190, 658 192" fill="none" stroke="#1a7f37" stroke-width="1.5" marker-end="url(#d6-arrow-green)"/>
<text x="470" y="200" text-anchor="middle" fill="#1a7f37">commit: x.grp.owner.add M</text>
<path d="M 254 261 C 420 248, 520 224, 658 216" fill="none" stroke="#b42318" stroke-width="1.5" marker-end="url(#d6-arrow-red)"/>
<text x="470" y="262" text-anchor="middle" fill="#b42318">removal: x.grp.mem.del M</text>
<rect x="214" y="318" width="356" height="32" rx="6" fill="#fbe9e7" stroke="#b42318"/>
<text x="392" y="339" text-anchor="middle" font-weight="700" fill="#b42318">Applied the removal first</text>
<rect x="580" y="318" width="356" height="32" rx="6" fill="#e6f4ea" stroke="#1a7f37"/>
<text x="758" y="339" text-anchor="middle" font-weight="700" fill="#1a7f37">Applied the commit first</text>
<rect x="24" y="360" width="180" height="120" rx="6" fill="#f3f1ec" stroke="#d9d5cc"/>
<text x="36" y="386" font-weight="700">Relays, members,</text>
<text x="36" y="404" font-weight="700">owners, the</text>
<text x="36" y="422" font-weight="700">remover's device</text>
<text x="36" y="440" fill="#5b636e">(always on the left)</text>
<rect x="214" y="360" width="356" height="120" rx="6" fill="#ffffff" stroke="#d9d5cc"/>
<text x="228" y="384">refuses the commit: it holds M as departed</text>
<text x="228" y="402">(a relay also deleted its connection to M)</text>
<text x="228" y="432" font-weight="700">at its next link read: M is an owner</text>
<text x="228" y="450">(the link lists M, not revoked)</text>
<rect x="580" y="360" width="356" height="120" rx="6" fill="#ffffff" stroke="#d9d5cc"/>
<text x="594" y="384" font-weight="700">M is an owner</text>
<text x="594" y="408">an admin's later removal is refused</text>
<text x="594" y="426">(checkRole, as today); an owner's is</text>
<text x="594" y="444">applied, and the next read takes M back</text>
<rect x="24" y="490" width="180" height="120" rx="6" fill="#f3f1ec" stroke="#d9d5cc"/>
<text x="36" y="516" font-weight="700">M's own device</text>
<rect x="214" y="490" width="356" height="120" rx="6" fill="#fbe9e7" stroke="#b42318"/>
<text x="228" y="514">has left the channel, and relays that applied</text>
<text x="228" y="532">the removal first have no connection to M</text>
<text x="228" y="562" font-weight="700" fill="#b42318">M stays listed as an owner without a</text>
<text x="228" y="580" font-weight="700" fill="#b42318">device until another owner removes it</text>
<rect x="580" y="490" width="356" height="120" rx="6" fill="#ffffff" stroke="#d9d5cc"/>
<text x="594" y="514" font-weight="700">admin remover: M stays an owner (T18)</text>
<text x="594" y="540">owner remover: M's device applies the</text>
<text x="594" y="558">removal here too (an owner may remove</text>
<text x="594" y="576">an owner), so it leaves, as on the left</text>
<rect x="24" y="630" width="912" height="92" rx="8" fill="#f3f1ec" stroke="#d9d5cc"/>
<text x="40" y="652">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</text>
<text x="40" y="670">ends the same way. A promoter that applied the removal before its swap refuses to add M: CLCAddOwner refuses a removed or</text>
<text x="40" y="688">departed member [§4.4]. The commit-first owner-remover cells follow today's checkRole, which the plan keeps; no plan</text>
<text x="40" y="706">text or test states them (T8 covers the removal-first order).</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

@@ -1,85 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="756" viewBox="0 0 960 756" role="img" aria-label="Data model before and after. The link queue goes from one recipient key to a key set swapped with the link data; the link data gains linkVersion, revokedOwners, roster, creatorQueueKey and linkRcvId; the server log gains LKEYS; groups gains five columns and group_members three; the agent gains an LSWP command and a hash in LDATA; chat gains three command functions, five in-memory maps and a flag.">
<rect width="960" height="756" fill="#fbfaf7"/>
<rect x="0.5" y="0.5" width="959" height="755" fill="none" stroke="#d9d5cc"/>
<g font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="12" fill="#1f2328">
<text x="24" y="34" font-size="17" font-weight="700">7 · Data model, before and after</text>
<text x="24" y="56" fill="#5b636e">Red marks what the plan adds [§4.1, §4.2, §4.3, §4.16]. Single-owner channels write the same link data as today.</text>
<text x="36" y="94" font-weight="700" fill="#5b636e">Where</text>
<text x="226" y="94" font-weight="700" fill="#5b636e">Today</text>
<text x="536" y="94" font-weight="700" fill="#5b636e">With several owners</text>
<line x1="24" y1="104" x2="936" y2="104" stroke="#d9d5cc"/>
<rect x="24" y="110" width="6" height="268" fill="#b8661a"/>
<rect x="30" y="110" width="906" height="76" fill="#fdf2e4"/>
<text x="40" y="132" font-weight="700">Link queue:</text>
<text x="40" y="150" font-weight="700">recipient keys</text>
<text x="226" y="132">one: the creator's queue key</text>
<text x="536" y="132" fill="#b42318">the creator's queue key, while it is an owner,</text>
<text x="536" y="150" fill="#b42318">+ the member key of every non-revoked chain owner;</text>
<text x="536" y="168" fill="#b42318">LSWP replaces the set together with the data</text>
<rect x="30" y="190" width="906" height="128" fill="#fdf7ee"/>
<text x="40" y="212" font-weight="700">Link data</text>
<text x="40" y="230" fill="#5b636e">on the link server</text>
<text x="226" y="212">owners [creator], signed by the root key;</text>
<text x="226" y="230">relays, profile, subscriber count</text>
<text x="226" y="256">written with LSET: last writer wins</text>
<text x="536" y="212">owners chain [creator, M, …], each entry signed by</text>
<text x="536" y="230">the root key or an earlier entry; relays, profile, count</text>
<text x="536" y="250" fill="#b42318">+ linkVersion, revokedOwners, roster {version, author},</text>
<text x="536" y="268" fill="#b42318">+ creatorQueueKey, linkRcvId (all optional JSON)</text>
<text x="536" y="288" fill="#b42318">written only by LSWP once multi-owner; LSET gets AUTH</text>
<text x="536" y="306" fill="#b42318">on a queue with several keys</text>
<rect x="30" y="322" width="906" height="56" fill="#fdf2e4"/>
<text x="40" y="344" font-weight="700">Server store log</text>
<text x="226" y="344">LINK, KEYS, … records</text>
<text x="536" y="344" fill="#b42318">+ LKEYS: link data with its keys, then KEYS;</text>
<text x="536" y="362" fill="#b42318">a restart never restores data without its keys</text>
<rect x="24" y="386" width="6" height="140" fill="#2f6db5"/>
<rect x="30" y="386" width="906" height="76" fill="#e9f1fb"/>
<text x="40" y="408" font-weight="700">groups</text>
<text x="40" y="426" fill="#5b636e">chat database</text>
<text x="226" y="408">roster_version, stored_roster_version,</text>
<text x="226" y="426">applied_complete_roster_version,</text>
<text x="226" y="444">roster_blob, …</text>
<text x="536" y="408" fill="#b42318">+ link_data_version, link_roster_version,</text>
<text x="536" y="426" fill="#b42318">+ link_roster_seen_at, members_catchup,</text>
<text x="536" y="444" fill="#b42318">+ roster_pending_msg_id</text>
<rect x="30" y="466" width="906" height="60" fill="#f2f6fc"/>
<text x="40" y="488" font-weight="700">group_members</text>
<text x="40" y="506" fill="#5b636e">chat database</text>
<text x="226" y="488">member_role, member_status,</text>
<text x="226" y="506">member_pub_key, roster_served_version, …</text>
<text x="536" y="488" fill="#b42318">+ owner_inv_msg_id, owner_inv_accepted,</text>
<text x="536" y="506" fill="#b42318">+ relay_web_domain</text>
<rect x="24" y="534" width="6" height="60" fill="#6e7781"/>
<rect x="30" y="534" width="906" height="60" fill="#f1f2f4"/>
<text x="40" y="556" font-weight="700">Agent</text>
<text x="226" y="556">async commands NEW, LSET, LGET, JOIN, …</text>
<text x="226" y="574">LDATA: the link data, no hash</text>
<text x="536" y="556" fill="#b42318">+ LSWP command, stored as a blob: no migration</text>
<text x="536" y="574" fill="#b42318">LDATA carries the hash a swap needs</text>
<rect x="24" y="602" width="6" height="112" fill="#2f6db5"/>
<rect x="30" y="602" width="906" height="56" fill="#e9f1fb"/>
<text x="40" y="624" font-weight="700">Chat commands</text>
<text x="40" y="642" fill="#5b636e">continuations</text>
<text x="226" y="624">CFSetShortLink, CFGetRelayData…, …</text>
<text x="536" y="624" fill="#b42318">+ CFChannelLinkSync, CFChannelLinkResign,</text>
<text x="536" y="642" fill="#b42318">+ CFChannelLinkSwap</text>
<rect x="30" y="662" width="906" height="52" fill="#f2f6fc"/>
<text x="40" y="684" font-weight="700">Chat memory</text>
<text x="40" y="702" fill="#5b636e">ChatController</text>
<text x="226" y="684">none of these</text>
<text x="536" y="684" fill="#b42318">+ linkWriters, linkSyncSwaps, countPublishPending,</text>
<text x="536" y="702" fill="#b42318">+ memberCatchups, rosterRequests, mainApp</text>
<text x="24" y="740" fill="#5b636e">One chat migration, M20261015_channel_multi_owner (SQLite and Postgres, with its down migration); no agent or server schema change.</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because it is too large. Load diff
@@ -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 #<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].
**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 #<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.
![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].
@@ -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.