diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index 8d09138f16..1ac1878e95 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -2,7 +2,9 @@ A channel can have N equal owners, per `docs/protocol/channels-overview.md` §Governance ("v7: any-owner-decides"). Any owner can take any administrative action, including destroying the channel; no coordination between owners. Owner *removal* (handing over ownership, dropping an owner) is in scope — it is the point of the feature: a channel must survive losing any single owner. -Only the creator can do this today. simplexmq already models most of it and leaves it unwired: `UserContactData.owners :: [OwnerAuth]` (a signed chain — each entry signed by root or an earlier owner), `decryptLinkData`/`validateLinkOwners` accept data signed by any listed owner, and `RKEY`/`recipientKeys :: NonEmpty` let a queue have many writers. The single-owner assumption lives almost entirely in `groupLinkData` (`Internal.hs:1543`, self-only owners at `:1549-1554`), which republishes only *self* as the owner. +Only the creator can do this today. simplexmq already models most of it and leaves it unwired: `UserContactData.owners :: [OwnerAuth]` (a signed chain — each entry signed by root or an earlier owner), `decryptLinkData`/`validateLinkOwners` accept data signed by any listed owner, and `RKEY`/`recipientKeys :: NonEmpty` let a queue have many writers. The single-owner assumption lives almost entirely in `groupLinkData` (`Internal.hs:1670`, self-only owners at `:1676-1682`), which republishes only *self* as the owner. + +**One key per owner.** An owner's member key — the key that signs its chat messages — is also its `OwnerAuth.ownerKey`, the key that signs the link data it writes (the creator's writes stay signed with the root key), and its recipient key on the link queue. The first two are fixed by today's code: joiners and relays take an owner's member key from its chain entry (`Commands.hs:2327-2332`, `Store/Groups.hs:2367-2373`), and link data must be signed by the root key or a chain key (`ShortLink.hs:114-116`). Making the recipient key the same key is what lets every owner know the queue's key set from the published chain (§3). ## 1. The frame: every shared state needs a sequencer @@ -11,7 +13,7 @@ The whole problem is ordering concurrent writes, and the answer is fixed by **wh | State | Lives on | Sequencer | How it converges | |---|---|---|---| | **Link data** (profile, relay list, owners chain) | one blob on the SMP link queue | the **SMP server** | hash-CAS: write-with-expected-hash, rejection returns current blob, re-merge | -| **Owner queue keys** (`recipientKeys`) | one set on the SMP link queue | the **SMP server** | hash-CAS, same shape; + an owner-held key→owner map | +| **Owner queue keys** (`recipientKeys`) | one set on the SMP link queue | the **SMP server** | hash-CAS, same shape; the published owners chain is the key map | | **Roster** (member/mod/admin roles) | a signed copy on every relay; no trusted central copy | **none** | deterministic tie-break + owner-side auto-retry | | **Subscriber count** | derived, cosmetic | none — any owner, on channel open | relay-sourced max, timestamp freshness-gate | @@ -21,23 +23,31 @@ Link data and keys already *have* a sequencer — the server — so they need on **What:** the mutable link blob — channel profile, relay list, owners chain, subscriber count. Read by every joiner; written by any owner. The server stores it encrypted and cannot read it. -**Sequencer — a content hash for the server CAS, a version inside the data for event ordering.** These are two tokens serving two layers. The server's CAS token is the **hash of the stored encrypted user-data blob** — the only thing the server can key on, since it cannot read the content. `LSET` carries the expected hash; the server accepts iff `hash(stored) == expected`, stores the new blob, and a mismatch **rejects and returns the current blob**. The hash must be taken where the exact uploaded ciphertext exists — **inside the agent's write path**: encryption uses a fresh random nonce, so the chat layer cannot reproduce the stored bytes by re-encrypting the plaintext. The agent hashes the encrypted blob it uploaded on an accepted write (or the blob the server returns on a rejection) and hands that hash back to chat; chat never re-derives it. Separately, a **monotonic `version` lives inside the mutable data** (invisible to the server); it exists only so recipients can **reject stale events** — an event whose version is below what a recipient already holds is ignored. This version is **advisory**: it is client-set and unverifiable, so correctness rests on the server-enforced hash-CAS and on reconcile-on-open, which reads the actual blob. A corrupted or saturated version (any owner is trusted, §8) can only make recipients skip live events until their next reconcile-on-open — it cannot corrupt stored state. So: hash = server CAS token (server-visible, unordered); in-content version = advisory client-side ordering. A rejected owner re-merges its one change onto the returned blob and retries. +**Sequencer — a content hash for the server CAS, a version inside the data for event ordering.** These are two tokens serving two layers. The server's CAS token is the **SHA-256 hash of the stored encrypted user-data blob** — the only thing the server can key on, since it cannot read the content. The new `LSETH` carries the expected hash and only the encrypted user data — the server keeps the stored fixed data, which a promoted owner could not reproduce byte for byte. The server accepts iff `hash(stored) == expected`, stores the new blob and replies `OK`; a mismatch **rejects and returns the current blob**. The hash must be taken where the exact uploaded ciphertext exists — **inside the agent's write path**: encryption uses a fresh random nonce, so the chat layer cannot reproduce the stored bytes by re-encrypting the plaintext. The agent hashes the encrypted blob it uploaded on an accepted write (or the blob the server returns on a rejection) and hands that hash back to chat; chat never re-derives it. Separately, a **monotonic `version` lives inside the mutable data** (invisible to the server); it exists only so recipients can **reject stale events** — an event whose version is below what a recipient already holds is ignored. This version is **advisory**: it is client-set and unverifiable, so correctness rests on the server-enforced hash-CAS and on reconcile-on-open, which reads the actual blob. A corrupted or saturated version (any owner is trusted, §8) can only make recipients skip live events until their next reconcile-on-open — it cannot corrupt stored state. So: hash = server CAS token (server-visible, unordered); in-content version = advisory client-side ordering. A rejected owner re-merges its one change onto the returned blob and retries. -**Both tokens travel in events, so writers rarely pre-read.** The events that change link data (`XGrpInfo`, `XGrpRelayNew`, the owner add/remove/revoke events, the count publish) carry the new `(version, hash)`. Owners hold current state + version + hash from those events, so a write CASes on the hash it holds and succeeds — the only rejections are genuinely simultaneous writes. **Order: write first (`LSET`-CAS), then broadcast the event with the hash of what was written** — a rejected-then-retried write would otherwise announce the wrong hash. **`LGET` is never needed to write** — a stale writer is simply rejected and gets the current blob back. +**Both tokens travel in events, so writers rarely pre-read.** An accepted write is announced by the event for its change — `XGrpInfo` (profile), `XGrpRelayNew` (relay added), `x.grp.owner.add` (owner added, §6), `x.grp.mem.del` of an owner (owner revoked, §7) — carrying `(version, baseHash, hash)`, where `baseHash` is the hash the write replaced. An event carries them only when it describes its write's entire change. **Order: write first (`LSETH`), then broadcast** — a rejected-then-retried write would otherwise announce the wrong hash. A receiving owner applies the content and adopts `hash` as its CAS token **only if `baseHash` is the hash it holds**, so its local state is exactly the blob that hash certifies. Otherwise — it missed an earlier event, or the write was not announced with hashes (the count publish, a relay-departure publish, a write with several changes) — it keeps its old token, and its next write is rejected, gets the current blob back and reconciles. Adopting a hash without holding the content it certifies would let that owner's next write pass the CAS while merging onto a stale view, silently overwriting what it missed. **`LGET` is never needed to write** — a stale writer is simply rejected and gets the current blob back. -**One reconcile routine keeps local state in sync; `groupLinkData` always builds from local.** "Reconcile" merges published link data into local state per field. Set fields (relays, owners, revoked-owners) merge as a **union keyed by identity** (memberId for owners) — idempotent, so an owner's own not-yet-published additions survive a merge without a stored delta. A whole-value field (profile/prefs) cannot self-identify a pending edit, so an owner keeps a **durable per-field pending edit** — the base hash it edited from plus the new value, cleared on an accepted `LSET`; reconcile diffs local against published relative to that base to classify adopt / keep-pending / surface-conflict (a genuine concurrent same-field edit). Because local state thus stays "published + my pending edits", `groupLinkData` builds the write from local state — no separate delta step. Reconcile runs in three places, all the same code: **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — the owner-exclusion is removed — to adopt others' changes, connect to newly-added relays, and correct revocations), **on a write rejection** (adopt the returned blob, then rebuild), and as the missed-event backstop. The `owners` list keeps **insertion (signer) order** — a new entry is appended after the entry that signed it, so `validateLinkOwners` ("signed by root or an earlier entry") holds as-is, with no ordering change and no leader index to maintain; the union merge dedups by memberId, so concurrent adds of the same member collapse to one entry. (The key-set hash in §3 *is* recomputed by every owner and so genuinely needs a canonical sort; the owners blob is hashed as ciphertext and never recomputed, so it does not.) `revokedOwners` is a list of `memberId` in the mutable data, append-only (the remover writes it on removal, the leaver on leave, any owner corrects an inconsistency it sees on reconcile). The count comes from relays (§5). +**One reconcile routine keeps local state in sync; `groupLinkData` always builds from local.** "Reconcile" merges published link data into local state, by the kind of field: -**One more existing defect:** relay activation must key off an *accepted* write, not the `LINK` echo (`Agent.hs:1814` notifies `LINK link userLinkData` — the client's own sent data, so today an owner marks a relay active because it asked to — nothing reads back the truth). +- **Owners and `revokedOwners`** merge as a union keyed by memberId; `revokedOwners` is the permanent tombstone. Removal adds a `revokedOwners` entry (§7), and a revoked owner's chain entry is dropped once no current entry depends on it (§7), so the union never brings a dropped entry back; an owner's own not-yet-published addition survives a merge without a stored delta, and concurrent adds of the same member collapse to one entry. +- **Relays:** the published set is authoritative, except this owner's own in-progress relay changes, which already live durably in its relay rows (a relay it is adding and has not published yet, a relay it is removing). Each owner caches the last published relay list it knows — a promoted owner reads the link data to fill it before its first write — and a write's relay list is that cache plus its own in-progress additions minus its own removals; a write with an empty relay list is refused unless the owner is removing the last relay — today the written list comes only from the writer's own `group_relays` rows (`Store/Groups.hs:1528-1545`), which reference its own chat relays (`chat_schema.sql:840`), so a promoted owner would publish no relays at all. A relay another owner removed is therefore not resurrected by this owner's next write, and a relay another owner added is not dropped. +- **Profile and preferences** (whole values): the published value is adopted. An owner's own profile edit is synchronous and write-then-commit: it keeps the profile it started from in memory, writes, and commits locally only on an accepted `LSETH`. On a rejection it merges field by field against the profile it started from — a field only it changed keeps its value, a field only the other owner changed takes theirs, and a field both changed differently is a conflict, surfaced ("profile edit superseded") with the published value kept — then retries. Today `runUpdateGroupProfile` commits locally (`Commands.hs:4172-4174`) before the link write (`:4190`); the order is reversed. + +Because local state thus stays "published + my in-progress changes" (an owner I am adding, relays I am adding or removing), `groupLinkData` builds the write from local state — no separate delta step. Reconcile runs in four places, all the same code: **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — the owner exclusion is removed, in core at `Commands.hs:1968` and in the apps — with an owner relay step in place of `syncSubscriberRelays`, which would delete this owner's not-yet-published relays and join other owners' relays as a subscriber; the owner step deactivates relays absent from the published data that this owner is not adding, and connects to relays other owners added by joining through the relay's link as itself — which needs a relay change: today a relay accepts a join onto an existing member only for roster roles (`memberJoinRequestViaRelay`, `Subscriber.hs:1696-1706`), so an owner falls through to a fresh member insert; the relay instead accepts a join from a current, non-revoked owner that is signed with that owner's key, with the same `viaRelay` replay check, onto the existing owner row with role owner; on the owner's side, completing that join updates only the relay member — the subscriber path it reuses would overwrite the owner's group profile with the relay's copy and, for the creator, its membership role and status (`updatePreparedUserAndHostMembers'`, `Store/Groups.hs:821-844`)), **when the owner's chat starts** (so owners that never open the channel view — CLI, bots — also heal revocations and publish the count), **on a write rejection** (adopt the returned blob, then rebuild), and as the missed-event backstop. The `owners` list keeps **insertion (signer) order** — a new entry is appended after the entry that signed it, so `validateLinkOwners` ("signed by root or an earlier entry") holds as-is, with no ordering change; the union merge dedups by memberId. (The key-set hash in §3 *is* recomputed by every owner and so genuinely needs a canonical sort; the owners blob is hashed as ciphertext and never recomputed, so it does not.) `revokedOwners` is a list of `memberId` in the mutable data, append-only (the remover writes it on removal, the leaver on leave, any owner corrects an inconsistency it sees on reconcile). The count comes from relays (§5). + +**Relay activation keys off the agent's `LINK` event** — or, for a promoted owner's foreign write, which produces none, off its `OK` (§9) — and `LINK` fires only after the server accepted the write (`Agent.hs:2255-2258`); under CAS it fires after the accepted `LSETH` and carries the data actually written, after any re-merge. A relay that another owner removed never produces a `LINK` on this device (the TODO at `Subscriber.hs:1475-1477`); reconcile deactivates it. ## 3. Owner queue keys -**What:** the set of recipient auth public keys allowed to write the link queue = the owners' link-write keys. A promoted owner generates one (`linkRcvKey`) and it is added via `RKEY`. +**What:** the set of recipient auth public keys allowed to write the link queue = the owners' member keys. A promoted owner's member key is added via `RKEYH`. Recipient keys are Ed25519 (`rcvAuthAlg`, `Agent/Env/SQLite.hs:227`), as member keys are. -**Sequencer — the server, hash-CAS.** The CAS token is the hash of the canonically-sorted key set. Unlike link data, the key set is *not* nonce-encrypted — the server holds the keys in the clear and any owner knows them (via the map below) — so **both sides compute the same hash from the content, and an owner rarely needs a rejection or an event to learn it**: it just hashes the set it believes is current. `RKEY` carries the expected hash and the full new set; a mismatch rejects and returns the current set. Two concurrent additions: one wins, the other re-merges its key onto the returned set and retries, so both land. CAS is needed because `RKEY` *replaces* — without it, two concurrent adds each drop the other's key. +**Why the member key.** Writes to the link queue cannot be anonymous among owners whatever the queue keys are: link data is signed by the writing owner's chain key inside a blob that any holder of the public channel link decrypts (`ShortLink.hs:108-116`), and the server knows which recipient key authorised each command (`Server.hs:1333-1334`). So an SMP operator that holds the link attributes every write to an owner, and a separate per-owner queue key would hide nothing while needing its own key→owner map distributed among owners. With the member key, the published chain *is* that map: every owner computes the queue's key set as the `ownerKey` of each chain entry not in `revokedOwners`, and removal reads the target's key from its entry. -**Correlation for removal — a signed map, cached on relays, never on the link server.** To remove owner X you must know *which* key is X's, but the server's set is deliberately **anonymous**: any public or link-server key→owner map lets a joining SMP operator (who reads the OwnerAuth chain and sees which key writes) tie an owner's link-queue activity to their identity. So the `memberId → linkRcvKey` map lives elsewhere: a new owner announces its key (own protocol event, delivered owner-scoped via `getGroupOwners`/a `DJSOwners` scope), and — critically — **relays cache the signed map and serve it on request**. Each entry is owner-signed, so a relay cannot forge one; a relay sees the map but is not the link-queue operator, so it cannot correlate link-queue writes. Because relays are always-on, an owner can always obtain the map without waiting for a peer owner. The map is also what lets an owner compute the key-set hash locally (above). +**The creator's key.** The creator's link queue was created with an agent-generated recipient key. Before its first promotion the creator rotates it to its member key in three resumable steps — `RKEYH` adding the member key, persisting it as the queue's signing key, `RKEYH` dropping the old key — each CAS-checked, so a crash between steps is finished on retry. -**So removal never blocks and self-heals:** it is an **async command** whose steps chain by continuation (fetch the map from a relay if the local copy lacks the target → `LSET` the revocation into `revokedOwners` → `RKEY` out the key), CAS rejections retrying inline — no new durable-worker type; the async command's own durable state resumes it after a restart. **Every `RKEY` payload is built by applying the delta to the server's *current* set** — the believed set, or the set the server returns on a CAS rejection — never by rebuilding the full set from the local map, which would silently drop a key another owner just added (e.g. a concurrently-promoted owner's). Removal uses the map only to *identify* the target's key, then sends *current-set minus that key*. The revocation is written **first**, so a crash before the `RKEY` leaves the *healable* state — marked revoked, key still live. `revokedOwners` is readable from the stored link blob (fetched and decrypted by owners on reconcile-on-open, §2); `recipientKeys` is not — it is write-only, with no read command — so healing can only run in that direction: an owner that sees a not-yet-enforced revocation subtracts that key from the current set with one `RKEY` (a CAS mismatch surfaces a still-present key and the retry drops it; an already-absent key is a harmless no-op). The reverse order (key dropped before the revocation) would be undetectable, which is why revocation must precede the key-drop, matching leave (§7). A missing *addition* self-heals via CAS. **`recipientKeys` is `NonEmpty`** — the server rejects a zero-length set at parse, but it has no notion of owner or revocation, so it enforces `>= 1` *key*, not `>= 1` *live* owner. "Do not remove/leave the last live owner" is therefore a **client-side precondition** (local `owners` minus `revokedOwners`); a stale view under a concurrent leave + removal, or **concurrent mutual removal** (O1 and O2 each remove the other), can leave the channel with no recognized owner — both marked in `revokedOwners`, one key still live. This is deliberately **not auto-healed**: an un-revoke gated on the un-revoke `LSET` succeeding proves only momentary key-liveness (all recipient commands authorize against `recipientKeys`), not that the peer's `RKEY` has landed, so it could clear the mark and then lose its key — an *undetectable* ghost, strictly worse than the *detectable* bricking (both revoked, readable). Mutual removal is accepted as a rare residual, no worse than "any owner can destroy the channel" (§8), rather than healed with a fragile rule. A relay withholding the map is handled by asking another relay. +**Sequencer — the server, hash-CAS.** The CAS token is the SHA-256 hash of the sorted key set (the server stores the set sorted). `RKEYH` carries the expected hash and the full new set; success replies `OK`, a mismatch rejects and returns the current set. Since every owner computes the set from the chain, a rejection happens only while a key change is in flight (a promotion between its `RKEYH` and `LSETH`, a removal between its `LSETH` and `RKEYH`); the returned set settles it. Two concurrent additions: one wins, the other re-merges its key onto the returned set and retries, so both land. CAS is needed because `RKEY` *replaces* — without it, two concurrent adds each drop the other's key. + +**So removal never blocks and self-heals:** it is an **async command** whose steps chain by continuation (`LSETH` the revocation into `revokedOwners` → `RKEYH` out the key → announce, §7), CAS rejections retrying inline — no new durable-worker type; the removal's durable step (§9) resumes it after a restart. **Every `RKEYH` payload is built by applying the delta to the server's *current* set** — the believed set, or the set the server returns on a CAS rejection — never by rebuilding the full set from the chain, which would silently drop a key another owner just added (e.g. a concurrently-promoted owner's, whose `RKEYH` precedes its chain entry). The revocation is written **first** because joiners read `revokedOwners` from the link data and never see the key set: published first, it stops new joiners treating the target as an owner at once, and a crash before the `RKEYH` leaves the *healable* state — marked revoked, key still live. An owner that sees, on reconcile, a revocation this device has not yet confirmed enforced issues one `RKEYH` whose expected set still contains that key and whose new set drops it (a CAS mismatch returns the current set, which shows whether the key is still there; an already-absent key needs nothing more) and records it as enforced. Only a revocation first seen in the link data needs this: the removal or leave announcement is sent after the key drop (§7), so an owner that receives it records the revocation as enforced at once, and the link server sees no extra direct connections. A missing *addition* self-heals via CAS. **`recipientKeys` is `NonEmpty`** — the server rejects a zero-length set at parse, but it has no notion of owner or revocation, so it enforces `>= 1` *key*, not `>= 1` *live* owner. "Do not remove/leave the last live owner" is therefore a **client-side precondition** (local `owners` minus `revokedOwners`); a stale view under a concurrent leave + removal, or **concurrent mutual removal** (O1 and O2 each remove the other), can leave the channel with no recognized owner — both marked in `revokedOwners`, one key still live. This is deliberately **not auto-healed**: an un-revoke gated on the un-revoke `LSET` succeeding proves only momentary key-liveness (all recipient commands authorize against `recipientKeys`), not that the peer's `RKEY` has landed, so it could clear the mark and then lose its key — a ghost owner listed as live with no key, which nothing detects, strictly worse than the *detectable* bricking (both revoked, readable). Mutual removal is accepted as a rare residual, no worse than "any owner can destroy the channel" (§8), rather than healed with a fragile rule. ## 4. Roster @@ -45,9 +55,9 @@ Link data and keys already *have* a sequencer — the server — so they need on **No sequencer exists** (a copy sits on each relay; relays are untrusted and cannot sign), so convergence is owner-side, in three parts: -1. **Union in live state; deterministic winner for the served blob.** The two representations converge by different rules. The signed per-member events *compose*: the event gate (the `fresh` check, `Subscriber.hs:3304`, `version >= held`, author-blind) keeps applying every delta, so two owners' concurrent *different-member* changes both land — the union; adding an author tie-break here would wrongly drop one. The served **blob** is instead made deterministic by *adding* a `(version, authorMemberId)` tie-break to the completion gate (`:3462-3468`, today a version-only downgrade check that then last-writer-overwrites via `setGroupLiveRoster`). The existing downgrade bound stays: reject `pendingVer < roster_version` (the gate, which per-member deltas also advance — this is what prevents applying a full snapshot older than intervening deltas). What changes is the equal-version case, which today overwrites by arrival order: the incoming blob replaces the held one iff `pendingVer > stored_roster_version`, or `pendingVer == stored_roster_version` and `author > storedAuthor`. The tie-break author is compared against the **stored blob's own** `storedAuthor`, never the gate `roster_version` (which carries no blob author), so a delta cannot corrupt the comparison. The author is stored **with the blob** — a cross-device `memberId` beside `stored_roster_version` (`roster_sending_owner_gm_id` today is a device-local `GroupMemberId`), written only by `setGroupLiveRoster`; the delta path (`setGroupRosterVersion`) advances the gate and writes no blob author. `memberId` is identical on every device, so every relay serves the same snapshot regardless of arrival order. +1. **Union from the deltas; deterministic winner for the served blob.** The signed per-member events *compose*: the event gate (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`, author-blind) applies every delta, so two owners' concurrent *different-member* changes both land; adding an author tie-break here would wrongly drop one. A completed blob, however, replaces the roster: privileged members absent from it revert to the default role (`processRosterEntries`, `Subscriber.hs:3610-3619`). So the blob that wins a same-version race reverts the losing owner's delta on every relay that completes it (and on members a relay later serves it to) until the loser's retry (2) lands. The served **blob** is made deterministic by *adding* a `(version, authorMemberId)` tie-break to the completion gate (`Subscriber.hs:3572-3578`, today a version-only downgrade check that then last-writer-overwrites via `setGroupLiveRoster`). The existing downgrade bound stays: reject `pendingVer < roster_version` (the gate, which per-member deltas also advance — this is what prevents applying a full snapshot older than intervening deltas). What changes is the equal-version case, which today overwrites by arrival order: the incoming blob replaces the held one iff `pendingVer > stored_roster_version`, or `pendingVer == stored_roster_version` and `author > storedAuthor`. The tie-break author is compared against the **stored blob's own** `storedAuthor`, never the gate `roster_version` (which carries no blob author), so a delta cannot corrupt the comparison. The author is stored **with the blob** — a cross-device `memberId` beside `stored_roster_version` (`roster_sending_owner_gm_id` today is a device-local `GroupMemberId`), written only by `setGroupLiveRoster`; the delta path (`setGroupRosterVersion`) advances the gate and writes no blob author. `memberId` is identical on every device, so every relay serves the same snapshot regardless of arrival order. A roster never changes an owner's role: applying a roster skips members who are currently owners. Without that, a member promoted to owner from a roster role would be set back to that role by the blob relays still store — today the apply sets each listed member to its listed role (`processRosterEntries`, `Subscriber.hs:3622-3628`), and a joiner receives the stored blob right after its owner intros (`introduceInChannel`, `Internal.hs:1330-1331`). A relay being set up acks the roster version it was sent, and stays unpublished until the ack matches the owner's current version; with several owners another owner's delta can advance that version before the ack arrives, so an ack below the current version makes the owner re-send its current roster to that relay — today it only logs a warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`), and the owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would wait forever. -2. **Auto-retry to the union.** The winning blob may lack a concurrent *different-member* change, so the served snapshot must be repaired — and without waiting on any one owner to return. Each owner keeps its **pending role-deltas** durably (member, pre-role, target-role, version) and receives every other owner's `x.grp.mem.role` via `DJSGroup` fan-out (all members, not just relays/joiners), so the repair is event-driven with no new relay behavior: an owner holding an unsatisfied delta that sees another owner's *concurrent* role event (same version — evidence its own just-sent blob may have lost the tie-break) re-broadcasts its now-union local state at a higher version. **Both** owners do this, each for its own member, so O1 (X→admin) and O2 (Y→admin) converge the served blob to *both*. A delta is **cleared** the moment an observed roster shows its member at the target role; it is re-applied only while unsatisfied *and* the member still sits at the recorded pre-role — a member at some *other* role means a later change superseded it (clear, do not re-apply), which is what stops an old satisfied delta from resurrecting a value and ping-ponging with a legitimate later change. +2. **Auto-retry to the union.** The winning blob may lack a concurrent *different-member* change, so the served snapshot must be repaired — and without waiting on any one owner to return. Each owner keeps its **pending role-deltas** durably (member, pre-role, target-role, version) and receives every other owner's role events via `DJSGroup` fan-out (all members, not just relays/joiners), so the repair is event-driven with no new relay behavior. An *observed* roster is another owner's role event or blob, or a blob a relay served — never the owner's own local apply. When an observed roster shows a pending delta's member still at the delta's pre-role, the owner re-applies its unsatisfied deltas to its local roster and sends blob + deltas at one above the highest version it has seen. **Both** owners do this, each for its own member, so O1 (X→admin) and O2 (Y→admin) converge the served blob to *both*. A delta is **cleared** once an observed roster shows its member at the target role, or at a role other than the recorded pre-role — a later change superseded it (clear, do not re-apply), which is what stops an old delta from resurrecting a value and ping-ponging with a legitimate later change. 3. **True conflict.** If another owner emitted a *same-member* concurrent role event with a different role, that is a real conflict, not a dropped delta: the `(version, author)` tie-break value stands and is re-broadcast so the member converges, and the losing owner surfaces "your change to X was overridden" instead of re-applying. No silent revert. @@ -55,64 +65,76 @@ Link data and keys already *have* a sequencer — the server — so they need on ## 5. Subscriber count -**What:** the "subscribers: N" display — cosmetic, best-effort. Owners have no subscriber connections (subscribers connect to relays), so the count can only come from relays. +**What:** the "subscribers: N" display — cosmetic, best-effort. The creator hears of every joiner (relays announce each joiner to owners, `introduceInChannel`, `Internal.hs:1320-1326`), but an owner promoted later never learns the subscribers who joined before it, so its own tally stays low. The count must come from relays. -**Source — max across relays, with a timestamp.** Each relay batches its own *current absolute* subscriber count and broker timestamp with the join/leave/removal event it already sends owners — no extra SMP block, restated only when the count changes. An owner keeps the latest `(count, ts)` **per relay** (a field on the relay's member row); its estimate is the **max** count over *current* relays, carrying **that relay's own `ts`** — the value is always vouched by the relay that reported it, so a stale reading can never be republished as a fresh one. A relay that has left or been removed (`GSMemRemoved`/`GSMemLeft`) has its per-relay `(count, ts)` cleared so its stale reading stops pinning the max; a live relay that merely goes silent (crash) can still freeze the max at its last reading until it returns — an accepted residual for a cosmetic number. The max is what ignores a newly-added relay that subscribers are still slowly connecting to (its low count stays below the max until it catches up) while still decreasing on a real departure (the max-holding relay's own count drops, republished under its own fresh `ts`; a lagging second relay reporting a slightly lower number never lowers the max, so cross-relay skew causes no spurious decrease). A second cosmetic residual: when a shrink moves the max onto a *different* relay whose last reading is older than `subscriberCountTs`, the decrease is not published until that new max-holder next reports (its stale `ts` gates it out meanwhile) — the display stays briefly high and self-heals on the next reading; this is deliberately preferred over decoupling the `ts` from the max-holder, which would let one relay's fresh `ts` vouch for another's stale-high count and republish a wrong value. Deltas cannot do any of this: a leave is *deduplicated* across relays, so an owner cannot attribute a decrease; the per-relay count event, being distinct per relay, is not deduplicated. +**Source — max across relays, with a timestamp.** Each relay sends owners `x.grp.relay.count` with its own *current absolute* subscriber count and the time of that reading — directly to owners, as it sends the join announcement, and only when the count changes. The timestamp is the relay's own, carried in the event: the broker timestamp an owner sees is set by that owner's own queue server, so it would differ between owners for the same reading. An owner clamps the relay's `ts` to the broker timestamp of the message that carried it, so one relay's far-future clock cannot set a `subscriberCountTs` that no later reading beats. An owner keeps the latest `(count, ts)` **per relay** (a field on the relay's member row); its estimate is the **max** count over *current* relays, carrying **that relay's own `ts`** — the value is always vouched by the relay that reported it, so a stale reading can never be republished as a fresh one. A relay that has left or been removed (`GSMemRemoved`/`GSMemLeft`) has its per-relay `(count, ts)` cleared so its stale reading stops pinning the max; a live relay that merely goes silent (crash) can still freeze the max at its last reading until it returns — an accepted residual for a cosmetic number. The max is what ignores a newly-added relay that subscribers are still slowly connecting to (its low count stays below the max until it catches up) while still decreasing on a real departure (the max-holding relay's own count drops, republished under its own fresh `ts`; a lagging second relay reporting a slightly lower number never lowers the max, so cross-relay skew causes no spurious decrease). A second cosmetic residual: when a shrink moves the max onto a *different* relay whose last reading is older than `subscriberCountTs`, the decrease is not published until that new max-holder next reports (its stale `ts` gates it out meanwhile) — the display stays briefly high and self-heals on the next reading; this is deliberately preferred over decoupling the `ts` from the max-holder, which would let one relay's fresh `ts` vouch for another's stale-high count and republish a wrong value. Deltas cannot do any of this: a leave is *deduplicated* across relays, so an owner cannot attribute a decrease; the per-relay count event, being distinct per relay, is not deduplicated. -**Published on channel open, not on events.** Link data carries `(subscriberCount, subscriberCountTs)`. When an owner opens the channel it already reconciles link data (§2); if its estimate differs from the published count (by any amount) *and* the max-holder's `ts` is newer than `subscriberCountTs`, it publishes `(estimate, that ts)`. No delta threshold — whenever an owner opens, the exact current estimate is published if fresher, so a small/new channel converges to its true value (1 → 2 → 3) instead of being pinned by a threshold. The number refreshes at owner-open, not per join: while an owner holds the channel open the published value does not advance with each new joiner — acceptable for a cosmetic display, and it means publishing is gated by *channel open*, not by joins. The `ts` gate means an owner writes only when it is genuinely fresher — if the published count is already current it writes nothing. The timestamp is thus a **freshness gate** — a *behind* owner (stale data, older `ts`) stays quiet and cannot push a wrong value in either direction, which is what lets the update go **both ways** safely. Concurrent opens CAS: one wins, the other re-reads and sees it already fresh. This needs no leading owner, no handover, no staleness detection, no debounce, and no extra query — the count tracks owner activity (fine for a cosmetic number) and writes the link at most once per owner-open, not per join. (Use the relay's *broker* timestamp so owner clock skew is irrelevant. The freshness gate compares the max-holder's broker `ts` against `subscriberCountTs` — two relays' clocks when the max-holder differs from the last publisher's — so under relay clock skew a genuinely fresh reading can be briefly suppressed or a slightly stale one admitted; for a cosmetic count this is acceptable and self-corrects on a later open.) +**Published on channel open, not on events.** Link data carries `(subscriberCount, subscriberCountTs)`. When an owner opens the channel (or its chat starts, §2) it already reconciles link data; if its estimate differs from the published count (by any amount) *and* the max-holder's `ts` is newer than `subscriberCountTs`, it publishes `(estimate, that ts)`; a published `subscriberCountTs` later than the owner's own clock counts as stale. No delta threshold — whenever an owner reconciles, the exact current estimate is published if fresher, so a small/new channel converges to its true value (1 → 2 → 3) instead of being pinned by a threshold. The number refreshes at owner-open, not per join: while an owner holds the channel open the published value does not advance with each new joiner — acceptable for a cosmetic display, and it means publishing is gated by *channel open*, not by joins. The `ts` gate means an owner writes only when it is genuinely fresher — if the published count is already current it writes nothing. The timestamp is thus a **freshness gate** — a *behind* owner (stale data, older `ts`) stays quiet and cannot push a wrong value in either direction, which is what lets the update go **both ways** safely. Concurrent opens CAS: one wins, the other re-reads and sees it already fresh. This needs no leading owner, no handover, no staleness detection, no debounce, and no extra query — the count tracks owner activity (fine for a cosmetic number) and writes the link at most once per owner-open, not per join. (The freshness gate compares the max-holder relay's `ts` against `subscriberCountTs` — two relays' clocks when the max-holder differs from the last publisher's — so under relay clock skew a genuinely fresh reading can be briefly suppressed or a slightly stale one admitted; for a cosmetic count this is acceptable and self-corrects on a later open.) + +**This is the only count publish.** Today the owner branch of `updatePublicGroupData` (`Internal.hs:1620-1627`) republishes link data on every join, leave and removal, from six call sites, so an owner catching up after being offline would write once per queued event. It stops publishing for subscriber changes and publishes only when the publishable relay set changed (a relay member removed or left — it is also the relay-removal publish path). Every write other than this count publish carries the published `(subscriberCount, subscriberCountTs)` unchanged, and the owner branch stops recounting its own tally (`Internal.hs:1623`) — otherwise a profile or relay write would publish a promoted owner's low tally past the freshness gate. Only a sole live owner, while no current relay supports the count event, falls back to its own tally, with its own clock as `ts` — no other owner publishes then, and promoting a second owner requires every relay to support it (§9). A relay sends its current reading to an owner it learns of through `x.grp.owner.add`, so a newly promoted owner has readings without waiting for the next change. The relay-announcement threshold that assumes a single owner (`Subscriber.hs:1445-1449`, `publicMemberCount > 1`) uses the relay-sourced estimate instead. ## 6. Owner promotion (adding an owner) **Trigger:** `APIMembersRole … GROwner` on a channel starts an async, single-target invite flow instead of a plain role change. The UI warns the promoter to verify the invitee's key first (§8). -**Events** (role-generic, so a future non-owner promotion reuses them), all `requiresSignature`, delivered owner→relay→M in M's support scope: +**Events** (role-generic, so a future non-owner promotion reuses them), all `requiresSignature`; the first four are delivered owner→relay→M in M's support scope: ``` x.grp.promote.inv { invitationId, memberRole, roleData? } -- O1 -> M x.grp.promote.acpt { invitationId, memberKey, roleData? } -- M -> O1 x.grp.promote.reject { invitationId } -- M -> O1 x.grp.promote.cancel { invitationId } -- O1 -> M +x.grp.owner.add { memberId, ownerAuth } -- O1 -> all members and relays (the commit) ``` -`memberRole` is the offered role; `roleData` is an optional role-scoped block (owner: `linkRcvId` in inv; in acpt, M's `linkRcvKey` — **one key M generates once and reuses across concurrent invites**. The memberId-keyed owners merge (§2) already collapses two concurrent adds of M to a single `OwnerAuth`, so this is not about avoiding a duplicate entry; it is so that across concurrent promotion chains — where different owners may independently win the `RKEY` (key set) and `LSET` (owners chain) CAS races — `recipientKeys`, the surviving `OwnerAuth.ownerKey`, and M's materialized `linkPrivSigKey` all reference the *same* key, which two distinct keys could split, leaving M unable to write); `memberKey` is M's existing key, which O1 must check **equals the key it already holds for M** — a consistency check so the acceptance cannot introduce a new key (distinct from the OOB verification in §8, which is advisory). A future role adds a `roleData` variant; the parser must decode an unknown tag to an opaque/ignored value (not error), since `omittedField` only covers an *absent* field. +`memberRole` is the offered role; `roleData` is an optional role-scoped block (owner: `linkRcvId` in inv, nothing in acpt). `memberKey` is M's existing key, which O1 must check **equals the key it already holds for M**; O1 adds the key it holds, never a key from the wire — a consistency check, distinct from the OOB verification in §8, which is advisory. A future role adds a `roleData` variant; the parser must decode an unknown tag to an opaque/ignored value (not error), since `omittedField` only covers an *absent* field. -**Consent:** acceptance is not automatic — M confirms in the UI. A pending record on each side; reject or cancel clears it. On acceptance O1 chains, as an async command resumable from the pending record (no new worker type — the record is the durable state, re-driven on receipt and on startup): `RKEY` (add M's key, §3) → `LSET` (append M's `OwnerAuth` after its signer in `owners`, §2) → `x.grp.mem.role … GROwner` (the **commit point** — M treats itself as owner only on receiving this). O1 also announces M's key to the other owners and sends M the current key-map (§3). On receiving the commit, **M materializes its own agent-store link recipient credentials** — `rcvId = linkRcvId` (from the inv), `linkPrivSigKey` = its generated `linkRcvKey`, `linkRootSigKey` = the channel's root public key (from `FixedLinkData`), sender-side fixed data from the channel link — the input to the new `setForeignLinkData` write path (§9), since M as a former joiner has no recipient link connection and no existing agent API writes a link queue the device does not own. +**Consent:** acceptance is not automatic — M confirms in the UI. A pending record on each side; reject or cancel clears it. M holds at most one pending promotion: an `inv` that arrives while one is pending is rejected automatically, so two owners never run chains for the same member. Both records expire after a fixed TTL, checked at chat start and by the periodic cleanup — M's only until it accepts, since after acceptance it holds the `linkRcvId` the commit needs: the inviter's expiry undoes its chain (below) and sends `cancel`; M's record also clears on `cancel` and when the inviter is revoked, so a lost promoter cannot block other invitations. The creator reads its link queue's recipient id from its agent to put in the `inv`. On acceptance O1 chains, as an async command resumable from the pending record (no new worker type — the record, with the step reached, is the durable state, re-driven on receipt and on startup): it first refuses a memberId listed in `revokedOwners` (a revoked owner cannot be promoted again — its key would be added but its entry never written, since the merge excludes revoked memberIds) and checks that the link data still fits with M's entry added (§7); then, on the creator's first promotion, the key rotation (§3) → `RKEYH` (add M's member key, §3) → `LSETH` (append M's `OwnerAuth` after its signer in `owners`, §2) → `x.grp.owner.add` (the **commit point** — M treats itself as owner only on receiving this). + +**The commit announces the chain entry, not a role change.** `x.grp.owner.add` carries M's `OwnerAuth`. Every member and relay accepts M as an owner only if that entry validates — signed by the root key it stored at join, or by a current owner's key — and names M's memberId; it then sets M's key to `ownerAuth.ownerKey`, replacing any key a relay asserted for M (with a warning when they differ) and creating M if it was unknown. This is how members who joined before M learn M as an owner, and only with the chain's key. `x.grp.mem.role … GROwner` cannot do it: it would apply the role to whatever key a relay asserted for M in an unsigned intro (`Subscriber.hs:3465-3468`), handing a relay that substituted M's key owner authority, and would not create M for a member that never saw it (`:3458`). A new event also makes clients that predate it ignore the promotion and keep rejecting M's admin messages — failing closed — rather than applying it with a relay-asserted key. **Replay and self-reinstatement:** a relay could re-deliver an old, validly signed `x.grp.owner.add` for an owner since removed, and a removed owner still holds its own root-signed entry. So `x.grp.owner.add` is accepted only when its sender is a current owner, and never for a revoked memberId. Revoked memberIds live in a group-level record on every member's device and relay — seeded from `revokedOwners` at join and at relay invite, added on removal and leave events — not on the member row: member cleanup deletes a removed channel member's row a day after removal when it has no chat items (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2192-2203`; `cleanupRemovedMembers`, `Commands.hs:5960-5966`). A revoked memberId is never promoted again (above), so the record only grows. Members that miss an event converge on their next link-data refresh, which applies the chain — validated entries as owners, `revokedOwners` as revoked — where today it applies only the profile and count (`updateGroupFromLinkData`, `Internal.hs:1634-1654`): subscribers on channel open, relays in their periodic check, which already reads the link data every 15 minutes (`checkRelayServedGroups`, `Commands.hs:6001-6020`), and a relay at once when an owner join or `x.grp.owner.add` names a memberId it does not hold as owner. + +On receiving the commit for itself, **M has its link-write credentials** — the link server and `LinkKey` (from the channel link), `linkRcvId` (from the inv), the root public key (stored at join), and its member private key, which both signs the link data and authorises its link-queue commands — the input to the new foreign-link write path (§9), since M as a former joiner has no recipient link connection and no existing agent API writes a link queue the device does not own. + +**Cancel or abandonment after the key is added** must not leave M a live key: any recipient key authorises every recipient command, `DEL` and `LDEL` included (`Server.hs:1289-1290`). The pending record stores the step reached; cancelling (or the invitation expiring) after the `RKEYH` drops M's key with `RKEYH`, and after the `LSETH` also revokes M (§7), before the record is cleared. A residual remains: a promoter whose device is lost between its `RKEYH` and `LSETH` leaves M's member key in the key set with no chain entry — no owner status, but able to delete the queue. Another owner cannot tell it from a promotion still in flight, so it is not auto-removed; it is accepted, as M is a member who consented to becoming an owner. **Sign new owners with the root key when the adder holds it** (i.e. the creator, who keeps the root private key). Root-signed entries are each independently valid — `validateLinkOwners` accepts any entry signed by root — so all owners sit at one level and the list has no delegation dependencies. A non-creator owner has no root key, so it must sign with its member key (delegation), and *those* entries depend on their signer; the creator being the usual adder keeps the list flat, which is what makes removal (§7) simple. -**Transport is not confidential, and that is fine.** Support scope reaches M plus all moderators, so a moderator sees the invitation — but nothing secret is in it (`linkRcvId`/`linkRcvKey` are inert public data), and a moderator that *injects* a fake `x.grp.promote.inv` achieves nothing: completion needs an owner holding a matching pending record, which no one does, so no key or link write ever happens. M additionally verifies the `.inv` is owner-signed and ignores others. +**Transport is not confidential, and that is fine.** Support scope reaches M plus all moderators, so a moderator sees the invitation — but nothing secret is in it (`linkRcvId` authorises nothing without a key in `recipientKeys`), and a moderator that *injects* a fake `x.grp.promote.inv` achieves nothing: completion needs an owner holding a matching pending record, which no one does, so no key or link write ever happens. M additionally verifies the `.inv` is owner-signed and ignores others. ## 7. Owner removal -Add the target's `memberId` to **`revokedOwners`** in the mutable link data (`LSET`) **first**, then drop its key from `recipientKeys` (`RKEY`, targeting it via the owner-held map §3) — the same revoke-first order as leave, so a crash leaves the healable state (§3). **Keep the `OwnerAuth` entry** rather than deleting it — a revoked entry stays valid for verifying the owner's *past* signed messages, `revokedOwners` marks it as no longer current (so a new joiner does not treat it as an owner), and its key is gone so it can no longer write. This is uniform whether the entry is root-signed or delegated; for a delegated entry, keeping it also avoids breaking any owner it signed. Enforcement is at the SMP layer (no key = no authority), not in the chain. **Leave** is the same, self-sequenced by the leaver; if it crashes, a remaining owner finishes (§3). **The creator's root key is the one thing removal cannot revoke:** it is the immutable trust anchor in the signed `FixedLinkData`, distinct from the creator's link-write key. "Removing the creator" evicts that link-write key and marks its owner entry revoked — stopping its own queue writes — but it retains the power to mint new valid `OwnerAuth` entries; true creator eviction would require rotating the root key (re-signing `FixedLinkData` under a new root and re-issuing the link), which is out of scope. Removal is administrative hand-off, not a defence against a malicious owner (any owner can already destroy the channel). +Add the target's `memberId` to **`revokedOwners`** in the mutable link data (`LSETH`) **first**, then drop its key — the `ownerKey` of its chain entry — from `recipientKeys` (`RKEYH`), then announce the removal (`x.grp.mem.del`) — so a crash leaves the healable state (§3), and the target never processes its removal while its key could still delete the queue. **The revoked `OwnerAuth` entry is dropped from the chain unless a current entry depends on it** — a revoked entry that is an ancestor, through any number of delegations, of a current entry stays, because every entry must be signed by root or an earlier entry (`validateLinkOwners`, `Agent/Protocol.hs:2012`) and an orphaned entry would make every owner's agent refuse every write; a revoked entry with no current descendant, including any root-signed one (the usual case when the creator adds owners), is dropped. Members keep their own copies of owners' keys, so dropping an entry loses nothing they verify against, and `revokedOwners` stays as the tombstone that stops the union merge (§2) bringing it back. Dropping matters because link data is a fixed-size blob — user data is padded to 13,784 bytes (`ShortLink.hs:46`), the profile alone may take up to 10,240 bytes uncompressed (`encodeShortLinkData`, `Internal.hs:1704-1711`), and each entry is about 124 bytes — so an append-only chain would, over a channel's life, make every link write fail. Promotion checks the size before its `RKEYH` (§6), and with a large profile and three relays the chain holds roughly 20 entries. Enforcement is at the SMP layer (no key = no authority), not in the chain. **Leave** is the same, self-sequenced by the leaver: revoke itself, drop its own key, then send `x.grp.leave`; if it crashes, a remaining owner finishes (§3). + +**Leaving or being removed never deletes the link queue while another live owner remains.** Today the creator's client deletes its link connection — `DEL` on the shared link queue — when it leaves (`APILeaveGroup`, `Commands.hs:3246`), when it processes its own removal (`Subscriber.hs:3736`), and when an owner that is no longer current deletes the chat (`Commands.hs:1400`), all through `deleteGroupLinkIfExists` (`Internal.hs:1739-1748`), and any key still in `recipientKeys` authorises `DEL`. With another live owner, these delete the creator's link connection locally only (a new agent call that forgets a connection without `DEL`); the key drop that precedes them already makes a `DEL` fail. Destroying the channel still deletes the queue: the creator through its link connection, as today, and any other current owner — which has no link connection — through a foreign `DEL` with its own key (§9), so the link stops resolving even after the creator was removed. Only a current, non-revoked owner destroying the channel sends that `DEL`; deleting the chat locally (by a removed owner whose key a crash left live, say) never does. + +**The creator's root key is the one thing removal cannot revoke:** it is the immutable trust anchor in the signed `FixedLinkData`, distinct from the creator's link-write key. "Removing the creator" evicts that link-write key and marks its owner entry revoked — stopping its own queue writes — but it retains the power to mint new valid `OwnerAuth` entries; true creator eviction would require rotating the root key (re-signing `FixedLinkData` under a new root and re-issuing the link), which is out of scope. Removal is administrative hand-off, not a defence against a malicious owner (any owner can already destroy the channel). ## 8. Security -- **Promotion is only as safe as the verified key.** An owner's copy of a subscriber's key is relay-asserted (from unsigned `XGrpMemNew`), so a relay that substituted it could otherwise be promoted. The defence is out-of-band verification, which is **already implemented** for channels (`verifyChannelMemberCode`, `Commands.hs:3702`, called at `:2018`, hashes both members' keys, sorted, so comparing detects substitution). It is **advisory**: the promoter is warned in the UI to verify before promoting (matching the existing channel model), not hard-blocked in the backend. Residual risk: an owner who ignores the warning and promotes an unverified member could sign a relay-substituted key into the chain, making that relay an owner. The backend still enforces the weaker consistency check (§6) that the accepted key equals the one O1 already holds. -- **Any owner can destroy the channel** (`Server.hs:1249`: any recipient key authorises `DEL`/`LDEL`) and can RKEY the key set down to itself, evicting the others. Both accepted under any-owner-decides; recorded because they exceed any chat-level action. -- **Owner-key map is owner-only** (§3), so an SMP operator cannot tie an owner's link-queue writes to their identity. -- **Creator anonymity** is weaker than the overview claims once owner 2 signs owner 3 with its own key (the chain shows who delegated to whom). Qualify it. +- **Promotion is only as safe as the verified key.** An owner's copy of a subscriber's key is relay-asserted (from unsigned `XGrpMemNew`), so a relay that substituted it could otherwise be promoted. The defence is out-of-band verification, which is **already implemented** for channels (`verifyChannelMemberCode`, `Commands.hs:3826`, called at `:2074`, hashes both members' keys, sorted, so comparing detects substitution). It is **advisory**: the promoter is warned in the UI to verify before promoting (matching the existing channel model), not hard-blocked in the backend. Residual risk: an owner who ignores the warning and promotes an unverified member could sign a relay-substituted key into the chain, making that relay an owner. With one key per owner, the key that gains link-queue authority is exactly the member key the promoter was asked to verify; the backend also enforces the consistency check (§6) that the accepted key equals the one O1 already holds. +- **Members take an owner's key only from a validated chain entry** — from the link data at join, or from `x.grp.owner.add` (§6) — never from a relay-asserted intro. +- **Any owner can destroy the channel** (`Server.hs:1289-1290`: any recipient key authorises `DEL`/`LDEL`) and can RKEY the key set down to itself, evicting the others. Both accepted under any-owner-decides; recorded because they exceed any chat-level action. +- **Link writes are attributable to owners, and a promoted owner's network address is visible to the link server.** Any link holder can tell which owner signed each version of the link data (§3). Recipient commands are not proxied — the proxy forwards only `SEND`, `SKEY`, `LKEY`, `LGET` and `RSLV` (`Server.hs:2205-2211`) — so a promoted owner's device connects directly to the link server the creator chose, and that server's operator, holding the public link, can tie the owner's network address to its channel identity. The invitee's confirmation says so (§10); Tor or SOCKS avoids it. Foreign-link commands use a transport session of their own, one per link (the agent shares a session per user and server by default, `mkTSession`, `Agent/Client.hs:1442-1443`), so the link server cannot also tie them to the owner's other queues on that server. +- **The creator is identifiable among owners**, contrary to the overview's claim that root-signing several owners conceals it (`channels-overview.md:159`): its chain entry is first, the link data it writes is signed with the root key (`Agent.hs:1076`), and every owner it promotes appears root-signed in a promotion it announces. Ordering the chain by memberId would not hide it while the creator root-signs the owners it adds (§6). Qualify the overview. ## 9. Data model -- **SMP link queue (simplexmq server):** the CAS token per mutable object — the **hash of the stored user-data blob** for link data, and the hash of the sorted key set for `recipientKeys` (the server computes each on write; no version counter). The CAS belongs **inside the queue store, not the command handler**: in the Postgres store the mutable blob is not loaded into the in-memory `QueueRec` on the normal path (`rowToQueueRec` uses empty placeholders), so link-data hashing must be an `UPDATE … WHERE user_data_hash = expected` predicate with a `SELECT` of the current blob returned on a 0-row update; `recipientKeys` *is* loaded normally, so its hash-CAS can compare in memory — an asymmetry to respect. These wire changes gate on a new SMP **relay** version — bump **both** `currentClientSMPRelayVersion` and `currentServerSMPRelayVersion` (`VersionSMP` 18 → 19; the negotiated session version is the *min* of the two peers' relay versions, so the client constant must also advance for a client to perceive and exercise v19). This is the relay handshake version, *not* `currentSMPClientVersion` (the client↔client envelope, which gates nothing server-facing). **Multi-owner requires the link queue's server ≥ v19**, read from the connected link-server session: below it, blind `LSET` is merely lossy, but blind `RKEY` *evicts* other owners (replace with no CAS), so promotion must **fail closed** on an old link server, not degrade. -- **Agent store (owner device, both SQLite and Postgres trees):** the last-known link-data hash (computed by the agent from the encrypted blob at the moment of an accepted write and returned to chat — never re-derived by re-encrypting, since the nonce is fresh) + in-content version, cached next to the link credentials; a durable per-field **pending link-data edit** (base hash + pending value) for whole-value fields, for reconcile (§2); and **`linkRootSigKey` as a new durable column** — today `AgentStore.hs:2514` hardcodes it to `Nothing` (a `TODO`), so a promoted owner would have `validateOwners` treat its *own* key as the root; it must be populated on read and set when a promoted owner provisions its link creds (§6, the `setForeignLinkData` inputs). -- **Chat DB (owner device):** owners chain (`owner_auth_sig`/`owner_auth_index` on `group_members`, threaded through `createLinkOwnerMember`/`updateRelayGroupKeys`); pending-promotion record (member-row columns); pending role-deltas (member, pre-role, target-role, version); a cross-device roster-blob-author `memberId` on `groups` beside `stored_roster_version` (superseding the device-local `roster_sending_owner_gm_id` for the §4 blob tie-break); per-relay latest subscriber count + broker timestamp (fields on each relay's member row); the `memberId → linkRcvKey` map. Published link data (mutable) also carries `revokedOwners` (a list of `memberId`) and `subscriberCountTs` beside the owners chain and count; owners cache both locally for reconcile. A `DJSOwners` owner-only delivery scope for the key announcement. -- **Relay:** a cached signed `memberId → linkRcvKey` map, served on request (for owner removal). -- **Apps (iOS + Kotlin):** `canManageLink` on `GroupInfo`; `MemberRoleProposal` (`MRProposed`/`MRRejected`) + `promotionPending` on `GroupMember`; a new `RcvGroupEvent` case for the promotion service item — all optional/forward-compatible so remote-desktop parsing across versions holds. +- **SMP link queue (simplexmq server):** the CAS token per mutable object — the **SHA-256 hash of the stored user-data blob** for link data, and the SHA-256 hash of the sorted key set for `recipientKeys` (the server computes each on write; no version counter). The CAS must be atomic in both queue stores (the journal message store uses one or the other, `MsgStore/Journal.hs:107-117`): the STM store compares inside its transaction, and the Postgres store — with or without its cache — uses an `UPDATE … WHERE` predicate: it loads link data as empty placeholders (`rowToQueueRec`, `QueueStore/Postgres.hs:726-730`), and under the Postgres message store its queue lock is a no-op with no cache (`MsgStore/Postgres.hs:84`, `:100`), so two commands each hold their own copy and an in-memory compare would let concurrent writes both pass. A 0-row update reads and returns the current value. These wire changes gate on a new SMP **relay** version — `VersionSMP` 24, bumping **both** `currentClientSMPRelayVersion` and `currentServerSMPRelayVersion` from 23 (`Transport.hs:228-232`; the negotiated session version is the *min* of the two peers' relay versions, so the client constant must also advance for a client to perceive and exercise it); `proxiedSMPRelayVersion` stays at 23, since recipient commands are not proxied. This is the relay handshake version, *not* `currentSMPClientVersion` (the client↔client envelope, which gates nothing server-facing). **Multi-owner requires the link queue's server ≥ v24**, read from the connected link-server session: below it, blind `LSET` is merely lossy, but blind `RKEY` *evicts* other owners (replace with no CAS), so promotion must **fail closed** on an old link server, not degrade. +- **Agent (owner device):** no new stored state. The CAS APIs are stateless — the expected hash in; `OK` with the new hash, or the current state with its hash, out — and chat keeps the hashes. A promoted owner writes through a foreign-link API with explicit credentials (§6) — `LSETH`, `RKEYH` and `DEL` on a queue the device does not own — so no agent row needs `linkRootSigKey` (`AgentStore.hs:2903` keeps `Nothing` for the creator, whose link signing key is the root). Foreign writes are synchronous: an agent async command reports to a connection, and a promoted owner has no link connection; callers that are asynchronous today run them in a thread, and their intents are durable in chat state (relay rows, promotion and removal records) that reconcile re-drives. The creator's key rotation (§3), a read of its own link queue's recipient id (§6), and forgetting a connection without `DEL` (§7) are new agent operations. +- **Chat (owner device), one writer for every link write:** each link-write site resolves the writer once — the creator's own link connection, or a promoted owner's foreign-link credentials. Today every site needs the creator's group link row or connection, and the profile write silently skips without one (`setGroupLinkData'`, `Internal.hs:1581-1586`). Relay activation, which today runs in the handler of the agent's `LINK` event (`Subscriber.hs:1431-1481`), becomes a function of the written data, called from that event and after a foreign write returns `OK` — a foreign write produces no `LINK` event. +- **Chat DB (owner device):** owners chain (`owner_auth_sig`/`owner_auth_index` on `group_members`, threaded through `createLinkOwnerMember`/`updateRelayGroupKeys`); a revoked-owner record on every member's device — memberId, key, whether this device confirmed the key drop, and for a removal this device runs the step reached, so a crashed removal is re-driven at chat start — in a group-level table that member cleanup does not touch (§6); the last published relay list (§2); pending-promotion record (member-row columns, with the step reached); pending role-deltas (member, pre-role, target-role, version); a cross-device roster-blob-author `memberId` on `groups` beside `stored_roster_version` (superseding the device-local `roster_sending_owner_gm_id` for the §4 blob tie-break); per-relay latest subscriber count + the relay's timestamp (fields on each relay's member row); the last-known link-data and key-set hashes; and, for a promoted owner, the link queue's `linkRcvId` (its server, `LinkKey`, root key and member key are already stored). Published link data — chat's `GroupShortLinkData` JSON inside `UserContactData.userData`, so simplexmq's link-data encoding is unchanged — gains `linkDataVersion`, `revokedOwners` (a list of `memberId`) and `subscriberCountTs` beside the count; owners cache them locally for reconcile. +- **Relay:** creates owner rows for chain entries it has none for, skipping entries in `revokedOwners` (today `updateRelayGroupKeys` throws on a missing owner, `Store/Groups.hs:2366-2368`); keeps the revoked-owner record; applies `x.grp.owner.add`; applies the chain from the link data in its periodic check (§6); sends `x.grp.relay.count`; and accepts a signed join from a current owner onto its existing row (§2). A relay without these keeps a promoted owner an observer and drops its posts, never forwards `x.grp.owner.add` (`isForwardedGroupMsg`, `Protocol.hs:528`), and reports no count. So promotion requires every current relay to support the multi-owner chat version — owners already check a relay's version (`supportsVersion`, `Subscriber.hs:933`) — and is refused until they all do. Adding a relay to a channel with more than one owner fails closed instead: a relay's version is known only once it answers, and an older relay never answers (it throws on the owner it has no row for), so the owner times out the invitation and shows an error. +- **Apps (iOS + Kotlin):** `canManageLink` on `GroupInfo`; a backend-supplied flag on `GroupInfo` that another live owner exists (the chat list has no member list); `MemberRoleProposal` (`MRProposed`/`MRRejected`) + `promotionPending` on `GroupMember`; a new `RcvGroupEvent` case for the promotion service item — all optional/forward-compatible so remote-desktop parsing across versions holds. ## 10. UI/UX - **Promoter:** keep the role picker, add `.owner`; selecting it sends the invitation and shows a proposed-role row (proposed → invitation-sent + Cancel; rejected → declined + re-invite). Warn if the invitee's key is unverified and route to the verification screen — the consequence is the channel's trust chain, not one conversation. -- **Invitee:** a service chat item (unread) plus an accept/reject banner in M's support chat; accept goes through a confirmation stating any owner can delete the channel. (The banner is new code, not a reuse — the existing pending-member bar is gated on a non-null scope member, and M's own support scope has none.) +- **Invitee:** a service chat item (unread) plus an accept/reject banner in M's support chat; accept goes through a confirmation stating that any owner can delete the channel and that this device will connect directly to the channel's link server (§8). (The banner is new code, not a reuse — the existing pending-member bar is gated on a non-null scope member, and M's own support scope has none.) - **`canManageLink`** gates link/relay *management* (an owner mid-handover cannot write yet); relay *status display* stays on `isOwner`. +- **Leave:** an owner can leave a channel when another live owner exists (the backend flag, §9); the last live owner cannot. - **Conflict surfaces** (both rare): the losing owner is told "profile edit superseded" (link-data same-field conflict) or "your role change to X was overridden" (roster same-member conflict). No special affordance — the owner re-applies through the normal picker/edit if they still want to. ## 11. Open decisions -None outstanding. - -Settled this round: the `owners` list keeps insertion (signer) order rather than a memberId sort, so `validateLinkOwners` needs no order-independence change and delegated (post-creator-removal) additions validate as-is (§2) — this was the sole reason for the earlier `validateLinkOwners`-order question, now moot; the creator's root key is unrevocable, so "creator removal" evicts its link-write key but not its signing authority (§7); the subscriber count publishes on channel open with a timestamp freshness-gate — no leader, no handover (§5); multi-owner hard-blocks on a pre-v19 link server (§9, no degraded mode); the roster approach is union-at-the-event-gate + a blob tie-break keyed off the stored blob's own version and author + owner-side auto-retry driven by the co-owners' own role events (`DJSGroup` fan-out), no leader (§4); a same-member conflict is surfaced and re-applied through normal UI (§10). - -A detailed change-list (per-file edits, migrations, test cases) follows once this design is locked — deliberately omitted here to keep the design reviewable in one pass. +None. The implementation plan (`2026-07-21-channel-multi-owner-implementation.md`) lists what to build. --- diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index dc4cd94376..c191050b61 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -2,11 +2,11 @@ Companion to the design overview (`2026-07-15-channel-multi-owner.md`) — read that first for *why*. This covers *what to build*: data model, migrations, types, protocol events, commands, and sequencing. It is a map for implementing agents, not an exhaustive edit list; trivial wiring (JSON instances, `CMEventTag` cases, view strings) is implied. -The sequencer frame (overview §1) drives everything: link data and owner keys are sequenced by the SMP server via **hash-CAS** (the server keys on a content hash, not a version counter); the roster has no sequencer and converges by a deterministic blob tie-break plus owner-side auto-retry; the subscriber count is cosmetic and any owner corrects it on channel open. +The sequencer frame (overview §1) drives everything: link data and owner keys are sequenced by the SMP server via **hash-CAS** (the server keys on a content hash, not a version counter); the roster has no sequencer and converges by a deterministic blob tie-break plus owner-side auto-retry; the subscriber count is cosmetic and any owner corrects it on channel open. Each owner has **one key** — its member key is its `OwnerAuth.ownerKey`, its link-data signing key (the creator's link-data writes stay root-signed) and its link-queue recipient key — so the published owners chain is also the queue's key map. ## Sequencing -Two repos, in order. **simplexmq first** (the relay version bump, hash-CAS on the SMP link queue, the foreign-link write path, `linkRootSigKey`), then **simplex-chat** (data model, events, logic, apps). Multi-owner is gated on the link queue's server negotiating the new SMP relay version; below it, promotion is blocked, not degraded. +Two repos, in order. **simplexmq first** (the relay version bump, hash-CAS on the SMP link queue, the foreign-link write path, the creator's key rotation, forgetting a connection without `DEL`), then **simplex-chat** (data model, events, logic, apps). Multi-owner is gated on the link queue's server negotiating the new SMP relay version; below it, promotion is blocked, not degraded. Relays must accept channels with several owners a release before promotion is enabled (Part 8). --- @@ -14,21 +14,28 @@ Two repos, in order. **simplexmq first** (the relay version bump, hash-CAS on th ### 1.1 SMP server — hash-CAS on the link queue -- **Version bump.** New `VersionSMP` `linkCasSMPVersion = 19`; bump **both** `currentClientSMPRelayVersion` and `currentServerSMPRelayVersion` 18 → 19 (`Transport.hs:228`/`:234`). The negotiated session version is the *min* of the two peers' relay ranges, so the client constant must also advance for a client to perceive and exercise v19. This is the SMP *relay* version (`VersionSMP`, gating `Command`/`BrokerMsg` encoding) — **not** `currentSMPClientVersion`, which rides the client↔client envelope and gates nothing server-facing. -- **The CAS token is a content hash, not a counter.** For link data it is the hash of the stored *encrypted* user-data blob (the only thing the server can key on, since it cannot read the content); for `recipientKeys` it is the hash of the canonically sorted key set. No server version counters. -- **Where the CAS lives — inside the queue store, not the command handler.** In the Postgres store the mutable link blob is not loaded into the in-memory `QueueRec` on the normal path (`rowToQueueRec` uses empty placeholders), so link-data hashing cannot be done in the handler. Add a persisted `user_data_hash` column maintained on every link-data write, and CAS via `UPDATE … WHERE user_data_hash = expected`, returning the current blob on a 0-row update. `recipientKeys` *is* loaded normally in both stores, so the key-set hash-CAS compares in memory — an asymmetry to respect. -- **Two new recipient commands** (`Command Recipient`, gated ≥ v19; new tags rather than extending `LSET`/`RKEY`, so version-gating is clean): - - **`LSETH expectedHash linkId d`** — hash-CAS `LSET`. If `hash(stored) == expectedHash`: store `d`, reply the **new** hash. Else reject, replying `(currentHash, currentUserData)`. The check sits beside the existing `lnkId' /= lnkId -> err AUTH` in the `LSET` handler (`Server.hs:1484`). - - **`RKEYH expectedHash keys`** — hash-CAS `RKEY`. Same pattern on the sorted-key-set hash (`Server.hs:1483`, `updateKeys` `STM.hs:194`); reject replies `(currentHash, currentKeys)`. -- **New `BrokerMsg` responses:** success replies the new hash (small); a conflict replies `(hash, payload)` — `LSETH` returns the user-data blob, `RKEYH` the key set. (`LNK` already carries a `QueueLinkData`, so returning a blob is routine.) Returning current state on rejection is what removes the writer's pre-read. -- **Auth unchanged:** both are recipient commands, so any key in `recipientKeys` authorises them (`Server.hs:1248-1249`) — this is what lets a promoted owner write. `recipientKeys` stays `NonEmpty` (the parser rejects a zero-length set), so an `RKEYH` can never empty the set; the server has no notion of owner/revocation, so "≥ 1 *live* owner" is a client-side concern (Part 6). +- **Version bump.** New `VersionSMP` `linkCasSMPVersion = 24`; bump **both** `currentClientSMPRelayVersion` and `currentServerSMPRelayVersion` 23 → 24 (`Transport.hs:228-232`). The negotiated session version is the *min* of the two peers' relay ranges, so the client constant must also advance for a client to perceive and exercise v24. `proxiedSMPRelayVersion` stays at 23 (`:241-242`): recipient commands are not proxied. This is the SMP *relay* version (`VersionSMP`, gating `Command`/`BrokerMsg` encoding) — **not** `currentSMPClientVersion`, which rides the client↔client envelope and gates nothing server-facing. +- **The CAS token is a content hash, not a counter.** For link data it is SHA-256 of the stored *encrypted* user-data bytes (nonce + ciphertext, exactly as uploaded — the only thing the server can key on, since it cannot read the content); for `recipientKeys` it is SHA-256 of the encoding of the sorted key set. The server stores `recipientKeys` sorted, so it and the owners hash the same bytes. No server version counters. +- **The CAS is atomic in both queue stores** (the journal message store uses one or the other, `MsgStore/Journal.hs:107-117`). STM store: compare inside the existing STM transaction (`addQueueLinkData`, `QueueStore/STM.hs:168-188`; `updateKeys`, `:200-206`). Postgres store, with or without its cache: an `UPDATE … WHERE` predicate for both — link data `… AND sha256(user_data) = ?` (the blob is not loaded on the normal path: `rowToQueueRec` uses empty placeholders, `QueueStore/Postgres.hs:726-730`), keys `… AND recipient_keys = ?` against the value just read — because the Postgres message store runs with no cache and a no-op queue lock (`MsgStore/Postgres.hs:84`, `:100`), so each command holds its own copy and an in-memory compare would let two concurrent writes both pass. A 0-row update reads and returns the current value. +- **Two new recipient commands** (`Command Recipient`, gated ≥ v24; new tags rather than extending `LSET`/`RKEY`, so version-gating is clean): + - **`LSETH expectedHash linkId userData`** — hash-CAS on the user data only; the server keeps the stored fixed data. (`LSET` must resend the fixed data byte-identical, `QueueStore/STM.hs:178`, `QueueStore/Postgres.hs:319`, which a promoted owner cannot reproduce: encryption uses a fresh nonce and the read path keeps only the decrypted data.) If `hash(stored) == expectedHash`: store, reply `OK`. Else reply the current encrypted user data. The link-id check stays as in the `LSET` handler (`Server.hs:1575-1579`). + - **`RKEYH expectedHash keys`** — hash-CAS replace of the key set (as `RKEY`, `Server.hs:1574`). Accept: store sorted, reply `OK`. Else reply the current set. +- **One new `BrokerMsg`** for a rejection, carrying the current value (user data or key set); success is the existing `OK`, and the writer computes the new hash itself. Returning current state on rejection is what removes the writer's pre-read. +- **Auth unchanged:** both are recipient commands, so any key in `recipientKeys` authorises them (`Server.hs:1289-1290`, `:1333-1334`) — this is what lets a promoted owner write. `recipientKeys` stays `NonEmpty` (the parser rejects a zero-length set), so an `RKEYH` can never empty the set; the server has no notion of owner/revocation, so "≥ 1 *live* owner" is a client-side concern (Part 5). +- **Tests** (`tests/ServerTests.hs`, for each queue store): two concurrent `RKEYH` adds both land after one retry; of two concurrent `LSETH`, one is rejected with the winner's blob; `LSETH` cannot change the fixed data; the new commands' AUTH timing matches the existing "Timing of AUTH error" tests. -### 1.2 SMP agent — foreign-link write, hash-CAS APIs, `linkRootSigKey` +### 1.2 SMP agent — hash-CAS APIs, foreign-link write, key rotation, local forget -- **`rcv_queues` columns** (both SQLite and Postgres agent-store migration trees, selected by `-fclient_postgres`): `link_root_sig_key BLOB` — a real durable column that fixes `AgentStore.hs:2514` (it currently hardcodes `linkRootSigKey = Nothing`, a `TODO`; without it `validateOwners` derives the root as the owner's *own* key). Populate it on read and set it when a promoted owner provisions link creds. Also cache `link_data_hash BLOB` and `key_set_hash BLOB` (last-known hashes) beside the link credentials, replacing any version counter. -- **Hash-CAS agent APIs** over the CAS commands: `setConnShortLinkCAS` (takes the expected hash; on success returns the **new hash the agent computes from the exact ciphertext it uploaded** — never re-encrypting, since encryption uses a fresh random nonce, so chat cannot reproduce the bytes; on conflict returns the decrypted current data + its hash) and `updateRcvKeysCAS` (RKEY hash-CAS, same shape). On conflict the agent hands chat the current state + hash; it never re-merges (merge policy is chat's). -- **Foreign-link path** — a promoted owner does not own the link queue. Add `setForeignLinkData` / `getForeignLink` / `updateForeignRcvKeys` taking **explicit creds** — `SMPServer`, the link-queue recipient id (`linkRcvId`, delivered in the promotion `inv`; it is *not* derivable from the public short link, which yields only the sender-side `linkId`), the owner's `linkRcvKey` private half, the `LinkKey`, `rootPubKey`, and the expected hash. Prefer this over materialising a fake `RcvQueue`/`ContactConnection`, which would need an `rcvDhSecret` the owner must not have and risks it subscribing to the creator's queue. -- **`Crypto/ShortLink.hs`:** extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth` (the signer's private key); redefine `newOwnerAuth` on it; export (kills the formula duplicated in chat at `Internal.hs:1554`). The creator signs a new owner with the **root** private key (as `Commands.hs:2690` already does for itself), keeping the chain flat; a non-creator owner signs with its **own member** key (delegation). `validateLinkOwners` is **unchanged** — it accepts "signed by root or an earlier entry", which holds as long as chat writes the owners list in insertion (signer) order (Part 2), so no order-independence change is needed. +- **Stateless hash-CAS APIs.** Chat keeps the hashes; the agent takes the expected hash and returns either `OK` with the new hash (computed from the exact ciphertext it uploaded — never by re-encrypting, since encryption uses a fresh nonce) or, on conflict, the decrypted current data with its hash. The agent never re-merges (merge policy is chat's). No new agent-store columns. + - `setConnShortLinkCAS` — synchronous, on the device's own link queue. + - **An async variant** of the agent's `LSET` command (`setConnShortLinkAsync`, processed at `Agent.hs:2255-2258`), because the creator's link writes mostly run asynchronously: on success the `LINK` event carries the expected hash the command was issued with and the new hash; on conflict a new event returns the expected hash, the decrypted current data and its hash, and chat reconciles and re-enqueues. (The expected hash is the `baseHash` the announcing event needs, overview §2; reading it from chat's state when the event arrives would be wrong after an intervening reconcile.) + - `updateRcvKeysCAS` — `RKEYH` on the own queue. + - A local read of the own link queue's recipient id, which the creator puts in the promotion `inv` (today it is reachable only through `getConnectionQueueInfo`, a `QUE` round-trip to the server). +- **Foreign-link path** — a promoted owner does not own the link queue. `setForeignLinkData`, `updateForeignRcvKeys` and `deleteForeignLinkQueue` (`DEL`, for destroying the channel) take **explicit credentials**: `SMPServer`, the link-queue recipient id (`linkRcvId`, delivered in the promotion `inv`; it is *not* derivable from the public short link, which yields only the sender-side `linkId`), the `LinkKey`, `rootPubKey`, the owner's member private key (it signs the user data and authorises the commands), and the expected hash. Validation builds the link credentials in memory with the root key set, so `validateOwners` checks that the signing key is a chain key (`Agent/Protocol.hs:1991-2001`) — no `rcv_queues` row and no stored `linkRootSigKey` (`AgentStore.hs:2903` keeps `Nothing` for the creator, whose signing key is the root). Prefer this over materialising a fake `RcvQueue`/`ContactConnection`, which would need an `rcvDhSecret` the owner must not have and risks it subscribing to the creator's queue. Reads use the existing `getConnShortLink`. Foreign calls are **synchronous**: an agent async command delivers its result to a connection, and a promoted owner has no link connection to receive it. They run on a transport session of their own, one per link (the default session is shared per user and server, `mkTSession`, `Agent/Client.hs:1442-1443`), so the link server cannot tie them to the owner's other queues. +- **Creator key rotation** — `setConnRcvAuthKey connId privKey` replaces the own queue's recipient key with the given one in three resumable steps: `RKEYH` adding the new key, persisting it as the queue's `rcv_private_key`, `RKEYH` dropping the old key. A rejected step returns the current set, from which the next step is decided. +- **Forget a connection without `DEL`** — a new local-only deletion; today every agent deletion path sends `DEL` (`deleteConnectionsAsync_`, `Agent.hs:1022-1030`). +- **`Crypto/ShortLink.hs`:** extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth` (an existing owner key — the promotee's member key — signed by the signer's private key); redefine `newOwnerAuth` on it; export (kills the formula duplicated in chat at `Internal.hs:1680`). The creator signs a new owner with the **root** private key (as `Commands.hs:2757` already does for itself), keeping the chain flat; a non-creator owner signs with its **own member** key (delegation). `validateLinkOwners` is **unchanged** — it accepts "signed by root or an earlier entry", which holds as long as chat writes the owners list in insertion (signer) order (Part 2), so no order-independence change is needed. +- **Tests** (agent): a conflict is returned on the sync and the async path, with the expected hash; a foreign-link write signed by a non-creator owner's key is accepted by a reader; a foreign `DEL` by a non-creator owner deletes the queue; the rotation completes after a crash between each pair of steps. --- @@ -39,46 +46,52 @@ One migration `M20260721_channel_multi_owner` (register in `Migrations.hs` + `.c ### `group_members` (columns) | column | on which rows | purpose | |---|---|---| -| `owner_auth_sig BLOB` | owners | chain entry signature (with existing `member_pub_key` = key, this is the full `OwnerAuth`) | +| `owner_auth_sig BLOB` | owners | chain entry signature (with the existing `member_pub_key` = the entry's `ownerKey`, this is the full `OwnerAuth`) | | `owner_auth_index INTEGER` | owners | **insertion (signer) order** in the chain — a new entry is appended after the entry that signed it; load-bearing for `validateLinkOwners`, *not* a memberId sort | -| `owner_revoked INTEGER DEFAULT 0` | owners | revoked owner: kept in chain as a past signer, not a current owner (mirrors `revokedOwners` in the blob) | -| `link_rcv_key BLOB` | owners | the owner's link-write **public** key — the local `memberId → linkRcvKey` map | | `relay_subscriber_count INTEGER` | relays | latest absolute count that relay reported | -| `relay_subscriber_count_ts TEXT` | relays | that reading's **broker** timestamp (the freshness token; cleared with the count on `GSMemRemoved`/`GSMemLeft`) | +| `relay_subscriber_count_ts TEXT` | relays | the relay's own timestamp for that reading (the freshness token; cleared with the count on `GSMemRemoved`/`GSMemLeft`) | | `promote_invitation_id BLOB` | the promotee (invitee row on O1; own membership on M) | pending promotion | | `promote_role TEXT` | ″ | offered role | -| `promote_status TEXT` | ″ | invited / accepted / rejected / cancelled | +| `promote_status TEXT` | ″ | invited / accepted / key_added / entry_published / rejected / cancelled / expired — the step reached drives resume and the undo on cancel or expiry (Part 5) | | `promote_link_rcv_id BLOB` | M's own row | link-queue recipient id (from `inv`) | -| `promote_link_rcv_priv_key BLOB` | M's own row | M's generated link-write private key (one key, reused across concurrent invites) | `MemberRoleProposal` (Part 3) is *derived* from `promote_*`, not a stored column. ### `groups` (columns) | column | purpose | |---|---| -| `link_data_hash BLOB` | last hash this device knows for the published link data (from events/accepted writes) — the value it CASes on | +| `link_data_hash BLOB` | the hash this device holds for the published link data — the value it CASes on (from its accepted writes, rejections, and events whose base hash matched) | | `key_set_hash BLOB` | last known key-set hash | +| `published_relays BLOB` | the last published relay list this device knows (from reconcile, accepted writes, and adopted events); a write's relay list is this plus the owner's own in-progress additions minus its own removals (Part 6). `NULL` until first filled: a promoted owner reads the link data to fill it before its first write | | `stored_roster_owner_id BLOB` | cross-device author `memberId` of the **stored roster blob**, written beside `stored_roster_version` only by `setGroupLiveRoster`; the tie-break key (supersedes the device-local `roster_sending_owner_gm_id` for comparison) | -| link-write creds (a small group) | for *this user as a non-creator owner*: its `linkRcvKey` private half + the link-queue coordinates (`SMPServer`, `linkRcvId`, `LinkKey`, `rootPubKey`) so its agent can write the foreign link queue (the creator keeps `GroupKeys` unchanged) | +| `link_rcv_id BLOB` | for this user as a non-creator owner: the link queue's recipient id. With the server and `LinkKey` from the channel link and the existing `root_pub_key` and `member_priv_key` columns, it completes the foreign-link credentials (the creator keeps `GroupKeys` unchanged) | Do **not** add a `roster_version_owner_id` on the gate `roster_version` — the delta path (`setGroupRosterVersion`) must advance the gate *without* writing any blob author, or a delta would corrupt the blob tie-break (Part 6). +### `group_revoked_owners` (new table) +`(group_id, member_id, member_key, key_drop_enforced, removal_step)` — on every member's and relay's device, one row per revoked owner. It lives outside `group_members` because member cleanup deletes a removed channel member's row a day after removal when it has no chat items (`deleteOrUpdateMemberRecordIO`, `Internal.hs:2192-2203`; `cleanupRemovedMembers`, `Commands.hs:5960-5966`; `getRemovedMembersToCleanup`, `Store/Groups.hs:1375-1382`). Rows come from `revokedOwners` at join and at relay invite, from link-data refresh, and from removal and leave events; they are never deleted. +- `member_id` — the replay record: `x.grp.owner.add` for it is ignored, a relay refuses an owner join for it, and promotion refuses it (Parts 4–6). +- `member_key` — the key the heal drops (`NULL` when this device never knew it). +- `key_drop_enforced` — false when the revocation was first seen in the link data and the key drop is not yet confirmed; true once confirmed, or once the removal/leave event arrived (it is sent after the key drop). Drives the heal (Part 6). +- `removal_step` — for a removal this device runs: revoked / key_dropped / announced, so a crash is re-driven at chat start. + ### `group_pending_role_changes` (new table) -`(group_id, group_member_id, target_role, pre_role, roster_version, created_at)` — an owner's role changes not yet confirmed converged. Drives the roster auto-retry (overview §4): re-apply a row only while it is unsatisfied **and** the member still sits at `pre_role`; **clear** the row as soon as an observed roster shows the member at `target_role` (this is what stops an old satisfied delta from resurrecting a value and ping-ponging with a legitimate later change). +`(group_id, group_member_id, target_role, pre_role, roster_version, created_at)` — an owner's role changes not yet confirmed converged. Drives the roster auto-retry (overview §4): re-apply a row only while it is unsatisfied **and** the member still sits at `pre_role` in an *observed* roster (another owner's event or blob, or a relay-served blob — never this owner's own apply); **clear** the row as soon as an observed roster shows the member at `target_role` or at a role other than `pre_role` (this is what stops an old delta from resurrecting a value and ping-ponging with a legitimate later change). -### Whole-value link-data pending edit -For a whole-value link field (channel profile/prefs) that the owner has edited but not yet had accepted, keep a durable `(base_hash, pending_value)` (a small `groups` column group or side table), cleared on an accepted `LSETH`. Reconcile diffs local against published relative to `base_hash` to classify adopt / keep-pending / surface-conflict. Set fields (relays, owners, revoked-owners) need no such record — they merge as an idempotent union keyed by identity. +### No pending-edit record for whole-value fields +Profile/prefs edits are synchronous and write-then-commit (Part 6), so nothing is stored while they are in flight. Owners and revoked owners merge as a union keyed by memberId; a relay this owner is adding or removing is already durable in its relay row. -### Published link data (not DB — the `UserContactData` user-data blob) -Gains an in-content `linkDataVersion` (advisory ordering — recipients drop older events; correctness rests on the server hash-CAS and reconcile, so a corrupted value only degrades live-event ordering, never stored state), a `revokedOwners :: [MemberId]` list, and `(subscriberCount, subscriberCountTs)`, alongside the existing `owners`/`relays`/profile. `owner_revoked` locally mirrors `revokedOwners`. +### Published link data (not DB — chat's `GroupShortLinkData` JSON) +`GroupShortLinkData` (`Protocol.hs:1621`), carried in `UserContactData.userData`, gains optional fields — simplexmq's `UserContactData` encoding is unchanged: `linkDataVersion` (advisory ordering — recipients drop older events; correctness rests on the server hash-CAS and reconcile, so a corrupted value only degrades live-event ordering, never stored state), `revokedOwners :: [MemberId]`, and `subscriberCountTs` beside `publicMemberCount` in `PublicGroupData` (`:1616`). `group_revoked_owners` locally mirrors `revokedOwners`. --- ## Part 3 — new Haskell types - **`MemberRoleProposal = MRProposed GroupMemberRole | MRRejected GroupMemberRole`** (`Types.hs`); `GroupMember` gains `memberRoleProposal :: Maybe MemberRoleProposal` (derived from `promote_*`) and the app-facing `promotionPending :: Bool`. -- **`PromotionRoleData`** for the wire: a role-tagged sum with an opaque fallback — `PRDOwnerInv { linkRcvId }` / `PRDOwnerAcpt { linkRcvKey }` / `PRDUnknown` (decode an unrecognised tag to `PRDUnknown`, do **not** error — `omittedField` only covers an *absent* field, not a present unknown one). -- **`ForeignLinkKeys`** for a promoted owner — a record holding `SMPServer`/`linkRcvId`/`LinkKey`/`rootPubKey`/`linkRcvKey` priv + the last-known link-data and key-set hashes; the input to the Part 1.2 foreign-link APIs. +- **`PromotionRoleData`** for the wire: a role-tagged sum with an opaque fallback — `PRDOwnerInv { linkRcvId }` / `PRDUnknown` (decode an unrecognised tag to `PRDUnknown`, do **not** error — `omittedField` only covers an *absent* field, not a present unknown one). +- **`ForeignLinkKeys`** — the input to the Part 1.2 foreign-link APIs: `SMPServer`/`linkRcvId`/`LinkKey`/`rootPubKey`/member private key, assembled from `groups` columns, plus the expected hash. +- **`GroupLinkWriter = GLWOwn Connection GroupLink | GLWForeign ForeignLinkKeys`** — resolved once per link write (Part 6). - Reuse: `OwnerAuth` (simplexmq), `MemberId`, `MemberKey`, `VersionRoster`. --- @@ -90,76 +103,100 @@ All `requiresSignature`. Wire is JSON with optional-field forward-compat (`.=?` | event | dir | fields | delivery | |---|---|---|---| | `x.grp.promote.inv` | O1→M | `invitationId, memberRole, roleData?` (`roleData` = `PRDOwnerInv{linkRcvId}`) | M's support scope | -| `x.grp.promote.acpt` | M→O1 | `invitationId, memberKey, roleData?` (`roleData` = `PRDOwnerAcpt{linkRcvKey}`) | support scope | +| `x.grp.promote.acpt` | M→O1 | `invitationId, memberKey, roleData?` (none for owner) | support scope | | `x.grp.promote.reject` | M→O1 | `invitationId` | support scope | | `x.grp.promote.cancel` | O1→M | `invitationId` | support scope | -| `x.grp.owner.key` | owner→owners | `memberId, linkRcvKey` | **`DJSOwners`** (new) | -| `x.grp.relay.count` | relay→owners | `count` (+ the batch's broker ts) | batched into the `DJSGroup` delivery already carrying the member join/leave/removal event (owners receive it as members) | +| `x.grp.owner.add` | owner→all | `memberId, ownerAuth` (+ the link-data hash fields below) | `DJSGroup` — all members and relays | +| `x.grp.relay.count` | relay→owners | `count, ts` (the relay's own clock) | sent directly to owners, as the relay sends the join announcement (`introduceInChannel`, `Internal.hs:1320-1326`) — not a `DJSGroup` job, which would fan out to every member | -`memberKey` in `acpt` is M's existing key; O1 checks it **equals the key it already holds for M** (a consistency check, distinct from the advisory OOB verification in Part 7). The `linkRcvKey` in `acpt` is one key M generates once and reuses across concurrent invites — the memberId-keyed owners merge already collapses two concurrent adds of M to one `OwnerAuth`, but reuse additionally keeps `recipientKeys`, the surviving `OwnerAuth.ownerKey`, and M's materialised `linkPrivSigKey` all referencing the same key even when different owners win the `RKEYH` and `LSETH` races. +`memberKey` in `acpt` is M's existing key; O1 checks it **equals the key it already holds for M** and adds the key it holds (a consistency check, distinct from the advisory OOB verification in Part 7). -Additive: `linkDataVersion` and the **server-confirmed** `link_data_hash` become optional fields on the events that change link data (`XGrpInfo`, `XGrpRelayNew`, the owner add/remove/revoke events), so owners track both without a pre-read. Order: write first (`LSETH`), then broadcast the event carrying the hash of what was written. +**`x.grp.owner.add` is the promotion commit.** A member or relay applies it only if its sender (the message signer) is a current owner, `memberId` is not in `group_revoked_owners`, `ownerAuth` validates — signed by the stored root key (`root_pub_key`) or by a current, non-revoked owner's key — and `ownerAuth.ownerId` is `memberId`; it then sets the member's `member_pub_key` to `ownerAuth.ownerKey` (logging a warning when a relay-asserted key differs), creates the member if unknown, and sets its role to owner. M, receiving it for itself, becomes owner (Part 6). It is a new event rather than `x.grp.mem.role … GROwner`, which would apply the role to a relay-asserted key (`Subscriber.hs:3465-3468`) and never create an unknown owner (`:3458`); clients that predate it ignore it and keep rejecting the new owner's admin messages (Part 8). The sender check stops a removed owner reinstating itself with its own still-valid root-signed entry; the revoked check stops a relay re-delivering an old add. `group_revoked_owners` survives member cleanup (Part 2), and a revoked memberId is never promoted again (Part 5), so no version gate is needed — which would also drop adds that relays merely reordered. On receiving `x.grp.owner.add`, a relay sends the new owner its current `x.grp.relay.count` reading. + +Additive: `linkDataVersion`, `linkDataBaseHash` and `linkDataHash` become optional fields on the events that announce link-data writes — `XGrpInfo`, `XGrpRelayNew`, `x.grp.owner.add`, and `x.grp.mem.del` of an owner — present only when the event describes its write's entire change (a write that activates several relays at once carries none). Order: write first (`LSETH`), then broadcast. A receiving owner adopts `linkDataHash` into `link_data_hash` only when `linkDataBaseHash` equals the value it holds (overview §2); otherwise it applies the content and keeps its hash. The roster tie-break needs **no wire field** — the author is the message signer (`withAuthor`), already available at the completion gate; store its cross-device `memberId` in `stored_roster_owner_id` at completion and compare there. -New delivery scope **`DJSOwners`** (`Delivery.hs` + the worker branch near `Subscriber.hs:4276`): recipients = `getGroupOwners` (owner-only, mirrors the existing `DJSMemberSupport` shape; no schema column needed). The owner-key map must not be public or on the link server — a joining SMP operator could otherwise correlate a link-queue key to an owner identity — so it travels owner-scoped and is cached by relays (Part 6). +The new events are added to the relay forwarding set (`isForwardedGroupMsg`, `Protocol.hs:528`) and the relay's dispatch, or relays silently drop them. --- ## Part 5 — commands (`ChatCommand`, `Controller.hs`) -- **`APIAcceptRolePromotion GroupId`** — M generates its `linkRcvKey` (once), sends `acpt`, marks accepted; the async chain (overview §6) then runs on O1. +- **`APIAcceptRolePromotion GroupId`** — M sends `acpt`, marks accepted; the async chain (overview §6) then runs on O1. M holds at most one pending promotion: an `inv` received while one is pending is answered with `reject` automatically. M's record clears on `cancel`, when the inviter is revoked, and — only until M accepts — on its own TTL; after acceptance it holds `promote_link_rcv_id`, which the commit needs. - **`APIRejectRolePromotion GroupId`** — sends `reject`, clears. -- **`APICancelRolePromotion GroupId GroupMemberId`** — O1 sends `cancel`, clears the proposal. -- Route **`APIMembersRole … GROwner`** on a channel into the invite flow — single-target (reject a multi-member owner promotion, mirroring the existing admin-batch guard in the batch-role handler). -- Route owner **removal** (`APIRemoveMembers` of an owner) into the removal flow (overview §7): an async command that writes the revocation **first** (`LSETH` adding the target to `revokedOwners`), **then** drops the key (`RKEYH`), each `RKEYH`/`LSETH` payload built by applying the delta to the server's *current* set/blob (the believed one, or the one returned on a conflict) — never rebuilt from the local map, which could drop a concurrently-added owner's key. A crash between the two heals via reconcile (Part 6). Refuse removing/leaving the last *live* owner (local `owners` minus `revokedOwners`); a stale view under concurrent leave+removal or mutual removal is an accepted, disclosed residual, not auto-healed. +- **`APICancelRolePromotion GroupId GroupMemberId`** — O1 sends `cancel`. If the chain already added M's key (`promote_status` `key_added`), it drops the key with `RKEYH`; if the entry was published (`entry_published`), it also revokes M (the removal chain below); then clears the proposal. The same undo, followed by `cancel` to M, runs when the inviter's record expires. Both sides' records (M's only before it accepts) expire after a fixed TTL in `ChatConfig`, checked at chat start (with the re-drive of pending records) and by a per-user step in the periodic cleanup manager (`cleanupUser`, `Commands.hs:5922-5933`) — not by the relay-request pattern, whose TTL is checked only on a temporary error inside its own worker (`retryTmpError`, `Subscriber.hs:4554-4559`), which an invitation waiting on a person never reaches. +- Route **`APIMembersRole … GROwner`** on a channel into the invite flow — single-target (reject a multi-member owner promotion, as the existing guards do: channel owner-role guard `Commands.hs:2994-2995`, multi-member admin guard `:2990-2991`). Before any key change the chain refuses a memberId in `revokedOwners` (its key would be added but its entry never written, since the merge excludes revoked memberIds) and checks that the link data still fits with M's entry added (user data is padded to 13,784 bytes, `ShortLink.hs:46`), refusing the promotion with a clear error otherwise. The creator's first promotion then rotates its link-queue key to its member key (Part 1.2). +- Route owner **removal** (`APIRemoveMembers` of an owner) into the removal flow (overview §7): an async command that writes the revocation **first** (`LSETH` adding the target to `revokedOwners`), **then** drops the key (`RKEYH`; the target's key is its chain entry's `ownerKey`), **then** sends `x.grp.mem.del`. Each `RKEYH`/`LSETH` payload is built by applying the delta to the server's *current* set/blob (the believed one, or the one returned on a conflict) — never rebuilt from the chain, which could drop a concurrently-added owner's key whose chain entry is not yet published. A crash between the steps heals via reconcile (Part 6). Refuse removing/leaving the last *live* owner (local `owners` minus `revokedOwners`); a stale view under concurrent leave+removal or mutual removal is an accepted, disclosed residual, not auto-healed. +- **Leave** (`APILeaveGroup`, `Commands.hs:3233-3253`) by an owner while another live owner remains: revoke itself (`LSETH`), drop its own key (`RKEYH`), then `x.grp.leave`. The creator then forgets its link connection without `DEL` (Part 1.2) instead of `deleteGroupLinkIfExists` (`Internal.hs:1739-1748`), which would `DEL` the shared queue. The same applies where the creator processes its own removal (`Subscriber.hs:3736`) and where an owner that is no longer current deletes the chat (`Commands.hs:1400`). Destroying the channel still deletes the queue: the creator through its link connection as today, and a non-creator owner, which has none, with `deleteForeignLinkQueue` (Part 1.2) — only in the branch where a current, non-revoked owner sends `x.grp.del` (`doSendDel`, `APIDeleteChat`, `Commands.hs:1394-1400`). `deleteGroupLinkIfExists` at `:1400` runs regardless of `doSendDel`, so the foreign `DEL` must not join it there: a removed owner whose key a crash left live would otherwise destroy the channel by deleting its chat. -The promotion and removal chains are async commands resumable from their durable records (the `promote_*` columns / the removal command's own state) — no new durable-worker type. +The promotion and removal chains are async commands resumable from their durable records (the `promote_*` columns / `group_revoked_owners.removal_step`) — no new durable-worker type. --- ## Part 6 — key logic changes (anchors) -- **`groupLinkData`** (`Internal.hs:1543`, self-only owners at `:1550-1554`) → build the write from local state via the **reconcile** routine; delete the `GRKPrivate` singleton / `[]` fallback; read the stored chain (insertion order). -- **Reconcile** = merge published link data into local, per field: set fields (relays, owners, revoked-owners) merge as an idempotent union keyed by identity (memberId for owners), so an owner's own not-yet-published additions survive without a stored delta; a whole-value field (profile/prefs) uses the durable `(base_hash, pending_value)` (Part 2) to classify adopt / keep-pending / surface-conflict. Run it **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — remove the owner-exclusion around `syncSubscriberRelays`, `Commands.hs:1913`, and the app-side `else if`), **on a CAS conflict** (adopt the returned blob, rebuild), and as the **missed-event backstop**. -- **Removal heal** (runs during reconcile-on-open, and it is what makes the revoke-first ordering crash-safe): for each `memberId` in the decrypted `revokedOwners` whose `linkRcvKey` the owner still holds in its local map, issue one `RKEYH` dropping that key from the server-*returned* current set (CAS-retry inline; an already-absent key is a harmless no-op). This is the sole heal direction, because `revokedOwners` is readable from the blob while `recipientKeys` is write-only — so a crash between `LSETH` (revocation) and `RKEYH` (key drop) is recoverable, but the reverse order would not be. Merging `revokedOwners` into local state alone does *not* evict a still-live key; this explicit server-side `RKEYH` is required. -- **Relay activation off an accepted write**, not the `LINK` echo (`Agent.hs:1814` notifies `LINK link userLinkData` — the client's own submitted data, not the server's stored truth). -- **Roster** (overview §4): the event gate stays **version-only, author-blind** (the `fresh` check, `Subscriber.hs:3304`, `version >= held`), so concurrent *different-member* deltas both apply — the union. The `(version, authorMemberId)` tie-break is added **only at the completion gate** (`Subscriber.hs:3462-3468`): keep the existing downgrade reject (`pendingVer < roster_version` gate) and, at the equal-version case that today overwrites by arrival order, accept iff `pendingVer > stored_roster_version` **or** (`pendingVer == stored_roster_version` **and** `author > storedAuthor`) — comparing the incoming blob's signer against `stored_roster_owner_id`, never the gate. Store the cross-device author with the blob in `setGroupLiveRoster` (`:3468`). **Auto-retry is event-driven**: an owner holding an unsatisfied pending role-delta that sees another owner's concurrent (same-version) `x.grp.mem.role` re-broadcasts its now-union local state at a higher version; both owners do this, so the served blob converges without waiting on any one owner. Existing members converge from the re-emitted `x.grp.mem.role` (`DJSGroup` → all members). No leader, no relay-to-owner blob forwarding. -- **Count** (overview §5): `x.grp.relay.count` produced by the relay branch of `updatePublicGroupData` (`Internal.hs:1491`) and batched with the join/leave/removal delivery; owners store `(count, ts)` per relay, estimate = **max** count over current relays carrying **that max-holder's own `ts`**. On channel open (during reconcile) any owner publishes `(estimate, that ts)` to link data iff the estimate differs from the published count **and** the max-holder's `ts` is newer than `subscriberCountTs` — a freshness gate, no leader, no threshold. Clear a relay's `(count, ts)` on `GSMemRemoved`/`GSMemLeft` (`Types.hs:1357-1358`). -- **Owner materialisation** threads the full `OwnerAuth` (sig + append index): `createLinkOwnerMember` (`Groups.hs:3508`), `updateRelayGroupKeys` (`Groups.hs:2299`, also create the row if missing). Root-sign when the adder is the creator (holds the root private key), else delegate. -- **Relay caches** the signed `memberId → linkRcvKey` map (announced over `DJSOwners`) and serves it on request — the source an owner uses to *identify* a removal target's key; the `RKEYH` payload is still built from the server-returned set. Each entry is owner-signed, so a relay cannot forge one, and a relay is not the link-queue operator, so it cannot correlate link-queue writes. -- **Promoted owner commit:** on receiving `x.grp.mem.role … GROwner` (the commit point), M materialises its `ForeignLinkKeys` (rcvId = `linkRcvId` from the inv, `linkPrivSigKey` = its generated key, `linkRootSigKey` = the channel root public key from `FixedLinkData`, `LinkKey`/server from the channel link) so its agent can issue foreign `LSETH`/`RKEYH`. +- **One writer for every link write.** Each link-write site resolves a `GroupLinkWriter` once: the creator's own link connection and group link, or a promoted owner's `ForeignLinkKeys`. Today every site needs the creator's group link row or link connection — `setGroupLinkData'` silently skips the write without one (`Internal.hs:1581-1586`, used by the profile edit at `Commands.hs:4190`), and `setGroupLinkData`/`setGroupLinkDataAsync` (`:1588-1604`), `updatePublicGroupData` (`:1624`), relay add (`Commands.hs:2790`) and relay connect/ack (`Subscriber.hs:946-947`, `:3663-3665`) throw. Foreign writes are synchronous (Part 1.2); a caller that is asynchronous today runs them in a thread, and its intent is already durable (relay rows, promotion records, `group_revoked_owners.removal_step`), so reconcile at chat start re-drives an interrupted one. The body of the `LINK` handler (`Subscriber.hs:1431-1481`: relay activation to `RSActive`, `XGrpRelayNew` to other relays, `CEvtGroupLinkDataUpdated`) becomes a function of the written data and the resolved writer — not a re-read of the group link (`Subscriber.hs:1439`), which a promoted owner has no row for — called from the `LINK` event and after a foreign write returns `OK`, which produces no `LINK` event. +- **`groupLinkData`** (`Internal.hs:1670`, self-only owners at `:1676-1682`) → build the write from local state via the **reconcile** routine; delete the `GRKPrivate` singleton / `[]` fallback; read the stored chain (insertion order). The relay list is `published_relays` (read from the link data first when it is still `NULL`) plus this owner's in-progress additions minus its own removals, and a write whose relay list is empty is refused unless this owner is removing the last relay — not only its own `group_relays` rows (`getPublishableGroupRelays`, `Store/Groups.hs:1528-1545`), which reference its own chat relays (`chat_schema.sql:840`) and are empty for a promoted owner. The count fields are the published `(publicMemberCount, subscriberCountTs)`, unchanged, except in the gated count publish. +- **Reconcile** = merge published link data into local, by field kind: owners and revoked owners merge as an idempotent union keyed by memberId, minus revoked entries no current entry depends on (dropped, below); relays: `published_relays` is replaced by the published list, and this owner's in-progress relay rows (being added and not yet published, or being removed) are kept; profile/prefs adopt the published value. Subscribers' link-data refresh (`updateGroupFromLinkData`, `Internal.hs:1634-1654`, today profile and count only; called only from `Commands.hs:1966`, `:4624`, `:4651`) also applies the chain: validated entries as owners (keys from `ownerKey`), `revokedOwners` into `group_revoked_owners`. Relays never call it; a relay applies the chain the same way in its periodic check, which already fetches the link data every 15 minutes (`checkRelayServedGroups`, `Commands.hs:6001-6020`), and at once when an owner join or `x.grp.owner.add` names a memberId it does not hold as owner — relays don't forward to each other, so an owner event from an owner not connected to a relay never reaches it otherwise. Run it **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — remove the owner exclusion at `Commands.hs:1968` and the apps' owner branch, `ChatView.swift:764-779` and its Kotlin counterpart), **at chat start** for each channel the user owns (owners that never open the channel view — CLI, bots — still heal and publish the count), **on a CAS conflict** (adopt the returned blob, rebuild), and as the **missed-event backstop**. +- **Owner relay step** in reconcile, instead of `syncSubscriberRelays` (`Commands.hs:3992-4011`), which would delete this owner's relays that are not yet published and join other owners' relays as a subscriber: deactivate relays absent from the published data that this owner is not adding, and connect to relays other owners added by joining through the relay's link as itself, signed with its member key. +- **Relay accepts an owner's join.** In `memberJoinRequestViaRelay` (`Subscriber.hs:1690-1720`), an existing member is accepted onto its row only for roster roles (`:1702-1706`); any other existing member falls through to a fresh insert, which violates `UNIQUE(group_id, member_id)`. Add the owner case: an existing, non-revoked owner row plus a join signed with that owner's key, under the same `viaRelay` replay check as the roster branch (`verifyKey`, `:1711-1714`), is accepted via `acceptJoin` with the existing row and role owner; a memberId in `group_revoked_owners` is refused. Without it no owner can connect to a relay another owner added, so its posts never reach that relay's subscribers. On the owner's side, completing the join (the relay answers with `XGrpLinkInv`, handled for a `GCHostMember` at `Subscriber.hs:830-842`) updates only the relay member: `updatePreparedUserAndHostMembers'` (`Store/Groups.hs:821-844`) would replace the owner's group profile with the relay's copy and, unless the membership is `GSMemConnected` (the creator's is `GSMemCreator`), rewrite its membership role and status. +- **Profile edit** (`runUpdateGroupProfile`, `Commands.hs:4164-4204`) becomes write-then-commit: today it commits the profile locally (`:4172-4174`) before the synchronous link write (`:4190`). Keep the starting profile in memory; on a conflict, merge field by field against it — a field only this owner changed keeps its value, a field only the other owner changed takes theirs, a field both changed differently keeps the published value and is surfaced as "profile edit superseded" — then retry. +- **Removal heal** (runs during reconcile, and it is what makes the revoke-first ordering crash-safe): for each `group_revoked_owners` row with `key_drop_enforced` false and a known `member_key`, issue one `RKEYH` whose expected set is the believed set plus that key and whose new set is the believed set (on a conflict, the returned set shows whether the key is still there), then set `key_drop_enforced`. Merging `revokedOwners` into local state alone does *not* evict a still-live key; this explicit `RKEYH` is required. An owner that receives the removal or leave event sets `key_drop_enforced` directly — the event is sent only after the key drop — so only a revocation first seen in the link data (the crash case) costs a direct connection to the link server. A removal this device runs and that crashed is resumed from its `removal_step` at chat start, through the announcement. +- **Relay activation** stays on the `LINK` event (and, for a foreign write, its `OK`, through the extracted function above), which the agent emits only after the server accepted the write (`Agent.hs:2255-2258`); under CAS it follows the accepted `LSETH` and carries the data written after any re-merge. Relays another owner removed produce no `LINK` here (the TODO at `Subscriber.hs:1475-1477`); the owner relay step deactivates them. +- **Roster** (overview §4): the event gate stays **version-only, author-blind** (`applyAtRosterVersion`'s `fresh` check, `Subscriber.hs:3414`, `version >= held`), so concurrent *different-member* deltas both apply. The `(version, authorMemberId)` tie-break is added **only at the completion gate** (`Subscriber.hs:3572-3578`): keep the existing downgrade reject (`pendingVer < roster_version` gate) and, at the equal-version case that today overwrites by arrival order, accept iff `pendingVer > stored_roster_version` **or** (`pendingVer == stored_roster_version` **and** `author > storedAuthor`) — comparing the incoming blob's signer against `stored_roster_owner_id`, never the gate. Store the cross-device author with the blob in `setGroupLiveRoster` (`:3578`; `Store/Groups.hs:1716-1724`). A completed blob reverts privileged members absent from it (`processRosterEntries`, `Subscriber.hs:3610-3619`), so the losing owner's delta is reverted on relays until its retry. Applying any roster skips members who are currently owners (in `processRosterEntries`, `Subscriber.hs:3610-3630`, which today sets each listed member to its listed role, `:3601-3608`, `:3622-3628`): a member promoted to owner from a roster role stays in relays' stored blob, which a later joiner receives right after its owner intros (`Internal.hs:1330-1331`), and would otherwise be set back. **Relay setup under concurrent roster changes:** a roster ack below the owner's current version during relay setup re-sends the current roster to that relay (`sendGroupRosterToRelay`, `Internal.hs:2657-2662`) instead of only warning (`xGrpRosterAck`, `Subscriber.hs:3654-3667`) — another owner's delta can advance the version between send and ack, and this owner re-sends only when it bumps the roster itself (`broadcastRoster`, `Internal.hs:2642-2654`), so the relay would stay unpublished. **Retry**: when an observed roster shows a pending delta's member still at its pre-role, the owner re-applies its unsatisfied deltas to its local roster and sends blob + deltas at one above the highest version seen; both owners do this, so the served blob converges without waiting on any one owner. Existing members converge from the re-sent deltas (`DJSGroup` → all members). No leader, no relay-to-owner blob forwarding. +- **Count** (overview §5): `x.grp.relay.count` is produced by the relay branch of `updatePublicGroupData` (`Internal.hs:1628-1630`) and sent directly to owners; owners store `(count, ts)` per relay, with `ts` clamped to the broker timestamp of the message that carried it; the estimate is the **max** count over current relays, carrying **that max-holder's own `ts`**. Only a sole live owner, while no current relay supports `x.grp.relay.count`, uses its own tally with its own clock as `ts` (no other owner publishes then; promoting a second owner requires count-capable relays, Part 8). During reconcile (channel open, chat start) any owner publishes `(estimate, that ts)` iff the estimate differs from the published count **and** the max-holder's `ts` is newer than `subscriberCountTs` (a `subscriberCountTs` later than the owner's clock counts as stale). Every other write carries the published count fields unchanged, and the owner branch stops recounting its tally (`Internal.hs:1623`). The owner branch of `updatePublicGroupData` (`Internal.hs:1620-1627`) stops publishing for subscriber joins, leaves and removals — call sites `Subscriber.hs:3253`, `:3268`, `:3286`, `:3773`, `:3820`, `Commands.hs:3152` — and publishes only when the publishable relay set changed (a relay member removed or left). The `XGrpRelayNew` announcement threshold (`Subscriber.hs:1445-1449`, `publicMemberCount > 1`, which assumes one owner) uses the relay-sourced estimate. Clear a relay's `(count, ts)` on `GSMemRemoved`/`GSMemLeft` (`Types.hs:1430-1431`). +- **Owner materialisation** threads the full `OwnerAuth` (sig + append index): `createLinkOwnerMember` (`Store/Groups.hs:3575`; the key is `ownerKey`, as today), `updateRelayGroupKeys` (`Store/Groups.hs:2353`; create the owner row if missing — today it throws, `:2366-2368`). Joiners and relays skip entries listed in `revokedOwners` as owners and record them in `group_revoked_owners` (Part 2). Root-sign when the adder is the creator (holds the root private key), else delegate. A revoked entry is dropped from the written chain unless it is an ancestor, through any number of delegations, of a current entry (every entry must be signed by root or an earlier one, `Agent/Protocol.hs:2012`; an orphan would make every owner's agent refuse every write); `revokedOwners` keeps its memberId, so the union merge cannot restore it (overview §7: the blob is fixed-size, each entry is about 124 bytes). +- **`x.grp.owner.add`** handling, for members and relays: validate and apply as in Part 4. +- **Promoted owner commit:** on receiving `x.grp.owner.add` for itself, M sets its own role to owner and stores `link_rcv_id` (from the inv), which completes its `ForeignLinkKeys`; its agent can then issue foreign `LSETH`/`RKEYH`, signing with its member key. - **Creator's root key is unrevocable:** "removing the creator" evicts its link-write key and marks its owner entry revoked, but it retains root signing authority (the immutable trust anchor in `FixedLinkData`); true creator eviction would need a root-key rotation, out of scope. --- ## Part 7 — apps (iOS + Kotlin, mirror both) -- `GroupInfo`: decode **`canManageLink`** (new backend field); gate link/relay *management* on it (an owner mid-handover cannot write yet), keep relay *status display* on `isOwner`. +- `GroupInfo`: decode **`canManageLink`** (new backend field); gate link/relay *management* on it (an owner mid-handover cannot write yet), keep relay *status display* on `isOwner`. Also decode a new backend field saying another live owner exists. - `GroupMember`: decode `memberRoleProposal` + `promotionPending` (optional/nullable). - **New `RcvGroupEvent` case** for the promotion service item (both enums are exhaustive — a missing case throws) + strings. -- `canChangeRoleTo` (`ChatTypes.swift:3092`, `ChatModel.kt:2668`): add `.owner` for channels; selecting it starts the async invite and shows the proposed-role row (proposed + Cancel / rejected + re-invite). Warn if the invitee's key is unverified and route to the verification screen (`verifyChannelMemberCode`, `Commands.hs:3702`, called at `:2018`, hashes both keys sorted) — advisory, matching the existing channel model; the consequence is the channel's trust chain, not one conversation. -- Invitee: a **new** support-chat accept/reject banner (the existing pending-member bar is gated on a non-null scope member; M's own support scope has none, so this is new code reading the proposal from `membership`) + the service-item unread; accept goes through a confirmation stating any owner can delete the channel. +- `canChangeRoleTo` (`ChatTypes.swift:3382`, `ChatModel.kt:2944`): add `.owner` for channels; selecting it starts the async invite and shows the proposed-role row (proposed + Cancel / rejected + re-invite). Warn if the invitee's key is unverified and route to the verification screen (`verifyChannelMemberCode`, `Commands.hs:3826`, called at `:2074`, hashes both keys sorted) — advisory, matching the existing channel model; the consequence is the channel's trust chain, not one conversation. +- Invitee: a **new** support-chat accept/reject banner (the existing pending-member bar is gated on a non-null scope member; M's own support scope has none, so this is new code reading the proposal from `membership`) + the service-item unread; accept goes through a confirmation stating that any owner can delete the channel and that this device will connect directly to the channel's link server (overview §8). - Conflict surfaces: "profile edit superseded", "your role change to X was overridden" — plain, re-apply via normal UI. -- Fix the chat-list leave guard to honour `hasOtherOwner` at **both** occurrences per file (`ChatListNavLink.swift:247`+`:272`, `ChatListNavLinkView.kt:319`+`:341`) — the guard `!(useRelays && isOwner)` appears in two member-status branches; patching one leaves the other inconsistent. +- Chat-list leave guard: the condition `!(useRelays && isOwner)` appears in two member-status branches per file (`ChatListNavLink.swift:247`, `:272`; `ChatListNavLinkView.kt:322`, `:344`); allow leaving when the new `GroupInfo` field says another live owner exists, at both occurrences. The chat list has no member list, so the answer comes from the backend (the Kotlin `hasOtherOwner` is a local in `GroupChatInfoView.kt:829-832`; iOS inlines the check at `GroupChatInfoView.swift:250-251`). --- -## Part 8 — version gating +## Part 8 — version gating and compatibility -Detect the link queue's negotiated server version (the relay-handshake `thVersion`); **require ≥ v19 to promote a second owner** (the hash-CAS `LSETH`/`RKEYH` all need it). On an older link server, block promotion with a clear error rather than degrade — a blind `RKEY` there evicts other owners (replace with no CAS). Because the negotiated version is the min of the two peers' relay ranges, both relay constants (Part 1.1) must reach 19 before a client perceives v19. +- **Link server:** detect the link queue's negotiated server version (the relay-handshake `thVersion`); **require ≥ v24 to promote a second owner** (the hash-CAS `LSETH`/`RKEYH` all need it). On an older link server, block promotion with a clear error rather than degrade — a blind `RKEY` there evicts other owners (replace with no CAS). Because the negotiated version is the min of the two peers' relay ranges, both relay constants (Part 1.1) must reach 24 before a client perceives v24. +- **Relays:** a relay invited to a channel with two or more owners throws on the owner it has no row for (`updateRelayGroupKeys`, `Store/Groups.hs:2366-2368`, called from `Subscriber.hs:4604`), so adding a relay to such a channel fails. A relay that predates multi-owner also never forwards `x.grp.owner.add` (`isForwardedGroupMsg`, `Protocol.hs:528`, unknown events → `False`), keeps the promoted owner an observer and drops its posts (`memberCanSend`, `Subscriber.hs:1737-1738`), rejects that owner's join (Part 6), and sends no `x.grp.relay.count`. So the relay side — tolerant `updateRelayGroupKeys`, `x.grp.owner.add`, the owner join, `x.grp.relay.count` — ships in a release before promotion is enabled, and at runtime promotion requires every current relay to support the multi-owner chat version (owners already check a relay's version, `supportsVersion`, `Subscriber.hs:933`), else it is refused with a clear error. Adding a relay to a channel with more than one owner cannot be checked up front — a relay's version is known only once it answers — so it fails closed: an older relay throws before answering (`Subscriber.hs:4604`, its error recorded only on the relay, `:4565-4567`), and the owner times out the unanswered invitation and shows an error — checked by the same `cleanupUser` step as the promotion TTLs (Part 5), since today nothing on the owner's side expires an `RSInvited` relay. A sole owner whose relays predate the count event falls back to its own tally (Part 6). +- **Older members** ignore `x.grp.owner.add`: they keep rejecting the new owner's admin messages until they upgrade — failing closed rather than trusting a relay-asserted key. **Older joiners** take every chain entry as an owner, including revoked ones; a revoked owner's key is gone and relays have dropped its connection, so its messages do not reach them. --- ## Part 9 — tests (scope) -Under `describe "channels"` (`tests/ChatTests/Groups.hs`), ceiling 2 owners + 1 relay + 3 subscribers (profiles run out at `frank`). Priorities: -- promote O2 (verified-then-accepted), and its admin message accepted by a subscriber that only saw O1; -- owner-2 adds owner-3 (delegated entry validates in insertion order); -- concurrent link writes by two owners (hash-CAS: loser re-merges onto the returned blob, both survive); -- concurrent roster edits — different members (event-gate union + auto-retry → served blob converges to both) and same member (tie-break winner, loser surfaces conflict); +Under `describe "channels"` (`tests/ChatTests/Groups.hs`). Named profiles run out at `frank` (alice…frank); relays use `chatRelayProfile`, as existing channel tests do, so a test has up to six owners and subscribers in total plus its relays, within `it`'s 180 s timeout (`tests/ChatTests/Utils.hs:96-100`). Priorities: +- promote O2 (verified-then-accepted); a subscriber that joined before the promotion accepts O2's admin message after `x.grp.owner.add`; a member that knew O2 from a relay intro with a different key ends with the chain key; +- owner-2 adds owner-3 (three owners): the delegated entry validates in insertion order; +- the creator's first promotion rotates its link-queue key; after removing the creator, a new joiner still resolves the link; +- concurrent link writes by two owners (hash-CAS: the loser re-merges onto the returned blob, both survive); an owner that missed an announcement does not adopt the next one's hash; +- concurrent roster edits — different members (both converge after the retry) and same member (tie-break winner, loser surfaces conflict); +- a relay removed by one owner stays removed after another owner's write; a promoted owner's first write keeps every relay, and a relay O1 added survives O2's profile edit; +- O2 adds relay R3; O1 joins R3 as an owner; a subscriber connected only to R3 receives O1's post; +- promote admin A to owner; a later joiner and another owner both still see A as owner after the stored roster is served; +- a relay re-delivers an old `x.grp.owner.add` for a removed owner: a member who joined after the removal ignores it, and so does a member present at the removal after `cleanupRemovedMembers` has deleted the removed owner's row; a removed owner sending `x.grp.owner.add` for itself is ignored; +- promoting a memberId in `revokedOwners` is refused before any `RKEYH`; +- delegation chain O2 → O3 → O4: revoke O3, then O2 — both stay in the chain while O4 is current, and link writes keep working; +- after the creator is removed, O2 deletes the channel and the link no longer resolves; a removed owner deleting its chat leaves the link resolving; +- promotion is refused when the chain would no longer fit in the link data, before any `RKEYH`; and refused while a current relay predates multi-owner; +- a promoted owner edits the profile, adds a relay (it becomes active and existing subscribers receive `XGrpRelayNew`) and publishes the count (foreign writes); its first write, before any reconcile, keeps every relay; two owners edit different profile fields concurrently and both edits survive; +- O1 joins relay R3 that O2 added: O1's profile and membership are unchanged afterwards; +- a relay O1 is setting up acks a roster version that O2's concurrent role change has superseded: O1 re-sends and the relay becomes active; +- a relay added by O2, to which O1 never connected, learns of O1's promotion of M and of a removal through its periodic link-data check; +- an invitation not answered by M expires on both sides, and M can then accept another owner's invitation; - count across a newly-added relay (no crash; max ignores the catching-up relay) and publish-on-open updating a small channel 1 → 2 → 3; -- owner removal (revoke-first + key drop, a new joiner sees the removed owner as non-owner); crash between `LSETH` and `RKEYH` heals on another owner's reconcile; -- adversarial — replayed `acpt` (invitation-bound, no double-add), a moderator-injected `x.grp.promote.inv` (inert: no owner holds a matching pending record, M rejects a non-owner-signed inv), a chain published out of order / on a pre-v19 link server (promotion fails closed), promotion on an unverified key (advisory warning only). +- owner removal (revoke-first + key drop, a new joiner sees the removed owner as non-owner); a crash between `LSETH` and `RKEYH` — the remover's chain stopped after the `LSETH` by a test hook and the remover not restarted — heals on another owner's reconcile; +- cancel after accept: M's key is dropped and M cannot write the link; +- adversarial — replayed `acpt` (invitation-bound, no double-add), a moderator-injected `x.grp.promote.inv` (inert: no owner holds a matching pending record, M rejects a non-owner-signed inv), a chain published out of order, a pre-v24 link server (promotion fails closed), promotion on an unverified key (advisory warning only). -The count/roster races need forced interleaving (`deliveryWorkerDelay`). +The count/roster races need forced interleaving (`deliveryWorkerDelay`). simplexmq server and agent tests are listed in Part 1.