diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index 1adddc6253..8d09138f16 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -2,7 +2,7 @@ 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:1509`), 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:1543`, self-only owners at `:1549-1554`), which republishes only *self* as the owner. ## 1. The frame: every shared state needs a sequencer @@ -10,8 +10,8 @@ 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** | version-CAS: write-with-expected-version, rejection returns current state, re-merge | -| **Owner queue keys** (`recipientKeys`) | one set on the SMP link queue | the **SMP server** | version-CAS, same shape; + an owner-held key→owner map | +| **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 | | **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,13 +21,13 @@ 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 owner learns the hash without a round-trip: it hashes the bytes it uploaded on success, or the returned blob on a rejection — never assuming 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. So: hash = server CAS token (server-visible, unordered); in-content version = client-side ordering (server-blind). 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 **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. **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. -**One reconcile routine keeps local state in sync; `groupLinkData` always builds from local.** "Reconcile" merges published link data into local state per field: sets (relays, owners, revoked-owners) merge and keep the owner's own pending intent; a whole-value field (profile/prefs) with a genuine concurrent same-field edit surfaces a conflict. Because local state thus stays "published + my pending intents", `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 is **sorted by `memberId`** (a canonical order giving a stable leader index and deterministic serialization); a write inserts my entry in place. This is clean when every entry is root-signed (§6 default, order-independent); a *delegated* entry must currently follow its signer (`validateLinkOwners` accepts "signed by root or an earlier entry"), so a memberId sort is only fully compatible if that validation is made order-independent (validate by anchoring each entry to root through the set) — a simplexmq change flagged in §11. `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 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 more existing defect:** relay activation must key off an *accepted* write, not the `LINK` echo (`Agent.hs:1813` returns the client's own sent data, so today an owner marks a relay active because it asked to — nothing reads back the truth). +**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). ## 3. Owner queue keys @@ -37,7 +37,7 @@ Link data and keys already *have* a sequencer — the server — so they need on **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). -**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 → `RKEY` out the key → `LSET` the revocation), CAS rejections retrying inline — no new durable-worker type. A crash mid-removal is corrected by reconcile-on-open (§2): any owner that sees an `OwnerAuth` whose key is no longer in the set treats it as revoked and completes the marker. A missing *addition* self-heals via CAS (a stale set hashes wrong → rejection → returned set repairs it). A relay withholding the map is handled by asking another relay. +**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. ## 4. Roster @@ -45,21 +45,21 @@ 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. **Deterministic winner.** Tie-break on `(version, authorMemberId)` — `memberId` is identical on every device, so every node picks the same blob regardless of arrival order. This must be applied at **all three** version gates: the signed-event gate (`Subscriber.hs:3302`), the blob header (`:3430`), and the blob-completion gate (`:3461`, today version-only and storing a device-local id) — the owner↔owner path is the blob, applied at completion. Add `roster_version_owner_id` (the cross-device author) and store it there. +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. -2. **Auto-retry to the union.** An owner keeps its **pending role-deltas** durably. On adopting a newer winning blob that lacks one, it re-applies the delta *if the winner did not also touch that member* — re-broadcasting at a higher version. O1 (X→admin) and O2 (Y→admin) thus converge to *both*, not one. Verified: the re-emitted `x.grp.mem.role` fans out to the full membership (`DJSGroup` → all members, not just relays/joiners), so an already-joined affected member receives it and updates — this is the only channel by which an existing member learns a role change (the blob goes only to relays/joiners). +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. -3. **True conflict.** If both owners changed the *same* member, the tie-break winner stands, that member heals to the winner (both owners emitted its event), and the losing owner surfaces "your change to X was overridden." No silent revert. +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. -**Why no leader:** a leading owner that sequences the roster reintroduces the single-owner dependency multi-owner exists to remove — if it is asleep, all role changes stall. Auto-retry needs no one to be online but the acting owners. Races are rare (concurrent role edits within seconds), so the extra broadcast is acceptable. Bulk changes (one call, one version bump, N events) re-send the bulk on retry. +**Why no leader:** a leading owner that sequences the roster reintroduces the single-owner dependency multi-owner exists to remove — if it is asleep, all role changes stall. Auto-retry needs no one to be online but the acting owners. Races are rare (concurrent role edits within seconds), so the extra broadcast is acceptable. Bulk changes (one call, one version bump, N events) re-broadcast the owner's union local state on retry — not the original bulk — so a concurrent single-member change by another owner is preserved, not regressed. ## 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. -**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, carrying that relay's `ts`. 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 — a lagging second relay reporting a slightly lower number never lowers the max, so cross-relay skew causes no spurious decrease). Deltas cannot do 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 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. -**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* its `ts` is newer than `subscriberCountTs`, it publishes `(estimate, ts)`. No delta threshold — a small/new channel must move on every joiner (1 → 2 → 3). There is no write-amplification to guard against: publishing is gated by *channel open*, not by joins, and 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; relay-to-relay skew is absorbed by the max.) +**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.) ## 6. Owner promotion (adding an owner) @@ -72,9 +72,9 @@ x.grp.promote.acpt { invitationId, memberKey, roleData? } -- M -> O1 x.grp.promote.reject { invitationId } -- M -> O1 x.grp.promote.cancel { invitationId } -- O1 -> M ``` -`memberRole` is the offered role; `roleData` is an optional role-scoped block (owner: `linkRcvId` in inv, fresh `linkRcvKey` in acpt); `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; 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. -**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` (insert M's `OwnerAuth` in the sorted `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). +**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. **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. @@ -82,20 +82,20 @@ x.grp.promote.cancel { invitationId } -- O1 -> M ## 7. Owner removal -Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-held map §3) and add its `memberId` to **`revokedOwners`** in the mutable link data. **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 (root-signed entries — the common case, §6 — have no such dependency). Enforcement is at the SMP layer (no key = no authority), not in the chain. **Leave** is self-sequenced: an owner removing itself writes the revocation first and drops its own key last; if it crashes, a remaining owner finishes. Removal is not a defence against a malicious owner (any owner can already destroy the channel) — it is administrative hand-off. +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). ## 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:2021`, 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. +- **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. ## 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). These wire changes gate on a new SMP **relay** version (`currentServerSMPRelayVersion`, `VersionSMP` 18 → 19 — *not* `currentSMPClientVersion`, which is the client↔client envelope and gates nothing server-facing). **Multi-owner requires the link queue's server ≥ v19**: 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 + in-content version cached next to the link credentials; `linkRootSigKey` persisted (`AgentStore.hs:2514`). -- **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; `roster_version_owner_id`; per-relay latest subscriber count + broker timestamp (fields on each relay's member row); the `memberId → linkRcvKey` map. Published link data (mutable) also carries `subscriberCountTs` beside the count. A `DJSOwners` owner-only delivery scope for the key announcement. +- **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. @@ -108,9 +108,9 @@ Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-h ## 11. Open decisions -- **`validateLinkOwners` order** (simplexmq): a memberId sort of `owners` (§2) coexists with delegated (non-root-signed) entries only if validation anchors each entry to root through the set rather than requiring "signed by an earlier entry". Decide whether to make it order-independent, or to require all owners root-signed (creator-only adds). +None outstanding. -Settled this round: 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 tie-break + auto-retry with no leader (§4); a same-member conflict is surfaced and re-applied through normal UI (§10). +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. @@ -141,7 +141,7 @@ The general rule: a relay-provided sequencer is only worth considering for state ### A.3 A single designated relay as the roster sequencer — also rejected -The lightest form — one designated relay holding the roster under version-CAS (no inter-relay consensus, reusing the link-data pattern) — is rejected too: it concentrates trust in a single relay that can then fork the roster by serving different states to different members, or freeze roster writes whenever it is offline. That worsens the current threat model, in which no single relay can fork governance. The deterministic tie-break (§4) deliberately needs zero trusted parties; a single-relay sequencer trades that property away for a sequencer the roster already approximates without trust. +The lightest form — one designated relay holding the roster under hash-CAS (no inter-relay consensus, reusing the link-data pattern) — is rejected too: it concentrates trust in a single relay that can then fork the roster by serving different states to different members, or freeze roster writes whenever it is offline. That worsens the current threat model, in which no single relay can fork governance. The deterministic tie-break (§4) deliberately needs zero trusted parties; a single-relay sequencer trades that property away for a sequencer the roster already approximates without trust. ### The coordination the design does use diff --git a/plans/2026-07-21-channel-multi-owner-implementation.md b/plans/2026-07-21-channel-multi-owner-implementation.md index 3c172625d9..dc4cd94376 100644 --- a/plans/2026-07-21-channel-multi-owner-implementation.md +++ b/plans/2026-07-21-channel-multi-owner-implementation.md @@ -2,30 +2,33 @@ 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. + ## Sequencing -Two repos, in order. **simplexmq first** (versions + CAS on the SMP 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 being ≥ 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, `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. --- ## Part 1 — simplexmq -### 1.1 SMP server — versioned CAS on the link queue +### 1.1 SMP server — hash-CAS on the link queue -- **Version bump.** New `VersionSMP` `linkCasSMPVersion = 19`; `currentServerSMPRelayVersion` 18 → 19. (This is the SMP *relay* version — `VersionSMP`, gating `Command`/`BrokerMsg` encoding in `Protocol.hs` — **not** `currentSMPClientVersion`, which rides the client↔client envelope and gates nothing server-facing.) -- **Queue record** (`Server/QueueStore`, both STM and Postgres stores): add `linkDataVersion :: Int64` and `keySetVersion :: Int64` — server-owned counters, initialised at 0. +- **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): - - **`LSETV expectedVersion linkId d`** — CAS `LSET`. If `linkDataVersion == expectedVersion`: store `d`, increment, reply the new version. Else reply a conflict carrying `(linkDataVersion, currentUserData)`. The check sits beside the existing `lnkId' /= lnkId -> err AUTH` in the `LSET` handler (`Server.hs:1484`); the bytes are already in `queueData qr`, so no new server storage beyond the counter. - - **`RKEYV expectedVersion keys`** — CAS `RKEY`. Same pattern on `keySetVersion`; conflict carries `(keySetVersion, currentKeys)`. -- **New `BrokerMsg` responses:** success replies the new version (small); conflict replies `(version, payload)` — `LSETV` conflict returns the user-data blob, `RKEYV` conflict returns the key set. (`LNK` already carries a `QueueLinkData`, so returning a blob is routine.) -- **Auth unchanged:** both are recipient commands, so any key in `recipientKeys` authorises them (`Server.hs:1249`) — this is what lets a promoted owner write. + - **`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). -### 1.2 SMP agent — foreign-link write, versioned APIs, `linkRootSigKey` +### 1.2 SMP agent — foreign-link write, hash-CAS APIs, `linkRootSigKey` -- **`rcv_queues` columns** (both SQLite and Postgres agent-store migration trees, selected by `-fclient_postgres`): `link_root_sig_key BLOB` (fixes `AgentStore.hs:2514`); `link_data_version`, `key_set_version` cached for the *creator's own* link queue. -- **Versioned agent APIs** over the CAS commands: `setConnShortLinkCAS` (returns new version, or a conflict with the decrypted data + version); `updateRcvKeysCAS` (RKEY-CAS, same). On conflict the agent decrypts and hands chat the current state + version; 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`, `linkId`, the owner's `linkRcvKey` private half, the `LinkKey`, `rootPubKey`, expected version). 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`; redefine `newOwnerAuth` on it; export (kills the formula duplicated in chat at `Internal.hs:1519`). +- **`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. --- @@ -37,31 +40,37 @@ One migration `M20260721_channel_multi_owner` (register in `Migrations.hs` + `.c | 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_index INTEGER` | owners | chain order (load-bearing for `validateLinkOwners`) | -| `owner_revoked INTEGER DEFAULT 0` | owners | revoked owner: kept in chain as a past signer, not a current owner | -| `link_rcv_key BLOB` | owners | the owner's link-write **public** key — the `memberId → linkRcvKey` map | +| `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`) | | `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_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 | +| `promote_link_rcv_priv_key BLOB` | M's own row | M's generated link-write private key (one key, reused across concurrent invites) | -`MemberRoleProposal` (below) is *derived* from `promote_*`, not a stored column. +`MemberRoleProposal` (Part 3) is *derived* from `promote_*`, not a stored column. ### `groups` (columns) | column | purpose | |---|---| -| `link_data_version INTEGER` | last version this device knows for the published link data (tracked from events/writes) | -| `key_set_version INTEGER` | last known key-set version | -| `roster_version_owner_id BLOB` | author `memberId` of the applied roster version — the cross-device tie-break key | -| link-write creds (a small group) | for *this user as a non-creator owner*: its `linkRcvKey` private half + the link-queue coordinates so its agent can write the foreign link queue (extends the existing per-group key material; the creator keeps `GroupKeys` unchanged) | +| `link_data_hash BLOB` | last hash this device knows for the published link data (from events/accepted writes) — the value it CASes on | +| `key_set_hash BLOB` | last known key-set hash | +| `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) | -### New table `group_pending_role_changes` -`(group_id, group_member_id, role, roster_version, created_at)` — an owner's role changes not yet confirmed converged. Drives the roster auto-retry (§4 overview): on adopting a newer blob, re-apply any row the winner did not touch; clear a row once seen in an adopted roster. +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_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). + +### 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. ### Published link data (not DB — the `UserContactData` user-data blob) -Gains `linkDataVersion` and a `revokedOwners :: [MemberId]` list alongside the existing `owners`/`relays`/profile. `owner_revoked` locally mirrors the latter. +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`. --- @@ -69,7 +78,7 @@ Gains `linkDataVersion` and a `revokedOwners :: [MemberId]` list alongside the e - **`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). -- **Link-write creds** for a promoted owner — a record (`ForeignLinkKeys` or an extension of `GroupKeys`) holding `SMPServer`/`linkId`/`LinkKey`/`rootPubKey`/`linkRcvKey` priv + the two versions. +- **`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. - Reuse: `OwnerAuth` (simplexmq), `MemberId`, `MemberKey`, `VersionRoster`. --- @@ -80,61 +89,77 @@ All `requiresSignature`. Wire is JSON with optional-field forward-compat (`.=?` | event | dir | fields | delivery | |---|---|---|---| -| `x.grp.promote.inv` | O1→M | `invitationId, memberRole, roleData?` | M's support scope | -| `x.grp.promote.acpt` | M→O1 | `invitationId, memberKey, roleData?` | support scope | +| `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.reject` | M→O1 | `invitationId` | support scope | | `x.grp.promote.cancel` | O1→M | `invitationId` | support scope | -| `x.grp.owner.key` | owner→owners | `memberId, linkRcvKey, keySetVersion` | **`DJSOwners`** (new) | -| `x.grp.relay.count` | relay→owners | `count` | batched with the join/leave/removal delivery to owners | +| `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) | -Additive: `link_data_version` becomes an optional field on the events that change link data (`XGrpInfo`, `XGrpRelayNew`, owner add/remove/revoke), carrying the **server-confirmed** version so owners track it without a pre-read. +`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. -The roster tie-break needs **no wire field** — the author is the message signer (`withAuthor`), already available at the three gates; store its `memberId` in `roster_version_owner_id` and compare there. +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. -New delivery scope **`DJSOwners`** (`Delivery.hs` + the worker branch at `Subscriber.hs:4276`): recipients = `getGroupOwners` (owner-only, mirrors the existing `DJSMemberSupport` shape; no schema column needed). +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). --- ## Part 5 — commands (`ChatCommand`, `Controller.hs`) -- **`APIAcceptRolePromotion GroupId`** — M generates `linkRcvKey`, sends `acpt`, marks accepted; the async chain (§6 overview) then runs. +- **`APIAcceptRolePromotion GroupId`** — M generates its `linkRcvKey` (once), sends `acpt`, marks accepted; the async chain (overview §6) then runs on O1. - **`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 `Commands.hs:2879`). -- Route owner **removal** (`APIRemoveMembers` of an owner) into the removal flow (§7 overview): async command, `RKEYV`-out + `LSETV` the revocation, reconcile heals a crash. +- 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. + +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. --- ## Part 6 — key logic changes (anchors) -- `groupLinkData` (`Internal.hs:1509`) → build the write from local state via the **reconcile** routine; delete the `GRKPrivate` singleton / `[]` fallback; read the stored chain. -- **Reconcile** = merge published link data into local (per-field: sets merge + keep pending intent, whole-value surfaces conflict). Run on channel open (remove owner-exclusion at `Commands.hs:1918` and the app `else if`), on a CAS conflict, and as missed-event backstop. -- Relay activation off an **accepted write**, not the `LINK` echo (`Subscriber.hs:1411-1458`, `Agent.hs:1813`). -- Roster: tie-break at all three gates (`Subscriber.hs:3302`, `:3430`, `:3461`) using `roster_version_owner_id`; pending-delta auto-retry; existing-member healing via re-emitted `x.grp.mem.role` (verified reaches members). -- Count: `x.grp.relay.count` produced by the relay branch of `updatePublicGroupData` (`Internal.hs:1457`), stored per-relay, `max` across relays; leading owner (lowest `owner_auth_index`) publishes to link data. -- Owner materialisation threads the full `OwnerAuth` (sig + index): `createLinkOwnerMember` (`Groups.hs:3508`), `updateRelayGroupKeys` (`Groups.hs:2320`, also create the row if missing). -- Relay caches the signed key-map and serves it on request (for removal). +- **`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`. +- **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, 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`. - `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, shows the proposed-role row (proposed + Cancel / rejected + re-invite). -- Invitee: a **new** support-chat accept/reject banner (the existing pending-member bar is gated on a non-null scope member; M's own scope has none, so this is new code reading the proposal from `membership`) + the service-item unread. +- `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. - 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` (`ChatListNavLink.swift:272`, `ChatListNavLinkView.kt:319`). +- 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. --- ## Part 8 — version gating -Detect the link queue's negotiated server version; **require ≥ v19 to promote a second owner** (RKEY-CAS + LSET-CAS + the version fields all need it). On an older link server, block promotion with a clear error rather than degrade — a blind `RKEY` there evicts other owners. +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. --- ## 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 (chain order); concurrent link writes by two owners (CAS: both survive); concurrent roster edits, different members (auto-retry → union) and same member (conflict surfaced); count across a newly-added relay (no crash); owner removal (revoke + key drop, joiner sees non-owner); adversarial — replayed `acpt`, chain published out of order (fail closed), promotion on an unverified key. The count/roster races need forced interleaving (`deliveryWorkerDelay`). +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); +- 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). + +The count/roster races need forced interleaving (`deliveryWorkerDelay`).