From 736ff957f40f5eea0be6ae11f2d590e6edc3812e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:21:07 +0400 Subject: [PATCH] update plan --- plans/2026-07-15-channel-multi-owner.md | 225 +++++++++++++----------- 1 file changed, 127 insertions(+), 98 deletions(-) diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index f3c7281cdc..a53b765c47 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -1,152 +1,181 @@ -# Channel multi-owner (v7: any-owner-decides) +# Channel multi-owner -Status: plan for review. Nothing implemented. +A channel can have N equal owners, each acting independently, per `docs/protocol/channels-overview.md` §Governance ("Near-term (v7): Multiple owners, any-owner-decides"). Owners are fully equal: any owner can take any administrative action, including destroying the channel. No coordination between owners is required for any action. -Goal: a channel can have N owners, each able to act independently, per `docs/protocol/channels-overview.md` §Governance ("Near-term (v7): Multiple owners, any-owner-decides"). +## 1. What an owner must be able to do -## 1. What a channel owner actually is — three independent authorities +1. Publish content, and publish as the channel. +2. Sign administrative messages that subscribers accept — profile, prefs, member removal, role changes, relay announcements, channel deletion. +3. Change member/moderator/admin roles, which means signing the roster. +4. Write the channel's link data — add/remove relays, update the profile and member count, and add the next owner. +5. Add another owner. -The word "owner" conflates three capabilities that live in different places. Every design question below is really "which of these does owner 2 get?" +Today only the creator can do 2, 4 and 5, and 3 works only for one owner at a time. Each is enforced in a different place, so each needs its own change; item 4 is the one that reaches into simplexmq. -| # | Authority | Enforced by | Held today by | -|---|---|---|---| -| A | **Sign as owner** — subscribers accept your admin messages | `OwnerAuth` chain in link data, verified against root key | creator only (published as a 1-element list) | -| B | **Write link data** — relays, profile, member count, owners list | SMP: `LSET` is `Command Recipient`, authorised by the link queue's recipient key | creator only (owns the queue) | -| C | **Sign the roster** — member/moderator/admin roles | single per-group `roster_version` counter | any `GROwner` (already), but see D3 | +## 2. Current state -A is the identity. B is the link. C is the roster. They are separable, and B is the expensive one. +**The protocol layer is already built for this; the client is not.** `UserContactData.owners :: [OwnerAuth]`, `validateLinkOwners` (chain: each entry signed by root *or an earlier entry*), and `decryptLinkData` (accepts data signed by root *or any listed owner*) exist in simplexmq and are multi-owner-correct. `ShortLinkCreds.linkRootSigKey` exists with the comment *"in case the current user is not the original owner"*. -## 2. Findings that shape the design - -**The protocol layer is already built for this; the client is not.** `UserContactData.owners :: [OwnerAuth]`, `validateLinkOwners` (chain: each entry signed by root *or an earlier entry*), and `decryptLinkData` (accepts data signed by root *or any listed owner*) all exist in simplexmq and are multi-owner-correct. `ShortLinkCreds.linkRootSigKey` exists with the comment *"in case the current user is not the original owner"* — i.e. designed for exactly this. - -**SMP already supports multi-writer links.** `RKEY :: NonEmpty RcvPublicAuthKey -> Command Recipient` and `recipientKeys :: NonEmpty` landed in commit `b7a95422` *"smp server: short links and owners for channels"* — "support multiple rcv keys", with tests. It is at `shortLinksSMPClientVersion = 4 = currentSMPClientVersion`, so every server that can host a channel already accepts it. **The agent never uses RKEY** (`grep RKEY` in `Agent.hs`/`Client.hs` → nothing). This is the intended mechanism for authority B, shipped server-side and unwired client-side. +**SMP already supports multi-writer links.** `RKEY :: NonEmpty RcvPublicAuthKey -> Command Recipient` and `recipientKeys :: NonEmpty` landed in commit `b7a95422` *"smp server: short links and owners for channels"* — "support multiple rcv keys", with tests — at `shortLinksSMPClientVersion = 4 = currentSMPClientVersion`, so every server that can host a channel already accepts it. **The agent never calls RKEY.** This is the mechanism for item 4. **Two blockers in simplexmq:** -- `AgentStore.hs:2514` — `linkRootSigKey = Nothing` on every load, with `-- TODO linkRootSigKey should be stored in a separate field`. There is no column. So the non-root branch of `validateOwners` is currently dead code. -- `Crypto/ShortLink.hs:80` — `newOwnerAuth` *generates the key pair locally*. Adding a remote owner needs to sign an externally supplied public key. The signing formula `sign(signingKey, ownerId <> encodePubKey ownerKey)` is **duplicated** in chat at `Internal.hs:1519`; extract `mkOwnerAuth` and use it from both. +- `AgentStore.hs:2514` — `linkRootSigKey = Nothing` on every load, with `-- TODO linkRootSigKey should be stored in a separate field`. There is no column, so the non-root branch of `validateOwners` is dead code. +- `Crypto/ShortLink.hs:80` — `newOwnerAuth` *generates the key pair locally*. Adding a remote owner must sign an externally supplied public key. The formula `sign(signingKey, ownerId <> encodePubKey ownerKey)` is duplicated in chat at `Internal.hs:1519`. -**The single-owner assumption is concentrated in one function.** `groupLinkData` (`Internal.hs:1509`, already carries `-- TODO [relays] owner: set owners on updating link data (multi-owner)`) rebuilds `owners` on every publish as a singleton containing *only the publishing user*, and as `[]` unless the user holds `GRKPrivate`. Consequences: (a) a second owner is never published; (b) if two owners could both publish, **each publish evicts the other**; (c) a non-root owner would publish an empty owners list, making the link data unreadable to everyone. +**The single-owner assumption is concentrated in one function.** `groupLinkData` (`Internal.hs:1509`, carrying `-- TODO [relays] owner: set owners on updating link data (multi-owner)`) rebuilds `owners` on every publish as a singleton containing *only the publishing user*, and as `[]` unless the user holds `GRKPrivate`. So (a) a second owner is never published; (b) once two owners can publish, each publish evicts the other; (c) a non-root owner would publish an empty owners list, making the link data unreadable to everyone. -**Owners are deliberately not in the roster.** `isRosterRole = GRMember | GRModerator | GRAdmin` (`Internal.hs:1281`, comment: *"owners are on the link, not the roster"*). Link data is the sole authority for owner keys (`Groups.hs:3533`: *"Role and key stay owner-authoritative … so taking either from an in-band relayed intro would let a compromised relay substitute them"*). This is correct and must be preserved — but it means **existing subscribers have no way to learn a new owner**, because `updateGroupFromLinkData` only carries profile + member count (never `owners`) and is only called on connect/preview paths. Hence the explicit role-broadcast step. +**Owners are deliberately not in the roster.** `isRosterRole = GRMember | GRModerator | GRAdmin` (`Internal.hs:1281`: *"owners are on the link, not the roster"*); link data is the sole authority for owner keys (`Groups.hs:3533`: *"Role and key stay owner-authoritative … so taking either from an in-band relayed intro would let a compromised relay substitute them"*). This is preserved. Since `updateGroupFromLinkData` carries only profile + member count and runs only on connect/preview, a new owner goes into link data **and** is announced by a signed `x.grp.mem.role`, so existing subscribers learn it promptly rather than at their next join. -**Live bug, independent of this feature.** `APIMembersRole gId [m] GROwner` on a channel is **not rejected by the backend** — it is blocked only in the iOS/Kotlin pickers (`ChatTypes.swift:3060`, `ChatModel.kt:2652`). Via CLI/API it produces a broken state: `isRosterRole GROwner == False`, so the observer→owner path skips the owner-only guard, sends no key (`mKey m = if isJust rosterVer then …` — `Commands.hs:2936`, and `rosterVer` is `Nothing` for a non-roster role), publishes nothing to the link, and leaves the promoted device with `membership.memberRole == GROwner` so `updatePublicGroupData` (`Internal.hs:1458`) calls `getGroupLink` on every `x.grp.mem.new` and throws `SEGroupLinkNotFound`. Fix now, regardless of the rest. +**`APIMembersRole … GROwner` on a channel is currently not rejected by the backend** — only by the iOS/Kotlin pickers. Via CLI it half-works: it skips the owner-only guard (`isRosterRole GROwner == False`), sends no key, publishes nothing to the link, and leaves the promoted device throwing `SEGroupLinkNotFound` from `updatePublicGroupData` on every `x.grp.mem.new`. The flow in §3 replaces that path. -## 3. Decisions for you +## 3. Protocol -**D1 — Include authority B (link writes) for owner 2?** *Recommend: yes, staged (§8).* Without it, "multiple owners" does not deliver the property the overview claims for it: *"Loss of all owner devices is the catastrophic event … Multiple owners and backups mitigate this risk"* and design objective 5 (relay transience). If owner 1 is lost, no relay can ever be added again and the channel dies with its last relay. Owner 2 would be an owner who cannot save the channel. +`APIMembersRole … GROwner` on a channel triggers this flow instead of performing a plain role change. It is two-phase and asynchronous: the command returns "invitation sent" and the role lands later. -**D2 — Must owner 2 already be a channel member?** *Recommend: yes.* Their key must already be known to relays and subscribers, and the relay connections they will publish through only exist for members (a subscriber creates `GRRelay`/`GCHostMember` rows, so `getGroupRelayMembers` already returns them for a promoted subscriber — the transport works unchanged). Non-member → invitation carries the channel link, they join, then accept. This mirrors the existing relay-invitation flow exactly. +Owner 2 must already be a channel member. Their member key is then already known to relays and subscribers, and the relay connections they will publish through already exist — a subscriber creates `GRRelay`/`GCHostMember` rows, so `getGroupRelayMembers` already returns them for a promoted subscriber and the transport works unchanged. -**D3 — Roster concurrency (authority C).** Two owners both compute `rosterVersion + 1` (`Internal.hs:2338`) and recipients accept `v >= gate` (`Subscriber.hs:3302`), so equal versions silently clobber. Worse, owner 2 never *sees* owner 1's versions: `sendRosterBlob` targets `getGroupRelayMembers` only, so rosters go owner→relays and are served to joiners, never to other owners. The overview already defers the real fix ("A planned extension will record role changes as a linearly ordered signed roster log with consistent sequencing across all owners"). Options: - - **(a) Root owner only signs rosters** in v1; other owners cannot change member/mod/admin roles. Simple, honest, but visibly undercuts "any-owner-decides". - - **(b) Forward rosters to owners + `max(seen)+1` + tie-break by ownerId.** Converges deterministically; concurrent edits still lose one update silently. *Recommended* — it keeps the v7 promise and the residual race is the one the overview already says is deferred. - - (c) Full linearised log — out of scope. - -**D4 — Can owner 2 add owner 3?** *Recommend: yes* (implied by any-owner-decides). Cost: `validateLinkOwners` is **order-dependent** (an entry must be signed by root or an *earlier* entry), so the chain order must be persisted, not derived. If only the root owner could add owners, every entry would be root-signed and order would be free. - -**D5 — Owner removal: in or out of v1?** *Recommend: out, and blocked.* Removing an owner from the middle of the chain invalidates every owner it transitively signed. Also note there is **no last-owner protection anywhere** today (`APIMembersRole` blocks only `selfSelected`), so two owners can mutually demote each other into an ownerless, unrecoverable channel. At minimum add the last-owner guard in v1. - -## 4. Protocol - -Two new events, both `requiresSignature`, both **sent over the direct owner↔owner contact connection — never through a relay**. This is a hard requirement, not a preference: support scope is not E2E encrypted (the overview lists "E2E encrypted support scope" as future work), so routing owner onboarding through a relay would hand the relay the material it needs to attack the link, directly contradicting design objective 3 ("No possibility for a relay to impersonate an owner"). +Three events, all `requiresSignature`, all over the existing relay connections to the invitee: ``` -x.grp.owner.inv { groupLink, memberRole, ... } -x.grp.owner.acpt { ownerKey, linkRcvKey? } +x.grp.owner.inv { memberRole } -- O1 -> M +x.grp.owner.acpt { invitationId, ownerKey, linkRcvKey } -- M -> O1 +x.grp.owner.creds { linkRcvId } -- O1 -> M, after RKEY + LSET succeed ``` -- `memberRole` is carried in the invitation even though v1 auto-accepts, so a future release can let the invitee accept/reject a named role without a wire change (your requirement). -- `ownerKey` is the invitee's existing channel member public key — the same key that already signs their messages and is already known to relays and subscribers. No new signing key. -- `linkRcvKey` is a fresh SMP recipient auth public key the invitee generates for authority B. Define and populate it from day 1 even if RKEY lands in stage 2 (optional field → no protocol revision later). +- `memberRole` rides in the invitation even though acceptance is automatic, so a later release can let the invitee accept or reject a named role without a wire change. +- `ownerKey` is the invitee's existing channel member public key — already signing their messages, already known to relays and subscribers. No new signing key. +- `linkRcvKey` is a fresh SMP recipient auth **public** key the invitee generates, so it can write link data. +- `invitationId` binds the acceptance to one invitation, so a relay cannot replay an old acceptance. +- `linkRcvId` is the link queue's recipient ID — the only non-public datum in the exchange. It is sent last, so a declining invitee never receives it. -Owner 1 on acceptance, in this order, driven by a durable worker (mirror `runRelayRequestWorker` — the flow is multi-step, cross-network and must survive a crash mid-way): +Everything else owner 2 needs in order to write link data is public or derivable: `shortLinkKey` and `shortLinkId` from the channel link, and the fixed-data plaintext (hence `rootPubKey`) via `LGET`, re-encrypted under a fresh nonce — readers check `sha3_256(fd) == linkKey` and the root signature over the *plaintext*, not the ciphertext. -1. `RKEY` — add `linkRcvKey` to the link queue's recipient keys. *(stage 2)* -2. `LSET` — publish `owners = [oa1, …, oaN, mkOwnerAuth memberId2 ownerKey rootPrivKey]`. -3. `x.grp.mem.role memberId2 GROwner (Just ownerKey) …` to relays → forwarded to subscribers. +**Routing this over relays is safe.** The relay sees `linkRcvKey` (a public key) and `linkRcvId`. Neither is exploitable: `Server.hs:1249` verifies *every* recipient command against `recipientKeys`, so `linkRcvId` alone authorises nothing, and the matching private key never leaves owner 2's device. The relay cannot forge an acceptance — it is signed by owner 2's key, which the relay does not hold — and `invitationId` closes replay. A relay can drop the exchange, which is a liveness failure, not an escalation. -LSET before the role broadcast, so any joiner in the window learns owner 2 from the link (the authority) rather than by TOFU. +O1 on acceptance, driven by a durable worker (mirroring `runRelayRequestWorker`: multi-step, cross-network, must survive a crash mid-way): + +1. `RKEY` — add `linkRcvKey` to the link queue's recipient keys. +2. `LSET` — publish `owners = [oa1, …, oaN, mkOwnerAuth memberId2 ownerKey signingKey]`. +3. `x.grp.owner.creds` to M; `x.grp.mem.role memberId2 GROwner (Just ownerKey)` to relays → forwarded to subscribers. + +LSET precedes the role broadcast so a joiner in the window learns owner 2 from the link — the authority — rather than by TOFU. Because LSET is a blind overwrite (§4.2), the worker must verify its entry survived and re-publish if not; LSET returning success does not mean the entry is still published. + +## 4. Concurrency + +### 4.1 Roster: same version from two owners, multiple relays + +With O1 and O2 both at version N-1 and both making a change: + +``` +O1 -> R1,R2 : roster N (content A) +O2 -> R1,R2 : roster N (content B) +R1 sees A then B: fresh = N >= N -> applies B -> R1 = B +R2 sees B then A: fresh = N >= N -> applies A -> R2 = A +``` + +Both relays accepted both, in different orders, and now serve different rosters to different subscribers, permanently. The cause is that `fresh = maybe True (v >=) gate` (`Subscriber.hs:3302`, and `notBelowRoster` at `:3430` for the blob) is not a total order — at equal versions the outcome depends on arrival order. + +**Make the version a total order by tie-breaking on the author's `memberId`.** Accept iff `(v, authorMemberId) >= (gate_v, gate_authorMemberId)` lexicographically. `memberId` is 12 random bytes and identical on every device, so every node computes the same winner. `roster_sending_owner_gm_id` already exists on `groups` but is a **local row id**, not comparable across devices; add `roster_version_owner_id BLOB` beside `roster_version`. + +The same interleaving, with `memberId(O2) > memberId(O1)`: + +``` +R1 sees A -> gate (N,O1); B: (N,O2) >= (N,O1) -> accept -> R1 = B +R2 sees B -> gate (N,O2); A: (N,O1) >= (N,O2) -> reject -> R2 = B +``` + +Both relays converge on B regardless of order, as do subscribers and O1. `>=` is retained rather than `>` to preserve the existing same-owner behaviour, which is deliberate (`Subscriber.hs:3288-3291`: a preceding signed event may already have advanced `roster_version` to this blob's version) — an equal tuple from the same owner still accepts. + +**Rosters must reach owners.** Today `sendRosterBlob` targets `getGroupRelayMembers` only, and the relay→subscriber broadcast was deliberately removed, so an owner never sees another owner's roster and its counter goes stale. **Relays forward `XGrpRoster` and its `BFileChunk`s to members with role `GROwner`, excluding the author** — a bounded fan-out, owners only, not all subscribers. This keeps every owner's version current so collisions stay rare, and it is what lets O1 receive (N,O2), lose the tie-break, and self-correct to the network's state. + +The losing owner's change is dropped. O1 adopts B and the change it made reverts; the UI must surface this rather than let it happen silently. See §11 for the alternative. + +The gap/frontier logic is unaffected: the counter remains a single shared sequence (each owner computes `local + 1`), so the tie-break introduces no gaps in `nextCompleteVersion`. + +### 4.2 Link data: concurrent LSET + +There is one link queue on one server, so writes serialise and the stored state never diverges — but LSET is a blind whole-object overwrite with no CAS, so O1 adding a relay concurrently with O2 changing the profile silently drops one of them. Neither owner finds out: `LINK` fires only in response to one's own `setConnShortLink` call (`Subscriber.hs:1451`). + +**The published link is the source of truth for the relay set, and every non-publishing owner reconciles against it.** `syncSubscriberRelays` already does exactly this for subscribers and is currently skipped for *all* owners (`Commands.hs:1918`); it should be skipped only for the owner that just published. Combined with the periodic `LGET` the relay worker already performs (`checkRelayServedGroups`, `Commands.hs:5254`), owners converge on the published set. + +Relay membership is a set with meaningful removals, so an owner republishing a stale snapshot can resurrect a relay another owner just removed. Last-writer-wins converges, but to the wrong value. See §11. + +### 4.3 Owner chain ordering + +`validateLinkOwners` requires each entry to be signed by root or by an entry **earlier in the list**. Since owner 2 can add owner 3 using its own key, order is load-bearing and must be persisted, not derived. Two owners concurrently appending an owner is an LSET race (§4.2): one append is lost and its invitee is left having accepted but never published — hence the worker's verify-and-republish step in §3. ## 5. Backend work (simplex-chat) -**Persist the owners chain — the core change.** Add `owner_auth_sig BLOB NULL` and `owner_auth_index INTEGER NULL` to `group_members` (migration + cabal + both SQLite and Postgres). The public key is already there as `member_pub_key`, so this cannot drift out of sync with the member record by construction, and the index gives `validateLinkOwners` its required order. Then rewrite `groupLinkData` to **read** the stored chain instead of deriving a singleton from `groupKeys`. This is what makes "each publish evicts the other owner" structurally impossible rather than merely unlikely, and it is required even in stage 1 (owner 1 must publish both entries). +**Persist the owners chain.** Add `owner_auth_sig BLOB NULL` and `owner_auth_index INTEGER NULL` to `group_members` (migration + cabal + SQLite and Postgres). The public key is already there as `member_pub_key`, so this cannot drift from the member record by construction, and the index carries the order §4.3 requires. `groupLinkData` then **reads** the stored chain rather than deriving a singleton from `groupKeys`, which makes "each publish evicts the other owner" structurally impossible rather than merely unlikely. -- `Internal.hs:1509` `groupLinkData` — read chain; remove the `GRKPrivate`-derived singleton and its `_ -> []` fallback. -- `Internal.hs:1458` `updatePublicGroupData`, `Commands.hs:4011` `runUpdateGroupProfile`, `Commands.hs:2713` `APIAddGroupRelays` — these gate on `memberRole' membership == GROwner`, which is authority A, but what they need is authority B. Introduce one predicate (`canWriteLinkData`) and use it at all three, so the proposition being asserted is the one that is actually required. In stage 1 it is "holds the root key"; in stage 2 "has link creds". -- `Commands.hs:3268` `APIAddGroupShortLink` asserts **no role at all** — add the same gate. -- `Subscriber.hs:3345` `allowCreate` — widen so an owner-signed `x.grp.mem.role` carrying a key can TOFU-create a `GROwner` (currently `isRosterRole GROwner == False` blocks it, so subscribers can never materialise a new owner). +- `Internal.hs:1509` `groupLinkData` — read the chain; delete the `GRKPrivate`-derived singleton and the `_ -> []` fallback. +- `Internal.hs:1458` `updatePublicGroupData`, `Commands.hs:4011` `runUpdateGroupProfile`, `Commands.hs:2713` `APIAddGroupRelays` — all gate on `memberRole' membership == GROwner` but need "can write link data" (an owner mid-handover cannot). One predicate, used at all three. +- `Commands.hs:3268` `APIAddGroupShortLink` asserts no role at all — add the same gate. +- `Commands.hs:2872` `APIMembersRole` — route `newRole == GROwner` on a channel into the §3 flow. +- `Subscriber.hs:3345` `allowCreate` — widen so an owner-signed `x.grp.mem.role` carrying a key can TOFU-create a `GROwner`; `isRosterRole GROwner == False` blocks it today, so subscribers can never materialise a new owner. - `Commands.hs:2936` `mKey` — send the key on owner promotion, not only when a roster version is present. -- `Subscriber.hs:4195` — the "owners are already known to every member" skip is false for a *newly promoted* owner; disseminate their profile. -- `Subscriber.hs:1422` — `publicMemberCount > 1` assumes exactly one owner (self-documented TODO). Use a subscriber count or `> ownerCount`. -- `Commands.hs:1918` `syncSubscriberRelays` — skipped for all owners; must be skipped only for the *publishing* owner, else a non-publishing owner's relay list drifts. -- `Commands.hs:1192` — share-link owner signing requires `GRKPrivate`, so owner 2 silently emits `ownerSig = Nothing`. Sign with the member key of any listed owner (`verifyLinkOwner` already resolves by `ownerId`). -- `Store/Groups.hs:2320` `updateRelayGroupKeys` — the `forM_ owners` loop already exists but `getGroupMemberIdViaMemberId` **throws** for an owner the relay has no row for, aborting the whole transaction (self-documented TODO). Create the record instead — reuse `createLinkOwnerMember`, which the subscriber path already uses for exactly this. -- `Commands.hs:5254` `checkRelayServedGroups` — the relay's periodic worker already re-reads link data but binds only `relays` and discards `owners` from the very same `UserContactData`. Refresh owners here; this is the relay's only path to learning about owner changes. -- `Store/Groups.hs:1117` `getHostMember` — `firstRow` on `GCHostMember`; relays model exactly one owner. Needed for D4/D5 only. -- Reject `newRole == GROwner` in `APIMembersRole` for channels (§2 live bug) and route owner promotion exclusively through the new flow. Add last-owner protection (D5). +- `Subscriber.hs:4195` — the "owners are already known to every member" skip is false for a newly promoted owner; disseminate their profile. +- `Subscriber.hs:1422` — `publicMemberCount > 1` assumes exactly one owner. Use a subscriber count or `> ownerCount`. +- `Commands.hs:1918` `syncSubscriberRelays` — §4.2. +- `Commands.hs:1192` — share-link owner signing requires `GRKPrivate`, so a promoted owner silently emits `ownerSig = Nothing`. Sign with the member key of any listed owner; `verifyLinkOwner` already resolves by `ownerId`. +- `Store/Groups.hs:2320` `updateRelayGroupKeys` — the `forM_ owners` loop exists but `getGroupMemberIdViaMemberId` throws for an owner the relay has no row for, aborting the transaction. Create the record — reuse `createLinkOwnerMember`, which the subscriber path already uses for exactly this. +- `Commands.hs:5254` `checkRelayServedGroups` — the relay's periodic worker already re-reads link data but binds only `relays`, discarding `owners` from the same `UserContactData`. Refresh owners here; it is the relay's only path to learning of owner changes. +- `Store/Groups.hs:1117` `getHostMember` — `firstRow` on `GCHostMember`; relays model exactly one owner. +- Roster: tie-break gate (`Subscriber.hs:3302`, `:3430`), `roster_version_owner_id` column, relays forward `XGrpRoster` + chunks to owners (§4.1). +- New durable owner-request worker + table, mirroring the relay-request worker. ## 6. simplexmq work -Stage 1: -- `Crypto/ShortLink.hs` — extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth`; redefine `newOwnerAuth` on top of it; export. Kills the formula duplicated across the two repos. - -Stage 2 (authority B): -- Persist `linkRootSigKey` — new `rcv_queues` column + migration; fixes `AgentStore.hs:2514`. Without it the non-root owner branch of `validateOwners` cannot work. -- RKEY agent API — add the recipient key to a contact-link queue. -- A **least-privilege** link-write API for an owner who does not own the queue. Prefer a standalone `setForeignLinkData` taking explicit creds over materialising a fake `RcvQueue`/`ContactConnection` on owner 2's device: the latter needs an `rcvDhSecret` owner 2 must not have, and risks owner 2 subscribing to owner 1's queue and racing it on inbound requests. +- `Crypto/ShortLink.hs` — extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth`; redefine `newOwnerAuth` on top of it; export. Removes the formula duplicated across the two repos. +- Persist `linkRootSigKey` — new `rcv_queues` column + migration (`AgentStore.hs:2514`). Without it the non-root branch of `validateOwners` cannot work. +- RKEY agent API — add a recipient key to a contact-link queue. +- A link-write API for an owner who does not own the queue. Prefer a standalone `setForeignLinkData` taking explicit creds over materialising a fake `RcvQueue`/`ContactConnection` on owner 2's device: the latter needs an `rcvDhSecret` owner 2 does not have, and risks owner 2 subscribing to owner 1's queue and racing it on inbound requests. ## 7. UI -The UI is further along than the backend: it already pluralises owners (`ownersContributorsCountStr`, `ChatInfoToolbar.swift:134` / `ChatView.kt:1550`), already checks `members.contains { $0.wrapped.memberRole >= .owner }` to decide whether to show the members button (`GroupChatInfoView.swift:128` / `.kt:650`), and already has a `hasOtherOwner` escape hatch on leave (`GroupChatInfoView.swift:250` / `.kt:822`). The gap is that `isOwner` is **self-relative and count-blind** (`membership.memberRole == .owner && membership.memberCurrent`) and is used ~30× as if it meant *the* owner. +The UI already pluralises owners (`ownersContributorsCountStr`, `ChatInfoToolbar.swift:134` / `ChatView.kt:1550`), already checks `members.contains { $0.wrapped.memberRole >= .owner }` for the members button (`GroupChatInfoView.swift:128` / `.kt:650`), and already has a `hasOtherOwner` escape hatch on leave (`GroupChatInfoView.swift:250` / `.kt:822`). The gap: `isOwner` is self-relative and count-blind (`membership.memberRole == .owner && membership.memberCurrent`) and is used ~30× as if it meant *the* owner. -**The structural problem: the UI has no model of authority B.** Grep for `groupKeys|GRKPrivate|rootKey` across `apps/ios` and `apps/multiplatform` returns **zero hits** — neither `GroupInfo` nor `GroupMember` carries any key material. The UI cannot distinguish a root-key holder from a promoted owner, so it currently uses two different proxies for it, both wrong for multi-owner: +**The UI has no model of link-write capability.** `grep groupKeys|GRKPrivate|rootKey` across `apps/ios` and `apps/multiplatform` returns zero hits — no key material on `GroupInfo` or `GroupMember`. It uses two proxies, both wrong once owners are equal: -- **`isOwner && groupLink != nil`** (`GroupChatInfoView.swift:111` — the site of `// TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership)`; `.kt:638`). The `groupLink != nil` half is the accidental single-owner gate: only the creator has a `user_contact_links` row, so only they get the link-management screen; everyone else falls to a read-only QR + share sheet. -- **`isOwner`** alone for relay add/remove and relay status fetch (`ChannelRelaysView.swift:26/48/83`, `.kt:39/90/109`), which a co-owner would pass while the backend call fails. +- **`isOwner && groupLink != nil`** (`GroupChatInfoView.swift:111` — the site of `// TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership)`; `.kt:638`). The `groupLink != nil` half is the accidental single-owner gate: only the creator has a `user_contact_links` row, so only they reach the link-management screen. +- **`isOwner`** alone for relay add/remove and relay status fetch (`ChannelRelaysView.swift:26/48/83`, `.kt:39/90/109`). -So: **add an explicit `canManageLink` (authority B) to `GroupInfo` from the backend** and gate link/relay/domain-claim UI on it, leaving `isOwner` to mean authority A. That replaces both proxies with the proposition actually being asserted, and it is what lets stage 1 ship (co-owner sees the read-only path, no error) and stage 2 flip one flag. +Both should be replaced by a `canManageLink` flag on `GroupInfo`, supplied by the backend. It is not equivalent to `isOwner`: an owner mid-handover, or one whose `x.grp.owner.creds` never arrived, cannot write the link. Gate link, relay and domain-claim UI on it. -Also: +- `canChangeRoleTo` (`ChatTypes.swift:3060`, `ChatModel.kt:2652`) hard-codes `[.observer, .member]` for channels. Add `.owner`, and handle that selecting it starts an async two-phase flow (pending → owner, or failed), not an immediate role change. +- New: owners section in channel info; pending-owner state. +- A role change an owner made can revert when it loses a tie-break (§4.1). Surface it. +- **iOS-only:** `GroupChatInfoView.swift:303` skips the `apiGetGroupLink` fetch unless `isOwner`; Kotlin fetches unconditionally (`ChatView.kt:403/428`). Reconcile. +- RULE-19 (`apps/ios/product/rules.md:112`) says a *sole* owner must not be able to leave. The info view honours it via `hasOtherOwner`, but the chat-list swipe/context menus drop the clause — `showLeaveGroup = … && !(groupInfo.useRelays && groupInfo.isOwner)` (`ChatListNavLink.swift:272`, `:247`; `ChatListNavLinkView.kt:319`, `:341`) — so any channel owner loses Leave in the list even when other owners exist. -- `canChangeRoleTo` (`ChatTypes.swift:3060`, `ChatModel.kt:2652`) hard-codes `[.observer, .member]` for channels — and even the commented-out "restore" line excludes `.owner`. Owner promotion must **not** be added to this picker; it is a distinct flow (invite → accept), not a role change. -- New: invite-owner entry point (from a contact or a member's profile), pending/accepted/failed state, owners section in channel info. -- **iOS-only:** `GroupChatInfoView.swift:303` skips the `apiGetGroupLink` fetch entirely unless `isOwner`; Kotlin fetches unconditionally (`ChatView.kt:403/428`). Reconcile when adding `canManageLink`. -- **Existing bug this will expose:** RULE-19 (`apps/ios/product/rules.md:112`) says a *sole* owner must not be able to leave. The info view honours it via `hasOtherOwner`, but the chat-list swipe/context menus drop that clause entirely — `showLeaveGroup = … && !(groupInfo.useRelays && groupInfo.isOwner)` (`ChatListNavLink.swift:272`, `:247`; `ChatListNavLinkView.kt:319`, `:341`) — so *any* channel owner loses Leave in the list even when other owners exist. Fix alongside; and note RULE-19 is UI-only, with no backend last-owner guard (D5). +## 8. Security -## 8. Staging +**Any owner can destroy the channel.** `Server.hs:1249` — `vc SRecipient _ = verifyQueue $ \q -> verifiedWithKeys $ recipientKeys (snd q)`: any recipient key authorises any recipient command, including `DEL` (destroy the queue) and `LDEL` (destroy the link data), with no per-command scoping. This is consistent with any-owner-decides, and is recorded because it is stronger than any chat-level action — the link address is embedded in the published profile, so its loss cannot be repaired by re-publishing. -**Stage 1 — identity.** Owners chain persisted and published; owner invite/accept over the direct connection; role propagation to relays and subscribers; relay creates owner records and refreshes them; live-bug fix; last-owner guard. Owner 2 can publish, post as the channel, moderate, and change roles. Link writes stay with owner 1, explicitly gated with a clear error rather than a silent empty-owners publish. All chat-side; no simplexmq schema change. +**RKEY revocation is a race.** `updateKeys` replaces the whole list, so O1 can evict O2 and O2 can evict O1; last writer wins. Owner removal at the SMP level is not a clean operation. -**Stage 2 — link authority.** simplexmq: `linkRootSigKey` persistence, RKEY, foreign-link write. Owner 2 gains relay and profile-in-link management. Delivers the loss-of-owner-1 survivability that motivates the feature. +**Chain order is load-bearing.** `validateLinkOwners` rejects an entry not signed by root or an earlier entry, so a reordered or partially-published chain makes the link data unreadable to every client and bricks joins. Publish in `owner_auth_index` order and validate the chain locally before `LSET` — the agent rejects a bad chain anyway, but as `CMD PROHIBITED`, not a legible chat error. -## 9. Security notes +**Creator anonymity is weaker than the overview states.** It claims all owners are indistinguishable "provided multiple owners were signed by the root key". Once owner 2 signs owner 3 with its own key, the chain reveals who delegated to whom, and root-signed entries identify the creator's cohort. Qualify the claim in the docs. -**Granting link-write grants link-destroy.** `Server.hs:1249` — `vc SRecipient _ = verifyQueue $ \q -> verifiedWithKeys $ recipientKeys (snd q)`: *any* recipient key authorises *any* recipient command, including `DEL` (destroy the queue) and `LDEL` (destroy the link data). There is no per-command key scoping. Stage 2 therefore gives every owner the power to irrecoverably destroy the channel link, which is strictly worse than any chat-level action because the link address is embedded in the published profile. Consistent with "any owner can independently make any administrative decision", but it should be a conscious choice. A scoped link-writer key class would be an SMP-server change; flagging as possible future work. +## 9. Tests -**RKEY revocation is a race.** `updateKeys` replaces the whole list, so owner 1 can evict owner 2 and owner 2 can evict owner 1; last writer wins. Owner removal at the SMP level is therefore not a clean operation (reinforces D5). +Channel tests live under `describe "channels"` (`tests/ChatTests/Groups.hs:254`, ~70 tests); `-m "channels"` selects all of them but **not** `chatRelayTests` (`tests/ChatTests/ChatRelays.hs:33`), where the owner-signature suite ("share channel card", `:243-380`) lives. -**Owner onboarding must not traverse a relay** — see §4. +Infrastructure is close: `memberJoinChannel'` already takes `owners :: [TestCC]` and fans out the "introduced … in the channel" assertion; `Groups.hs:9840` already passes a 2-element list. Gaps: `promoteChannelMember` (`Groups.hs:8773`) hardcodes one owner and one relay; every `prepareChannel*` helper takes one `owner` and registers relays under that owner's `/relays` (precedent for a second registration at `ChatRelays.hs:351`, which is why `setupRelay` returns the link); named profiles run out at `frank`, so **2 owners + 1 relay + 3 subscribers** is the ceiling without adding profiles; the custom `it` imposes a **90s timeout** (`Utils.hs:94`) that 6 clients plus the customary `threadDelay 1000000` between roster steps will approach. -**Chain order is load-bearing.** `validateLinkOwners` rejects an entry not signed by root or an earlier entry. A reordered or partially-published chain makes the whole link data unreadable to every client, which bricks joins. The stored `owner_auth_index` must be published in order, and the publish path should validate the chain locally before `LSET` (the agent will reject it anyway via `validateOwners`, but as `CMD PROHIBITED`, not a legible chat error). +Patterns to reuse: `<###` (`getInAnyOrder`, `Utils.hs:449`) with `EndsWith` for lines whose actor prefix is nondeterministic (an unresolved profile renders by member-id hash); `withCCTransaction` SQL for state that produces no console output (`checkMemberRole`, `Groups.hs:9846`) — the served roster arrives async and silently. `testChannelOwnerKeyAfterLinkUpdate` (`Groups.hs:9545`) is the owner-key template, and its assertion (`COUNT(1) … member_role = 'owner' AND member_pub_key IS NOT NULL` → `[[1]]`) is what must become `[[2]]`. -**Creator anonymity.** The overview claims all owners are indistinguishable "provided multiple owners were signed by the root key". If owner 2 adds owner 3 with owner 2's key (D4), the chain reveals who delegated to whom, and root-signed entries identify the creator's cohort. Worth stating explicitly in the docs rather than leaving the claim unqualified. +Cases: owner 2 promoted, verified by an existing subscriber (TOFU path) and by a later joiner (link path); owner 2 posts as the channel; owner 2's admin message verified by a subscriber that only ever saw owner 1; owner 2 adds a relay; owner 2 adds owner 3 (chain order); owner 1 offline throughout. The §4.1 race needs a deterministic test: two owners, two relays, same version, forced interleaving, asserting both relays and all subscribers converge on the same roster and the losing owner self-corrects. Adversarial, modelled on `ChatRelays.hs:277-287`: replaying an owner's `OwnerAuth`; a relay substituting an owner key; a replayed `x.grp.owner.acpt`; a chain published out of order (must fail closed, and fail *before* `LSET`). -## 10. Tests +## 10. Docs -Channel tests live under `describe "channels"` (`tests/ChatTests/Groups.hs:254`, ~70 tests); `-m "channels"` selects all of them. Note `-m "channels"` does **not** cover `chatRelayTests` (`tests/ChatTests/ChatRelays.hs:33`) — the owner-signature suite ("share channel card", `ChatRelays.hs:243-380`) lives there and must be run separately. +`channels-overview.md` §Governance — move v7 to current; qualify the creator-anonymity claim; record the §4.1 tie-break and the §4.2 residual. `channels-protocol.md` — new §Owner addition, and the `x.grp.owner.*` events in the signing table. Closes four TODOs: `Internal.hs:1508`, `Subscriber.hs:1426`, `Subscriber.hs:1451`, `Groups.hs:2320`. -The infrastructure is closer than expected: `memberJoinChannel'` already takes `owners :: [TestCC]` and fans out the "introduced … in the channel" assertion, and `Groups.hs:9840` already passes a 2-element list. Gaps to close first: +## 11. Open questions -- `promoteChannelMember` (`Groups.hs:8773`) hardcodes a single owner and single relay. -- Every `prepareChannel*` helper takes one `owner` and registers relays under that owner's `/relays`. A second owner needs its own registration — precedent at `ChatRelays.hs:351` (re-registering a relay short link under another user), which is why `setupRelay` returns the link. -- Named profiles run out at `frank`, so **2 owners + 1 relay + 3 subscribers** is the practical ceiling without adding profiles. -- The custom `it` imposes a **90s timeout** (`tests/ChatTests/Utils.hs:94`); multi-owner tests with 6 clients and the customary `threadDelay 1000000` between roster steps will approach it. +**Owner removal.** Out of scope here, and not yet designed. Three things make it harder than it looks: removing a chain entry invalidates every owner it transitively signed (§4.3); SMP-level revocation is a mutual-eviction race (§8); and there is **no last-owner protection anywhere** in the backend — `APIMembersRole` blocks only `selfSelected`, so two owners can already demote each other into an ownerless, unrecoverable channel, and RULE-19 is UI-only. The backend last-owner guard is worth adding regardless of how removal is designed. -Assertion patterns to reuse: `<###` (`getInAnyOrder`, `Utils.hs:449`) with `EndsWith` for lines whose actor prefix is nondeterministic (a subscriber that hasn't resolved a profile renders by member-id hash); and direct `withCCTransaction` SQL on the receiver's DB for state that produces no console output (`checkMemberRole`, `Groups.hs:9846`) — the served roster arrives async and is silent. `testChannelOwnerKeyAfterLinkUpdate` (`Groups.hs:9545`) is the existing owner-key template and its DB assertion (`COUNT(1) … member_role = 'owner' AND member_pub_key IS NOT NULL` → `[[1]]`) is exactly the thing that must become `[[2]]`. +**Lost roster updates (§4.1).** Either the losing owner accepts the loss and adopts the winning roster, or it re-applies its pending delta on top and broadcasts at N+1. The latter converges to the union and terminates — the winner does not retry, because its change survived — at the cost of persisting the pending delta. -Cases: owner 2 promoted, verified by an existing subscriber (TOFU path) and by a later joiner (link path); owner 2 posts as the channel; owner 2's admin message verified by a subscriber that only ever saw owner 1; concurrent link publishes by both owners; concurrent roster edits (D3); chain order after owner 3 is added by owner 2; owner 1 offline (stage 2); rejection of the direct `APIMembersRole … GROwner` path. Adversarial, modelled on `ChatRelays.hs:277-287`: a non-owner replaying an owner's `OwnerAuth`; a relay substituting an owner key; a chain published out of order (must fail closed, and must fail *before* `LSET`). - -## 11. Docs - -`channels-overview.md` §Governance — move v7 from "planned" to current, and qualify the creator-anonymity claim. `channels-protocol.md` — new §Owner addition, and the `x.grp.owner.*` events in the signing table. Remove the four stale TODOs this closes: `Internal.hs:1508`, `Subscriber.hs:1426`, `Subscriber.hs:1451`, `Groups.hs:2320`. +**Relay resurrection (§4.2).** Either accept that a stale republish can restore a removed relay, or make relay removal element-wise (tombstones, or a version on the link data). Owner removal has the identical shape, so the two are best decided together.