update plan

This commit is contained in:
spaced4ndy committed 2026-07-15 19:04:21 +04:00
1 parent 736ff957f4
commit 89ee872e6c
1 file changed
+129 -9
+129 -9
View File
@@ -62,6 +62,10 @@ LSET precedes the role broadcast so a joiner in the window learns owner 2 from t
## 4. Concurrency
There is no consensus protocol between owners, and there should not be. "Any-owner-decides" is defined by the absence of coordination, so a propose-then-commit round would contradict the governance model rather than implement it. It would also fail on the facts: owners are sleeping phones with no direct connections to each other, so a quorum could take hours and a two-owner channel would need both online to do anything; owners reach each other only through relays, so a relay could partition them and freeze governance, which is a capability the threat model specifically denies it; membership changes are reconfiguration, the hardest part of any consensus protocol, and adding owners is precisely what this feature does. Worst of all it inverts the point of the feature — multi-owner exists so a channel survives losing an owner, and quorum means losing one can stop the channel dead.
What is needed instead is convergence without coordination, and the two pieces of shared state have different storage, so they take different standard answers: link data is a single copy on a server, so it wants optimistic concurrency (§4.2); the roster has a copy on every relay and no central store, so it wants a last-writer-wins register with a deterministic timestamp (§4.1). The subscriber count stops being shared state at all once it is relay-derived.
### 4.1 Roster: same version from two owners, multiple relays
With O1 and O2 both at version N-1 and both making a change:
@@ -88,17 +92,119 @@ Both relays converge on B regardless of order, as do subscribers and O1. `>=` is
**Rosters must reach owners.** Today `sendRosterBlob` targets `getGroupRelayMembers` only, and the relay→subscriber broadcast was deliberately removed, so an owner never sees another owner's roster and its counter goes stale. **Relays forward `XGrpRoster` and its `BFileChunk`s to members with role `GROwner`, excluding the author** — a bounded fan-out, owners only, not all subscribers. This keeps every owner's version current so collisions stay rare, and it is what lets O1 receive (N,O2), lose the tie-break, and self-correct to the network's state.
The losing owner's change is dropped. O1 adopts B and the change it made reverts; the UI must surface this rather than let it happen silently. See §11 for the alternative.
The losing owner's change is dropped: O1 adopts B and the change it made reverts. There is no automatic retry — an owner re-applying its delta at N+1 would be two owners fighting over a whole-value object, the same reason profile edits are not retried (§4.2). The UI surfaces the revert so the user can re-apply if they still want it.
The gap/frontier logic is unaffected: the counter remains a single shared sequence (each owner computes `local + 1`), so the tie-break introduces no gaps in `nextCompleteVersion`.
### 4.2 Link data: concurrent LSET
There is one link queue on one server, so writes serialise and the stored state never diverges — but LSET is a blind whole-object overwrite with no CAS, so O1 adding a relay concurrently with O2 changing the profile silently drops one of them. Neither owner finds out: `LINK` fires only in response to one's own `setConnShortLink` call (`Subscriber.hs:1451`).
There is one link queue on one server, so writes serialise and the stored state never diverges. Two things make a lost write both possible and invisible.
**The published link is the source of truth for the relay set, and every non-publishing owner reconciles against it.** `syncSubscriberRelays` already does exactly this for subscribers and is currently skipped for *all* owners (`Commands.hs:1918`); it should be skipped only for the owner that just published. Combined with the periodic `LGET` the relay worker already performs (`checkRelayServedGroups`, `Commands.hs:5254`), owners converge on the published set.
**Every write is a blind whole-object overwrite.** `groupLinkData` rebuilds the entire `UserContactData` from local state, so each publish asserts *the whole link is what I think it is* — including fields the writer has no knowledge of. Correct for a single writer, wrong for N.
Relay membership is a set with meaningful removals, so an owner republishing a stale snapshot can resurrect a relay another owner just removed. Last-writer-wins converges, but to the wrong value. See §11.
**An owner's confirmation is its own echo.** `notify $ LINK link userLinkData` (`Agent.hs:1813`) returns *the data the client sent*, not the server's state, and the relay-activation reconciliation (`Subscriber.hs:1411-1458`) reads `relays` out of that echo to promote relays to `RSActive`. So an owner marks a relay active because it asked for it to be listed. With one writer that is harmless — what you sent is what is stored. With N writers it is the reason nobody notices: the owner adds a relay, another owner's concurrent publish drops it, the relay's own `checkRelayServedGroups` sees its link missing and goes `RSInactive`, and the owner that added it still shows it active. Nothing in the system reads back the truth.
Any loss here is damaging in some way — a dropped relay silently stops serving, a dropped owner entry leaves an accepted invitee unpublished, a dropped profile or prefs update simply vanishes — so the design below is about eliminating losses and detecting the rest, not about making them rare.
**First, fix where the subscriber count comes from.** It keeps updating on every join — a count that visibly moves is what makes a channel look alive, and every other write is rare — but no owner can compute it correctly, so the number must come from elsewhere.
`publicMemberCount` is published on every join and leave: `updatePublicGroupData` is reached from six sites (`Subscriber.hs:3137/3152/3170/3658/3705`, `Commands.hs:3035`), gated on `memberRole' membership == GROwner`. The number it publishes is `summary_current_members_count`, a DB-trigger count of rows in *that device's* `group_members` (`M20250919_group_summary.hs:48-81`) — "members I know about", not a global truth. A promoted owner's is systematically low, and permanently so: `introduceInChannel` announces a joiner only to `owners <> adminsMods`, so observers never learn of other observers and a promoted owner never learns the ones who joined before it. With N owners the published number would flap between each owner's private tally on every join. Making the creator the sole publisher only defers it — the number would then be wrong in perpetuity once the creator left. Introducing every existing subscriber to a new owner would fix the arithmetic at O(N) messages and O(N) rows per new owner, for a single displayed integer.
The fix is to notice **the count is already relay-derived.** An owner has no connection to subscribers; every member it knows arrived as an `XGrpMemNew` from a relay, so its "own" count is a tally of relay-sent announcements. Taking the number from the relay directly is exactly the same trust with none of the bookkeeping — and the relay already computes it (`updatePublicGroupData`'s relay branch calls `updatePublicMemberCount`); it simply cannot publish it, being neither an owner nor a link writer.
**So the relay states its subscriber count in the join and leave announcements it already sends to owners** — an optional field on `XGrpMemNew` and its departure counterparts, forward-compatible, no new event and no new fan-out. The owner mirrors it instead of deriving from `summary_current_members_count`, taking the maximum across relays, since subscribers connect to every listed relay and each relay's count approximates the total. A newly promoted owner seeds from the published link data on its first reconcile tick, so it is correct immediately even on a quiet channel where no announcement is due.
**Only one owner publishes it: the first in the chain, the next taking over when that one stops writing.** Every owner still tracks the count from the relay reports, so this is a choice of who writes, not who knows — a handover needs no catch-up, and the number never depends on which owner made it. The chain order (`owner_auth_index`) is already stored and identical on every device, so the choice is deterministic and needs no election. Without this, N owners would each write on every join: N times the traffic for one integer, of which N-1 are rejected and discarded, since a losing owner's fingerprint stays stale and it loses every time. It also keeps the link from being a target that N owners are constantly moving, which is what would otherwise starve human writes (§4.2).
Publishing stays per join, so the count moves as joins arrive and the channel looks alive. `updatePublicGroupData` keeps its trigger unchanged; only the *source* of the number, and which owner publishes it, change. That matters, because it does double duty and must not simply be dropped: there is no relay-removal command — a relay is removed by removing the relay member through `APIRemoveMembers` (`Commands.hs:3033-3035`), and it leaves the link because `getPublishableGroupRelays` requires `member_status = GSMemConnected` (`Store/Groups.hs:1470`); a relay leaving of its own accord arrives as `xGrpLeave` (`Subscriber.hs:3705`) and publishes the same way. Keeping the per-join publish keeps both working.
This makes owners symmetric — the published number no longer depends on which owner wrote it — and survives the creator leaving. It also disposes of the wider worry: the count was the only thing that needed a *complete* member list, so a promoted owner's partial one stops mattering. Moderation learns members lazily, on their first post (`Subscriber.hs:4195`), which is exactly when they are needed; the roster arrives as a served snapshot on join.
A relay can state a false number, but it can already do that by fabricating or withholding announcements — the threat model records count manipulation as detectable-not-prevented, and detection is unchanged: an owner comparing across relays sees the discrepancy.
**Second, make LSET a read-modify-write of the published data rather than a re-derivation from local state:** `LGET` → decode → apply only this write's delta → `LSET`. Each field then has a defined merge rule:
| field | semantics | rule |
|---|---|---|
| `direct` | constant for a channel | preserve |
| `owners` | append-only ordered chain (§4.3) | published, with my new entry appended |
| `relays` | set with meaningful removals | published, plus or minus the one relay this write adds or removes — never this owner's whole local list |
| `groupProfile` | whole object | overwrite only if this write *is* a profile change |
| `publicMemberCount` | relay-reported telemetry | overwrite only if this write carries a newer relay report |
This subsumes two further failures: an owner's publish can no longer evict another owner, and a profile edit can no longer resurrect a relay another owner removed, because the relay set comes from the published data rather than a stale local snapshot.
**The merge-and-retry lives in one place, not at every call site.** Callers stop building link data and instead say what they want changed — a delta: this profile, this relay added, this relay removed, this owner appended, this count. One function owns the whole cycle: take the published data, apply the delta by the table above, `LSET`, and on rejection re-read, re-apply the same delta and write again, bounded, returning a conflict only if the delta's own field moved underneath it. The merge rules exist once, the retry exists once, and a caller cannot get either wrong because it never sees them.
The structure already fits: every group link write funnels through `groupLinkData` (`Internal.hs:1509`) via exactly two callers, `setGroupLinkData` (sync) and `setGroupLinkDataAsync` (async). So `groupLinkData` — today "build the whole thing from local state" — becomes "apply this delta to the published state", and the two callers become the one place that loops. Channel creation is unaffected: it is the first write, with nothing to conflict with.
The async path needs the loop on the chat side, not the agent's. `setConnShortLinkAsync` enqueues the fully-built data into the agent's command queue and executes it later (`Agent.hs:1811`), so under compare-and-swap it could be rejected long after the caller returned — and the agent cannot re-merge, since merge policy is chat's and the agent cannot read the content. So the async callers (relay connected, roster acked, member count) enqueue a delta to a per-group link worker that runs the same loop off the message-processing thread. That also serialises this owner's own writes, so it never competes with itself.
**Third, add an owner-side reconcile tick.** An owner must also *learn* what other owners published, and there is no mechanism for it today: `checkRelayServedGroups` (`Commands.hs:5254`) polls the link on a timer but iterates `getRelayServedGroups`, i.e. groups this client serves *as a relay*, so an owner never runs it; and `syncSubscriberRelays` runs only inside `APIGetUpdatedGroupLinkData` (`Commands.hs:1909-1921`), a user-initiated command, and is skipped for owners outright (`:1918`).
So this is new: a periodic `LGET` of the channel's own link for groups where the user is an owner — the same shape and interval as the relay's check, and the natural home for it is alongside that worker. It does three jobs: adopt what other owners published (`syncSubscriberRelays` already implements the relay-set half; it needs to run for owners other than the one that just published), seed a newly promoted owner's subscriber count, and keep this owner's CAS fingerprint warm.
**Fourth, compare-and-swap on `LSET`.** With the count on a single publisher, every remaining write is human-initiated and rare, so conflicts are rare — but a lost one is still damaging, and the client-side alternative cannot prevent one. That alternative, for the record: merge onto the warm copy and `LSET` blind. A concurrent write is still lost and the writer cannot tell at the time, so reconciliation is after the fact, on the tick — the owner compares the published data against what it believes and either re-applies its change or reports it overwritten. Detecting the loss at all means durably recording what it intended, since its local state already shows the change as applied, and the loss surfaces only at the next tick. Detecting at write time instead needs a verify `LGET` after every write: a third round trip that still leaves a hole, for a clobber landing after the verify. So it is more machinery, slower, and still lossy.
**Compare-and-swap.** "Write this, but only if what is stored is still what I read." The writer passes a fingerprint — a hash of the stored user-data bytes its merge was based on — and the server stores the new data only if the current bytes still hash to that value; otherwise it rejects and stores nothing. A rejection *is* the detection, so no verify read is needed: re-read, re-merge, retry. Lossless.
The fingerprint is of the **stored ciphertext**, not the plaintext: the server never decrypts anything, so it can only hash the bytes it already holds (`queueData qr`). It works as a version token for free, because every write carries a fresh nonce and therefore changes the stored bytes even when the plaintext is identical.
**Where a writer gets the fingerprint.** Only two ways, because it is a hash of ciphertext: either you produced those bytes, or you read them. Your own successful write gives it to you — the stored bytes are the bytes you just sent, hashed on the way out. Otherwise the reconcile tick's `LGET` gives it to you. There is no third source: **another owner's write is opaque until you read it**, and you cannot re-derive its fingerprint from your local plaintext, since the nonce is fresh on every write. It is persisted next to the link credentials, so a restart does not force a read.
**Precisely: a write is rejected exactly when another owner has written since you last read or wrote.** So owners alternating do pay — the first write after another owner's is rejected, costing the rejection plus a read, and only then is that owner warm again. That is optimistic concurrency working as intended: you pay only when a conflict actually happened, which for human-paced writes means once per alternation.
A rejection is not free, though. `LSET` always uploads the user data padded to a fixed 13,784 bytes (`userDataPaddedLength`, `Crypto/ShortLink.hs:45`), so a rejected write wastes ~13.8 KB whatever it was changing. Two consequences. The tick must not be tuned to *prevent* rejections by keeping fingerprints warm — polling every owner on a short interval costs far more than the rejections it would save on writes that happen weekly; its frequency should be set by its real jobs, and a stale fingerprint is simply one of the things it happens to fix. And a writer frequent enough to be rejected *every time* would be a real cost rather than a tidiness concern — the subscriber count is the only one, and it is why that has a single designated publisher.
**The fingerprint never reaches chat.** The agent performs the encryption, so it is the only layer that sees the stored bytes; it computes the fingerprint, keeps it with the link credentials it already stores, and refreshes it on every successful write and every `LGET`. Chat calls `setConnShortLink` as it does now and gets back success or a conflict error; on conflict it re-reads, re-merges and calls again. Merge policy stays in chat, link crypto stays in the agent.
**Why not hash the plaintext instead**, so every owner could compute the fingerprint without having seen the ciphertext? It does not reduce rejections: any compare-and-swap token — ciphertext hash, plaintext hash, or a plain version counter — is stale exactly when another owner has written since you last read or wrote, so the rejection rate is identical. What plaintext hashing would buy is a *deterministic* token, computable by anyone who knows the content — so an owner could keep it warm from the in-band messages it already receives (`XGrpInfo`, `XGrpRelayNew`, `XGrpMemNew`) instead of reading. Three things spoil that. The owner would have to reconstruct the stored plaintext byte-for-byte, including the JSON encoding of `userData`, and any divergence yields a mismatch that no retry resolves without a read. Those in-band messages are not a reliable ordered log — relays may drop or reorder them and there is no total order across owners — so a shadow copy drifts. And the server cannot compute a plaintext hash at all, having no key, so it would have to store a client-supplied opaque token: a storage change, and one no party can verify, where the ciphertext hash is computed on demand from bytes the server already holds. Rejections are only frequent for a high-frequency writer, and the only one is the subscriber count, which its single designated publisher already settles — one writer is always warm.
```
without CAS with CAS
O1: LGET -> {News, [R1,R2]} O1: LGET -> {News, [R1,R2]} digest a
O2: LGET -> {News, [R1,R2]} O2: LGET -> {News, [R1,R2]} digest a
O1: LSET {News, [R1,R2,R3]} stored O1: LSET expect a {News, [R1,R2,R3]} -> OK, now b
O2: LSET {Daily, [R1,R2]} stored O2: LSET expect a {Daily, [R1,R2]} -> REJECTED
O2: LGET -> {News, [R1,R2,R3]} digest b
= R3 silently gone. It stops serving. O2: LSET expect b {Daily, [R1,R2,R3]} -> OK
O1 still shows it active. = both changes survive.
```
Operations per change:
| | uncontended | contended |
|---|---|---|
| today (single writer) | 1 — `LSET` | n/a |
| merge, write blind | 1 — `LSET` | 1, silently lost |
| merge, write, verify | 3 — `LGET`, `LSET`, `LGET` | 3, hole remains |
| **compare-and-swap** | **1 — `LSET`** | 3 — rejected `LSET`, `LGET`, `LSET` |
So it is not a correctness tax on top of the cheap design; it *is* the cheap design. It is as cheap as today in the common case, because the fingerprint is already known, and it *removes* machinery rather than adding it: no verify read, no durable per-write intent, no retry-until-observed loop, no residual hole to document.
Server-side it is a check beside the existing `lnkId' /= lnkId -> err AUTH` in the `LSET` handler (`Server.hs:1484`) — the data to hash is already there as `queueData qr`, so no storage change and no read-side change, since `LGET` already returns the exact ciphertext. Costs: a new command tag (`LSET`'s encoding is positional, so extending it in place is messy and a new tag version-gates cleanly), a new error for the rejection — none of the existing `ErrorType` constructors fit — an SMP version bump (`currentSMPClientVersion` 4 → 5), and agent plumbing. Against a server that predates it there is no compare-and-swap available, so writes fall back to merge-and-write-blind with its silent-loss window; that fallback is needed regardless, since the link lives on whatever server the creator chose.
**A rejection is not a conflict, and it does not reach the user.** It says *something* changed, usually not the thing this writer is changing — an owner editing the profile is rejected just as readily by another owner adding a relay. So the rule is: re-read, and if the field I am changing is untouched, re-merge and retry; that is always safe, whatever the field. The user sees a save that took one extra round trip, not an error. Only if my field *also* changed is it a genuine conflict, and only then does the field's nature matter — set-elements (relays, owners) still re-merge and converge; whole values (profile, prefs) cannot, because retrying would be two owners overwriting each other, so the writer yields and reports the winning value. That report is the one thing a user ever sees, it means another owner really did edit the same field seconds earlier, and they should see it — writing blind produces no error at all in that same case and the edit simply disappears. Retries are bounded, and only exhausting them is a real failure.
**What would make rejections user-visible is a high-frequency writer**, and the count is the only candidate — which is why it has a single designated publisher (§4.2, above). Left to every owner it would make the link a target that N of them are constantly moving, and a human profile edit could be rejected, re-read, re-merged and rejected again by the next join. With one publisher the link has one occasional writer, and rejections are what they should be: rare, internal, and invisible.
**Where the local state sits relative to the write decides what a rejection costs**, and it differs per operation:
| write | trigger | link write | on rejection |
|---|---|---|---|
| profile / prefs | `runUpdateGroupProfile`, user-facing | **sync** | re-merge and retry; on a genuine conflict abort before the local commit and show the user the winning value |
| relay add | relay's acceptance → worker | async | worker's existing retry |
| relay remove | member removal → `updatePublicGroupData` | async | worker's existing retry |
| owner add | acceptance → worker (§3) | async | worker's existing retry |
| subscriber count | join / leave | async | drop it; the next join publishes again |
**Nothing new is recorded anywhere.** Because a rejection is immediate, no writer has to remember what it wanted in order to notice later that it went missing — that bookkeeping is a cost of (A), not of (B). A sync write fails to the user, who retries, exactly as with any other error.
Nor do the async writes need bespoke retry logic. They are already durable multi-step network flows with retry state and backoff (`relay_request_retries`/`_delay`/`_execute_at` on `groups`, driven by `retryTmpError`, `Subscriber.hs:4404`), because they must survive a crash regardless of concurrency. A rejection is a textbook temporary error — the next attempt will almost certainly succeed — so classifying it as one puts it straight into the existing loop, and the existing expiry surfaces a relay error to the UI if attempts keep failing. Their progress is already tracked by a state machine — a relay sits at `RSAccepted`/`RSAcknowledgedRoster` until the write lands and only then becomes `RSActive` — so a rejection leaves it pending, with nothing to revert. The only fix there is that activation must key off an accepted write rather than the `LINK` echo. Failing outright and asking the user to re-add a relay would be worse for no saving: re-adding means re-inviting a relay that has already accepted.
The profile is the one that needs reordering. `runUpdateGroupProfile` commits the new profile locally (`Commands.hs:4016`) *before* the synchronous link write (`:4034`), so any failure — a CAS rejection, or just a network error today — leaves the profile changed on this device and nowhere else. Building the link data from the new profile does not require persisting it first (`groupLinkData` is pure), so the order should be: merge and write, then commit locally and broadcast. Then a rejection retries invisibly, a genuine conflict aborts with the local state untouched and the user sees the other owner's value, and the pre-existing "profile silently diverges when the link write fails" bug goes away with it.
Two owners genuinely disagreeing — one adding a relay, the other removing the same one — is not a race and no merge rule resolves it; last writer wins.
### 4.3 Owner chain ordering
@@ -106,17 +212,24 @@ Relay membership is a set with meaningful removals, so an owner republishing a s
## 5. Backend work (simplex-chat)
**Persist the owners chain.** Add `owner_auth_sig BLOB NULL` and `owner_auth_index INTEGER NULL` to `group_members` (migration + cabal + SQLite and Postgres). The public key is already there as `member_pub_key`, so this cannot drift from the member record by construction, and the index carries the order §4.3 requires. `groupLinkData` then **reads** the stored chain rather than deriving a singleton from `groupKeys`, which makes "each publish evicts the other owner" structurally impossible rather than merely unlikely.
**Persist the owners chain.** Add `owner_auth_sig BLOB NULL` and `owner_auth_index INTEGER NULL` to `group_members` (migration + cabal + SQLite and Postgres). The public key is already there as `member_pub_key`, so this cannot drift from the member record by construction, and the index carries the order §4.3 requires.
- `Internal.hs:1509` `groupLinkData` — read the chain; delete the `GRKPrivate`-derived singleton and the `_ -> []` fallback.
**Restructure the publish path as a delta (§4.2).** `groupLinkData` currently returns a whole `UserContactData` built from local state, and both callers (`setGroupLinkData`, `setGroupLinkDataAsync`, `Internal.hs:1427/1437`) hand it straight to the agent. It must instead take the published data and a delta describing what this write changes, and return the merged object. That is the single change that makes multi-owner publishing safe; the alternative — leaving it a snapshot and hoping owners do not collide — cannot be made correct.
- `Internal.hs:1509` `groupLinkData` — becomes "apply this delta to the published data" rather than "build the whole thing from local state": read the stored chain, delete the `GRKPrivate`-derived singleton and the `_ -> []` fallback. Its two callers, `setGroupLinkData` (sync) and `setGroupLinkDataAsync` (async), become the single place that owns merge, compare-and-swap and bounded retry (§4.2) — no caller sees any of it.
- Per-group link-update worker for the async writers (relay connected, roster acked, member count): they enqueue a delta rather than fully-built data, because `setConnShortLinkAsync` executes the agent's queued command later (`Agent.hs:1811`) and the agent cannot re-merge on rejection. Also serialises this owner's own writes.
- `Internal.hs:1458` `updatePublicGroupData` — trigger unchanged (it still publishes per join/leave, and is also the only publish for relay removal and departure); the number now comes from the relays' reports rather than `summary_current_members_count`, and count publishes are coalesced to at most one per few seconds (§4.2). A relay-set change is not coalesced.
- `XGrpMemNew` and its departure counterparts gain an optional relay-stated subscriber count; the relay fills it from the number its own `updatePublicGroupData` branch already computes. Owner side stores it per relay and uses the maximum across active relays, seeding from published link data on first reconcile.
- `Internal.hs:1458` `updatePublicGroupData`, `Commands.hs:4011` `runUpdateGroupProfile`, `Commands.hs:2713` `APIAddGroupRelays` — all gate on `memberRole' membership == GROwner` but need "can write link data" (an owner mid-handover cannot). One predicate, used at all three.
- `Commands.hs:4016` `runUpdateGroupProfile` — commits the profile locally before the synchronous link write at `:4034`. Reorder to write first, then commit and broadcast (§4.2); this also fixes the existing case where a failed link write silently diverges the profile.
- `Commands.hs:3268` `APIAddGroupShortLink` asserts no role at all — add the same gate.
- `Commands.hs:2872` `APIMembersRole` — route `newRole == GROwner` on a channel into the §3 flow.
- `Subscriber.hs:3345` `allowCreate` — widen so an owner-signed `x.grp.mem.role` carrying a key can TOFU-create a `GROwner`; `isRosterRole GROwner == False` blocks it today, so subscribers can never materialise a new owner.
- `Commands.hs:2936` `mKey` — send the key on owner promotion, not only when a roster version is present.
- `Subscriber.hs:4195` — the "owners are already known to every member" skip is false for a newly promoted owner; disseminate their profile.
- `Subscriber.hs:1422` — `publicMemberCount > 1` assumes exactly one owner. Use a subscriber count or `> ownerCount`.
- `Commands.hs:1918` `syncSubscriberRelays` — §4.2.
- `Subscriber.hs:1411-1458` — the `LINK` handler promotes relays to `RSActive`/`RSInactive` from `auData`, which is the client's own sent data echoed back (`Agent.hs:1813`), not the server's state. Relay activation must key off an accepted write (a CAS-confirmed `LSET`, or the reconcile tick under fallback), not the echo, which is self-confirming.
- New owner-side reconcile worker (§4.2): a periodic `LGET` for groups where the user is an owner. None exists — `checkRelayServedGroups` (`Commands.hs:5254`) is relay-side (`getRelayServedGroups`), and `syncSubscriberRelays` runs only from the user-initiated `APIGetUpdatedGroupLinkData` (`Commands.hs:1909`) and is skipped for owners (`:1918`).
- `Commands.hs:1192` — share-link owner signing requires `GRKPrivate`, so a promoted owner silently emits `ownerSig = Nothing`. Sign with the member key of any listed owner; `verifyLinkOwner` already resolves by `ownerId`.
- `Store/Groups.hs:2320` `updateRelayGroupKeys` — the `forM_ owners` loop exists but `getGroupMemberIdViaMemberId` throws for an owner the relay has no row for, aborting the transaction. Create the record — reuse `createLinkOwnerMember`, which the subscriber path already uses for exactly this.
- `Commands.hs:5254` `checkRelayServedGroups` — the relay's periodic worker already re-reads link data but binds only `relays`, discarding `owners` from the same `UserContactData`. Refresh owners here; it is the relay's only path to learning of owner changes.
@@ -129,7 +242,10 @@ Relay membership is a set with meaningful removals, so an owner republishing a s
- `Crypto/ShortLink.hs` — extract `mkOwnerAuth :: OwnerId -> PublicKeyEd25519 -> PrivateKeyEd25519 -> OwnerAuth`; redefine `newOwnerAuth` on top of it; export. Removes the formula duplicated across the two repos.
- Persist `linkRootSigKey` — new `rcv_queues` column + migration (`AgentStore.hs:2514`). Without it the non-root branch of `validateOwners` cannot work.
- RKEY agent API — add a recipient key to a contact-link queue.
- Compare-and-swap on `LSET` (§4.2): a new command tag carrying an expected fingerprint of the stored user data, a new `ErrorType` for the rejection, `currentSMPClientVersion` 4 → 5. The server check goes beside `lnkId' /= lnkId -> err AUTH` in the `LSET` handler (`Server.hs:1484`); the bytes to hash are already in `queueData qr`, so no storage change. Agent side: keep the fingerprint with the link credentials, refresh on every successful write and every `LGET`, and surface a conflict to chat as a distinct error. Fall back to writing blind against servers below v5.
- A link-write API for an owner who does not own the queue. Prefer a standalone `setForeignLinkData` taking explicit creds over materialising a fake `RcvQueue`/`ContactConnection` on owner 2's device: the latter needs an `rcvDhSecret` owner 2 does not have, and risks owner 2 subscribing to owner 1's queue and racing it on inbound requests.
- `LINK` currently notifies with the client's own `userLinkData` (`Agent.hs:1813`). Either return the server's state or drop the payload, so callers cannot mistake it for confirmation.
- If CAS is agreed (§11): an `LSET` variant taking an expected digest of the stored user data, rejected on mismatch; the check sits beside the existing `lnkId' /= lnkId -> err AUTH` in `Server.hs:1484`. The client already has the bytes to compute the digest from its `LGET`, so no read-side change.
## 7. UI
@@ -176,6 +292,10 @@ Cases: owner 2 promoted, verified by an existing subscriber (TOFU path) and by a
**Owner removal.** Out of scope here, and not yet designed. Three things make it harder than it looks: removing a chain entry invalidates every owner it transitively signed (§4.3); SMP-level revocation is a mutual-eviction race (§8); and there is **no last-owner protection anywhere** in the backend — `APIMembersRole` blocks only `selfSelected`, so two owners can already demote each other into an ownerless, unrecoverable channel, and RULE-19 is UI-only. The backend last-owner guard is worth adding regardless of how removal is designed.
**Lost roster updates (§4.1).** Either the losing owner accepts the loss and adopts the winning roster, or it re-applies its pending delta on top and broadcasts at N+1. The latter converges to the union and terminates — the winner does not retry, because its change survived — at the cost of persisting the pending delta.
**Disseminating the fingerprint after a write (§4.2).** An owner could tell the others what the link now hashes to, so the next one to write is warm and is not rejected. **Broadcasting the fingerprint alone is unsafe and must not be done.** The fingerprint's entire job is to attest *"I merged onto exactly these bytes"*; an owner that adopts a fingerprint for content it does not hold will pass the check while merging onto its stale view, so the write is accepted *and* silently clobbers — strictly worse than the rejection it avoids, because a rejection is at least honest.
**Relay resurrection (§4.2).** Either accept that a stale republish can restore a removed relay, or make relay removal element-wise (tombstones, or a version on the link data). Owner removal has the identical shape, so the two are best decided together.
The safe forms both cost more than they save here. Broadcasting the new content *verbatim* alongside the fingerprint works — the receiver replaces view and fingerprint together, and must not instead re-apply a delta to its own view, since any divergence yields a valid fingerprint over wrong bytes and the same clobber. But it spends ~13.8 KB per write on every other owner, whether or not any of them writes next, whereas a rejection costs one owner ~13.8 KB up and ~13.8 KB back, and only when it actually writes before its next tick. With one owner doing most of the writing — the normal case — the broadcast is pure waste. Alternatively, hashing the plaintext rather than the ciphertext would make a fingerprint-only broadcast self-verifying, since a receiver could accept it only if its own view hashes to the same value and otherwise fall back to reading; that is the only sound way to send a bare fingerprint, but it needs byte-exact agreement to ever hit, and it carries the server-side costs in §4.2.
Not worth it now: with the count on a single designated publisher, the writes this would optimise happen weekly. Revisit if owners turn out to alternate often.
**Handover of the count publisher (§4.2).** "The next owner takes over when the first stops writing" needs a rule — how long a gap counts as stopped, and how a resumed first owner steps back — but it does not need to be a good rule, since the cost of getting it wrong is two owners briefly both publishing the same relay-sourced number, one of them rejected. Settle it during implementation.