diff --git a/plans/2026-07-15-channel-multi-owner.md b/plans/2026-07-15-channel-multi-owner.md index d05132dc65..2e15da4069 100644 --- a/plans/2026-07-15-channel-multi-owner.md +++ b/plans/2026-07-15-channel-multi-owner.md @@ -21,23 +21,23 @@ 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 version on the server.** Add `link_data_version` to the queue record. `LSET` carries `expectedVersion` = *the version the writer's blob is merged onto* (not a guessed next one); the server accepts iff `stored == expectedVersion`, then bumps to `+1`; a mismatch **rejects and returns `(currentVersion, currentBlob)`**. The rejection returns the full state because a rejected owner cannot know whether it merely missed an event or genuinely raced — it re-merges its one change onto the returned blob and retries. (This is optimistic concurrency; the version is just a cheaper CAS token than a content hash, and — see below — one owners already know.) +**Sequencer — a version on the server, server-assigned.** Add `link_data_version` to the queue record. `LSET` carries `expectedVersion` = *the version the writer's blob is merged onto*; the server accepts iff `stored == expectedVersion`, increments, and **returns the new version in the success reply**. A mismatch **rejects and returns `(currentVersion, currentBlob)`**. The owner never picks or assumes a version: it takes the current one from events (below), sends it as `expectedVersion`, and on success echoes the server-confirmed new version into its broadcast. The rejection returns the full state because a rejected owner cannot know whether it missed an event or genuinely raced — it re-merges its one change onto the returned blob and retries. -**No `LGET` on the normal path.** Carry `link_data_version` in the signed events that change link data (`XGrpInfo`, `XGrpRelayNew`, the owner add/remove role events, the count publish). Owners then hold the current state *and* version from those events, so a write uses the version it holds and succeeds — the only rejections are genuinely simultaneous writes, which no one could have avoided. `LGET` survives only as cold-start catch-up (a long-offline or brand-new owner). **Order: write link data first (`LSET`-CAS), then broadcast the semantic event carrying the achieved version** — otherwise a rejected-then-retried write would announce the wrong version. +**The version travels in events, so writers rarely pre-read.** Carry `link_data_version` in the signed events that change link data (`XGrpInfo`, `XGrpRelayNew`, the owner add/remove/revoke events, the count publish), each carrying the version the *server confirmed* for that change. Owners hold current state + version from those events, so a write uses the version it holds and succeeds — the only rejections are genuinely simultaneous writes. **Order: write first (`LSET`-CAS), then broadcast the event with the server-confirmed version** — a rejected-then-retried write would otherwise announce the wrong version. **`LGET` is never needed to write** — a stale writer is simply rejected and gets the current state back. -**Field merge rules** (applied to the returned blob on a rejection, or to local state on a fresh write): profile/prefs = whole value (last writer wins that field; a genuine concurrent same-field edit surfaces to the user); relays = the published set ± the one relay this write touches; owners = append my entry; count = from relays (§5). This makes "one owner's write evicts another's field" structurally impossible. +**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 field appends my entry; the revoked-owners field appends (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). -**Two existing defects folded in:** `groupLinkData` must apply a *delta* to published data, not rebuild the whole blob from local state; and 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: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). ## 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`. -**Sequencer — the server, version-CAS, same as link data.** Give the key set a version; `RKEY` replaces the set with `expectedVersion`; a rejection 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. +**Sequencer — the server, version-CAS, same shape as link data.** Give the key set a `key_set_version`; `RKEY` carries the expected version and the full set; success returns the new version; a rejection 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. -**Correlation for removal — an owner-held map, never on the server.** To remove owner X you must know *which* key is X's, but the server's set is deliberately **anonymous**: any public or server-side key→owner map lets a joining SMP operator (who can read the OwnerAuth chain and see which key writes) tie an owner's link-queue activity to their identity. So owners keep the `memberId → linkRcvKey` map **themselves**: a new owner announces its key to the others (a plain `getGroupOwners` send — that owner-only path already exists, used by `introduceInChannel`), and a lagging or new owner catches up by request. Relays forward the announcement but are not the link-queue operator, so no deanonymization. +**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, carrying the `key_set_version` from its `RKEY` success), 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. -**Missed announcement:** for an *add* it self-heals — a stale key-set version means the `RKEY` is rejected and the returned set repairs it. For a *removal* it does not (the returned set is anonymous), so an owner missing a mapping must request it; if no other owner is reachable, surface "couldn't update the owner list, try later." +**So removal never blocks and self-heals:** it is an **async durable worker** (fetch the map from a relay if the local copy lacks the target → `RKEY` out the key → `LSET` the revocation), not a synchronous command. A missing *addition* self-heals via CAS (stale `key_set_version` → rejection → returned set repairs it). A relay withholding the map is handled by asking another relay. ## 4. Roster @@ -57,13 +57,13 @@ Link data and keys already *have* a sequencer — the server — so they need on **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:** each relay states its current subscriber count in the `XGrpMemNew` (join) and leave/removal events it already sends to owners (an optional field, forward-compatible). An owner keeps a scalar + the last-reporting-relay id: follow that relay up or down, take the max when a *different* relay reports higher, decrease on leave. (Subscribers connect to all relays, so relays report ~the same number; this just avoids flapping.) +**Source — absolute per-relay counts, take the max.** Each relay states its own *current absolute* subscriber count as a distinct per-relay event **batched with the join/leave event it already sends to owners** — so it costs no extra SMP block, and it need only be restated when the count changes, which is exactly on a join/leave (no periodic reporting). The owner stores the latest **per relay** (a field on the relay's member row); the published count is the **max** across relays. Absolute-plus-max makes the hard case correct: a newly added relay that subscribers are still slowly connecting to reports a low number that stays below the max and is ignored until it catches up; a subscriber leaving that relay drops only its entry, not the max; a real departure (from all relays) lowers every entry and so the max. **Deltas fail here** — a leave is forwarded by several relays and *deduplicated*, so an owner cannot attribute a decrease to a relay, and a low new relay's decrease would wrongly crash the total. The count event, being a separate per-relay event, is not deduplicated even though the join/leave it batches with is. (Relays need not talk to each other; the max of independent reports suffices.) **Sequencer — a leading owner** (the lowest `owner_auth_index` among current owners) publishes the count to link data. Not for correctness (relay-sourcing already fixed the promoted-owner-undercount) but for **efficiency**: the count shares `link_data_version`, so N owners publishing per join would bump it constantly and starve human writes; one publisher is one stream. **Handover follows chain order on a membership change** (tentative): when the leader is removed or leaves, the next owner in `owner_auth_index` order automatically becomes the publisher — no liveness detection. A leader that is merely *offline* is not handed over; the count just goes stale until it returns, which is fine because the count is cosmetic. A new owner seeds from the published value and does not publish until it has its own relay reports. ## 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. Gated on **out-of-band key verification** (§8). +**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: ``` @@ -72,7 +72,7 @@ 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 verified one. 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, 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. **Consent:** acceptance is not automatic — M confirms in the UI. A pending record on each side; reject or cancel clears it. On acceptance O1 runs a durable, resumable worker: `RKEY` (add M's key, §3) → `LSET` (append M's `OwnerAuth`, §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). @@ -84,7 +84,7 @@ Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-h ## 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 gate is out-of-band verification, which already exists: `verifyChannelMemberCode` (`Commands.hs:2021`) hashes both members' keys, sorted, so comparing it detects substitution. Promotion requires the invitee's `memberVerifiedCode` set, and the signed-into-chain key must equal the verified one. +- **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. - **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. @@ -93,7 +93,8 @@ Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-h - **SMP link queue (simplexmq server):** `link_data_version`, `key_set_version`. 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 (it replaces the set 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 CAS 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`; count scalar + last-relay id; the owner-key map. +- **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 absolute subscriber count (a field on each relay's member row); the `memberId → linkRcvKey` map. 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. ## 10. UI/UX