fix(sync): converge channel sidebar state across devices on the same identity - #6525
fix(sync): converge channel sidebar state across devices on the same identity#6525wpfleger96 wants to merge 13 commits into
Conversation
3da6677 to
d0c23c9
Compare
Channel-section sidebar state diverged between a user's devices and sometimes never self-healed. Four client-side gaps fed the divergence: - A local edit that lost whole-blob LWW was silently republished as remote content while the UI kept showing the edit. Now the manager adopts the winning remote head (writes it through to state + storage, advances the watermark) and skips publishing, unifying with the relay's OK-false conflict path as one convergence mechanism. - Edits made inside the 2s publish debounce were dropped on quit or community switch. A durable localStorage outbox persists every edit synchronously and resumes it on next mount; adopt clears the outbox so a superseded edit can never be replayed. - A skewed remote head could push the published createdAt past the relay's future-drift window and wedge all later publishes. createdAt is now clamped inside that window. - Stale-at-open state waited for a reconnect that a healthy socket never fires. A reconciliation loop periodically refetches the head (steady 60s, backoff on failure) and refreshes on window visibility. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three cross-layer races defeated the one-convergence-mechanism design: - An older in-flight publish unconditionally cleared pending state on completion, erasing a newer edit queued mid-flight. Each pending edit now carries a monotonic generation; a completion clears pending/outbox/retry only via compare-and-swap on the generation it published. - Hook-level remote application (bootstrap/live/periodic) cancelled the pending publish's timers without deciding supersession, stranding the durable outbox and clobbering the optimistic edit. applyRemote now defers entirely to a pending edit, whose own debounced publish converges via publish-or-adopt; the manager's adopt path clears pending before write-through so the winning remote still applies. - The equal-timestamp tie-break kept the largest event id, opposite the relay/database canonical order (created_at DESC, id ASC → lowest id wins). applyRemote now applies a strictly-lower id and ignores ids >= the last applied, so the UI converges on the event the relay actually stored. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useChannelStars, useChannelMutes, and useChannelSortPreference carried the same inverted equal-timestamp comparator as channel sections: applyRemote kept the largest event id, opposite the relay/database canonical order (created_at DESC, id ASC -> lowest id wins). Two devices writing the same second could leave the UI showing an event the relay did not store. Apply a strictly-lower id and ignore ids >= the last applied, matching the sections fix and the relay winner across all four 30078 sidebar surfaces. Each hook gains a regression test: larger-then-lower id delivery at equal timestamp, lower-id store wins (mutation-checked - reverting >= to <= fails each). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two convergence holes one layer under the pass-1 fixes: Sections: the pre-publish head check compared the fetched head against the mutable lastRemoteCreatedAt, which a live event observed during the debounce window already advanced to that same head — equality fell through to publish and the local blob overwrote a remote that became head after the edit was queued. Freeze a canonical head baseline (created_at, id) at publishSections and compare the fetched head against that generation baseline instead, adopting when the head advanced. Stars/mutes: applyRemote admits the canonical lower-id winner but then mergeStores resolved equal per-entry updatedAt as local/prev-wins, so a stale larger-id value delivered first survived and undid the winner. Add mergeApplyingRemote which resolves an entry-timestamp tie toward the canonical incoming blob while keeping strictly-newer local entries. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each prior round patched one cross-generation interleaving and opened another a layer deeper. Kill the race class structurally instead. Sections: serialize publish cycles (one in-flight at a time; a newer edit queued mid-cycle defers and the completion re-drives it). The per-edit pre-publish baseline is frozen at queue time, so a genuine remote observed during the debounce window still adopts, while our own accepted head is folded forward via canonicalMax so a stale generation's own write is never mistaken for a competing remote and adopted away. Dual generation guards in doPublish (post-fetch and pre-publish) stop a stale generation signing or publishing after a newer edit exists. Stars/mutes: scope mergeApplyingRemote (remote-wins on entry-tie) and the pending-publish cancel to fire only on a canonical supersession of an already-applied same-timestamp larger-id head. Every other application (bootstrap/live/newer-timestamp) keeps local-wins mergeStores and does not cancel the pending publish, so a later same-second local click is no longer clobbered by an older remote entry that decrypts late. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…biguous-ACK heads Round-4 client-side convergence fixes for channel-sections/stars/mutes sync, closing two silent edit-loss variants that survived publish serialization. Ambiguous-ACK fold: a publish whose ACK is lost may still have been accepted by the relay. Retain each attempt's signed id; when a later cycle's pre-publish fetch returns a head whose id matches a prior attempt, fold it forward as our own accepted predecessor and publish above it instead of adopting it away and erasing the queued edit. A head the relay never accepted can never surface by id, so the fold is proof-gated on an exact id match. Canonical-supersession dirty overlay (stars + mutes): a lower-id canonical correction that arrives after a same-second local click must not clobber the click. Apply the correction to the prior remote layer, then overlay entries changed locally since that layer; never cancel a pending publish merely because a correction arrived. Client-only: loss discovery relies on the existing pre-publish fetch, live subscription, and reconcile loop rather than a relay conflict signal. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace the LWW register plus ownership/dirty-set/canonical-supersession machinery with a per-entry Lamport `rev` and a single max-merge on every path, mirroring the read-state data model. Each entry carries an additive optional `rev` (missing implies 0; payload stays `version: 1` so older builds keep parsing our blobs). One `mergeStores` orders by updatedAt then rev then the starred/muted-true leaf, and ends in the 500-entry bound. Clicks stamp `updatedAt = max(now, localEntry?.updatedAt ?? 0, maxUpdatedAtSeen(id))` and mint `rev = max(localEntry.rev, maxRevSeen(id)) + 1`, so a click strictly dominates every state its replica has observed and cannot lose to a same-second remote. The sync managers hold a per-channel two-field high-water map fed by a single `observe()` on every ingest path. Stars sync keeps the generation-CAS + single-flight lane and bounded-backoff retry plus a durable outbox so an in-flight publish can never clear a newer pending edit; mutes sync mirrors it. Remote ingestion never touches the pending lane. Deleted: mergeApplyingRemote, mergeStoresWithTie, mergeCanonicalSupersession, dirtyChannelIds, lastAppliedRemoteTs/lastAppliedEventId, and the event-clock branch. Sections and sort are untouched. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… contract The hard-eviction-branch test asserted only the rev tuple outcome on two one-entry stores while its comment claimed the >500-entry eviction/remount setup. Build the real fixture: >500 equal-updatedAt entries so the bound evicts the target by the id tiebreak, then a remounted rev-1 click merged against the retained rev-100 remote, asserting the deterministic rev-100 outcome in both merge orders. Stars and mutes. The unobserved-future mixed-fleet residual was stated but never exercised directly: the fast-clock suites cover only the observed-future fix. Add a click with an empty high-water at t, then a genuinely unobserved opposite-value head at t+300 that wins on the primary updatedAt key. Both hook suites. Test-only; no production source changes. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at exact head 0d797b550e405eea9557e053f884533ad3dd891a. The relay ordering and the per-entry max-merge are coherent, but the lifecycle still has reproducible edit-loss paths.
[P1] The durable outboxes are not safe across desktop windows
Sections, stars, and mutes share one localStorage outbox key per identity + relay, but each window owns only an in-memory generation. A write in one window therefore replaces another window's pending payload, and a completion in either window removes the shared key without proving that it still owns the persisted value.
For sections, window A can start publishing edit A, window B can replace the outbox with newer edit B, and A's ACK then clears B's outbox. If B closes before its debounce fires, the next bootstrap applies relay head A before discovering there is no outbox, so B is permanently lost. See channelSectionsStorage.ts:211-245, channelSectionsSync.ts:262-281,284-313,522, and useChannelSections.ts:138-156.
Stars and mutes can lose independent clicks even earlier: two windows starting from the same store can click different channels before receiving each other's asynchronous storage event. Their whole-store main/outbox writes race, teardown cancels both timers, and remount resumes only the last blob. The storage handler merges only into React state; it does not durably merge the main store or outbox. See channelStarsStorage.ts:206-242, useChannelStars.ts:57-72,95-113, and channelStarsSync.ts:256-260,383-393 (mutes mirror these paths).
Please give persisted attempts cross-window ownership, not only manager-local generations. A per-operation outbox whose owner deletes only its own record, or an actual cross-window serialization mechanism, would close both overwrite and stale-clear races. Add multi-window tests that interleave write, ACK, storage delivery, teardown, and remount.
[P1] Sort preferences still drop pending edits on ordinary lifecycle and failure paths
publishSortPrefs keeps intent only in memory (channelSortSync.ts:113-121). destroy() deliberately cancels and discards it (:252-262), while a failed publish only logs and never retries (:167-210). A live remote also cancels the debounce while leaving pendingStore stranded (useChannelSortPreference.ts:82-103).
Reproduction: change a sort mode and quit/switch communities within two seconds, or let one publish time out and remount. Bootstrap then whole-blob-replaces the local cache with the relay head (useChannelSortPreference.ts:108-122), visibly reverting the user's choice. Please carry the durable outbox, generation ownership, serialized retry, and pending-aware remote application used by sections over to sort, with lifecycle tests.
[P2] Future relay heads can wedge stars, mutes, and sort publishing
Sections clamps created_at inside the relay's future-drift window (channelSectionsSync.ts:463-472), but stars (channelStarsSync.ts:298-301), mutes (matching code), and sort (channelSortSync.ts:183-186) stamp lastRemoteCreatedAt + 1 without a cap. If a self-authored head was accepted near the relay's +900s boundary, a correctly clocked second device emits +901s; the relay rejects it, and retries continue deriving from the same head until wall time catches up. Apply the same bounded timestamp rule across all four sidebar sync surfaces.
CI is green at this head. I did not duplicate the CI-equivalent suite locally; these failures are source-reproduced interleavings absent from the current tests.
0d797b5 to
16aeab6
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Re-reviewed exact current head 16aeab68b094baaba508a0f2fc91c06b0c430c1c against base ef0d2025683869418e8eee22ac5b5ac16c5198b7 after the force-rebase. Changes are still required.
All 19 files in this PR have the same Git blob IDs as at previously reviewed head 0d797b550e405eea9557e053f884533ad3dd891a, including all production sync files and tests. The only sidebar differences between the old and new repository trees are three UI files inherited from the new base; they do not touch the NIP-78 sync producers, consumers, or their direct dependencies. Consequently, the previous review's blockers survive unchanged:
- P1: sections/stars/mutes still lack cross-window ownership for the shared durable outboxes. Manager-local generation fencing cannot stop one desktop window from overwriting another window's persisted payload or from unconditionally clearing the newer payload after its own older ACK/no-op/adopt completion. The existing tests remain single-manager and do not cover two windows sharing the key.
- P1: sort still drops pending intent. It still has only an in-memory
pendingStore, no durable outbox or failure retry,destroy()still cancels and nulls the edit, and a live remote can still cancel the timer while leaving the intent stranded. - P2: stars, mutes, and sort still derive
created_at = max(now, lastRemoteCreatedAt + 1)without the future-drift clamp used by sections. A self-authored head accepted near the relay's +900s limit can therefore wedge subsequent writes until wall time catches up.
Please address the concrete reproductions and repair boundaries in the review on 0d797b550: #6525 (review)
Current CI is not green: Desktop Smoke E2E (2) failed at this head while several desktop jobs remain in progress. I am not using that still-unclassified failure as a separate code finding; the source blockers above independently require changes.
Carl's re-review found three edit-loss paths remaining after the stars/mutes rev-merge landed. [P2] Stars, mutes, and sort stamped createdAt = lastRemoteCreatedAt + 1 uncapped, so a self-authored head accepted near the relay's +900s drift boundary wedged every later publish until wall time caught up. Extract the sections clamp into a shared clampPublishCreatedAt in sidebarSyncWatermark.ts (all four surfaces already import that module) and wire sections/stars/mutes/sort to it. [P1] Sort preferences never got the durable lane: doPublish only logged on failure, destroy() discarded the pending edit, and a live remote cancelled the debounce with the edit stranded. Port the sections lane — durable outbox + bootstrap resume, generation/CAS ownership, single- flight + completion re-drive, 2s->30s backoff, 60s reconcile loop, and pending-aware remote application. Sort stays whole-blob LWW; a lost head is adopted at pre-publish rather than republished. Tests: a clamp test per surface, and sort lifecycle coverage — outbox resume after destroy-inside-debounce, retry without a later edit, overlapping-generation safety, live-remote-during-debounce adopt, and a hook-level pending-defer test. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sections, stars, mutes, and sort each persist an unpublished edit under one
localStorage outbox key per identity+relay, but generation ownership is only
in-memory per window. Without cross-window ownership, one window's completing
publish could clear a peer window's still-unpublished edit, and (on the merge
lanes) a peer's write could overwrite an edit before it published.
Every outbox write now mints an ownership token and stores a {store, token}
envelope; a completing publish compare-and-clears only when the stored token
still matches its own, so a peer's newer write survives an older window's ACK.
Stars and mutes additionally read-merge-write both the durable outbox and the
main store via their per-entry mergeStores, so two windows editing different
channels both survive; sort and sections replace whole-blob with LWW resolution
matching the relay. Sort also gains the durable outbox + bounded retry it
previously lacked, and stars/mutes/sort now clamp publish created_at inside the
relay's future-drift window like sections. The envelope reader tolerates a
legacy token-less entry so an outbox written by a prior build still resumes.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at exact head 9141fe292268e2681e65664bbeb708b732af2535.
[P1] The multi-window outbox operations are still racy
The new ownership token does not make the localStorage operations atomic. clearOutboxEntry reads and validates the stored token, then calls removeItem separately (sidebarSyncWatermark.ts:141-162). Window A can read token A, window B can write its newer {store, tokenB} envelope, and A can then remove B's value using the stale read. That still loses an unpublished edit on quit/remount.
Stars and mutes have the matching write-side race: writeChannelStarsOutbox reads the shared entry, merges in memory, then writes separately (channelStarsStorage.ts:226-237; mutes mirrors it). If two windows read before either writes, each computes a one-sided merge and the later setItem drops the other pending click. The main-store read/merge/write has the same shape (channelStarsStorage.ts:135-144).
The added multi-window tests execute whole operations sequentially, such as A write, B write, then A clear (multiWindowOutbox.test.mjs:62-167), so they cannot exercise either read/write or read/remove interleaving. localStorage provides no compare-and-delete or transactional read-modify-write primitive; the token proves what was read, not what is still stored at the destructive operation.
Please move this durability boundary to a design that does not depend on atomicity localStorage lacks, such as per-operation append-only records with owner-specific deletion, or an appropriately serialized cross-window store. Add tests that pause operations between their read and write/remove steps and verify teardown/remount preserves every unpublished intent.
The LWW comparator itself matches the relay's created_at DESC, id ASC rule; this review is blocking only on the durability race above. I reviewed read-only GitHub metadata and diff and did not check out or execute PR code.
localStorage has no atomic compare-and-delete or transactional read-modify-write, so a single outbox key shared across every window could not be mutated safely: one window's read-then-write or read-then-remove races a peer's write in the gap and drops its still-unpublished edit. A per-write ownership token narrowed that window but could not close it — the token proves what was read, not what is still stored at the destructive op. Key the outbox per window instead: <prefix>:<pubkey>:<relay>:<nonce>, where the nonce is minted once and parked in sessionStorage. Each window is the sole writer of its own key, so a hot-path write is one unconditional setItem — the write race is designed out, not guarded. Resume enumerates every window's key: merge lanes (stars/mutes) fold all records order-independently; whole-blob lanes (sort/sections) replay the max-queuedAt record with a nonce tiebreak. Redundant foreign keys are reclaimed at boot, gated on durable relay evidence (merge: head subsumes; whole-blob: head created_at supersedes) and re-read immediately before removal so a live peer's fresh write in the recheck gap survives. Reclamation runs only on a successful head fetch, never on a failed one. The token contract is deleted entirely — ownership is the key. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ce-free The per-window token/recheck reclamation still performed a non-atomic compare-then-delete on a mutable foreign key: a live owner could rewrite its key between the reclaim decision-read and the removeItem, and the whole-blob `queuedAt <= head.created_at` gate dropped same-second and legacy `queuedAt=0` records that had not provably lost LWW. Records are now write-once: a key is `<prefix>:<pubkey>:<relay>:<nonce>:<seq>` and is never rewritten. A new edit writes a new key (next zero-padded seq) then deletes its own older keys (write-before-delete, so a crash leaves at least one record). Foreign reclamation reads an immutable record, proves it reclaimable against durable relay evidence, and deletes it with no recheck. Whole-blob supersession is strict (`queuedAt < head.created_at`); replay runs before reclamation in every hook so a same-second record is consumed into pending first; the legacy v1 shared key is only ever replayed, never deleted. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The legacy v1 shared outbox key is never deleted (it is mutable and a concurrently-live old build may still rewrite it), so the whole-blob resume path re-read it on every boot and republished the stale blob above the current relay head forever. A found relay head never stopped it: a fresh manager has no lastPublishedStore and queueing the replay freezes publishBaseline to the just-fetched head. Distinguish compatibility replay from permanently pending intent with a durable per-value consumption marker, whole-blob lanes only. resumeWholeBlobOutbox excludes the legacy record when its exact raw matches the stored marker; a live old build rewriting the key stores a different raw and is replayed again. The hook transfers the intent into its own v2 key (synchronous publish) BEFORE writing the marker, so a crash in that gap replays the blob once more rather than losing it. Merge lanes (stars/mutes) need no marker but gained a head-subsumed gate so a lingering legacy key does not re-drive an identical boot-time publish. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Channel-section sidebar state (the sidebar's channel groupings) diverges between a user's dev build and installed DMG on the same identity: sections differ at app open, sometimes self-heal after minutes, sometimes never, and manual "kick" edits inconsistently force convergence. The cause is a set of client-side sync gaps in the desktop sync managers. This change is client-only — it does not touch the relay or database.
The sidebar has two payload shapes with different convergence needs, and this PR fixes each with the model that fits it: channel sections and sort use whole-blob last-write-wins; stars and mutes are per-entry sets that converge by max-merge. All four surfaces share one publish-timestamp clamp helper (
clampPublishCreatedAtinsidebarSyncWatermark.ts, with theMAX_PUBLISH_FUTURE_SECS = 840rationale in one place) so a skewed remote head can never wedge any lane's future publishes.Multi-window durable outbox (all four lanes)
Every lane persists an unpublished edit to
localStorageso it survives a quit or community-switch inside the 2s publish debounce.localStorageoffers no atomic compare-and-delete or transactional read-modify-write, so a single key shared across windows cannot be mutated safely: one window's read-then-write or read-then-remove races a peer's write in the gap and drops its still-unpublished edit. Will runs the dev build and the DMG concurrently on one identity, so this is a live race, not a theoretical one.The outbox is therefore keyed per window and write-once: a key is
<prefix>:<pubkey>:<relay>:<nonce>:<seq>, where the nonce is minted once and parked insessionStorage(survives reload, gone on window close) andseqis a per-window monotonic counter, zero-padded so lexicographic key order matches numeric order. A window never rewrites a key: a new edit writes a new key as a single unconditionalsetItem, then deletes its own older keys (write-before-delete, so a crash between the two leaves at least one record for replay to coalesce, never zero). Theseqcounter is seeded above the max surviving own key at boot, so a reload — where thesessionStoragenonce survives but the in-memory counter restarts — can never reuse and thus mutate an existing key. Resume enumerates every window's key: merge lanes (stars/mutes) fold all records order-independently; whole-blob lanes (sort/sections) replay the max-queuedAtrecord with a deterministic key tiebreak. A window clears only its own keys once its publish completes.Because records are immutable, foreign reclamation is safe by construction: a booting window reads an immutable foreign record, proves it reclaimable against durable relay evidence, and deletes it — nothing can have changed at that key since the proof, so no byte recheck is needed and a peer's fresh edit (which lands on a new key) can never be destroyed. Merge lanes delete a record the fetched relay head already subsumes; whole-blob lanes delete a record the head's
created_atstrictly supersedes (queuedAt< head), so a same-second record — which one-second clock granularity cannot prove lost LWW — is kept until a strictly-newer head lands. Reclamation runs only on a successful head fetch (never on a failed or absent one) and after replay, so a same-second record the head appears to supersede is consumed into pending before any reclaim can consider it. The single legacy shared key from a pre-per-window build is read as one more record (queuedAt0) and is never deleted by this build: that key is mutable (a concurrently-live old build may be rewriting it) and itsqueuedAt0 makes supersession meaningless, so no gating makes deleting it safe. Because it is never deleted, whole-blob lanes replay it exactly once per value rather than resurrecting it above the current relay head on every boot: a per-lane marker (<prefix>-legacy-consumed:<pubkey>:<relay>) records the exact raw string a boot replayed, and a later boot skips the legacy record while its raw matches. The marker is written only after the intent is durably transferred into this window's own v2 key (the synchronous publish path), so a crash in that gap replays the blob once more rather than losing it; a live old build that rewrites the key with new intent stores a different raw and is replayed again. Merge lanes need no marker — a head-subsumed legacy fold is skipped at replay (no redundant publish) and an unsubsumed one is genuine intent that drains once published. The bounded cost is at most one lingering legacy key (and one marker) per lane per(pubkey, relay), and only when the last old-build session quit with an unpublished edit.Channel sections (whole-blob LWW)
localStorage, advances the sync watermark — and skips publishing. The pre-publish check compares the fetched head against a canonical(created_at, id)baseline frozen when the edit was queued, not the live watermark, so a remote observed during the debounce window that became head after the edit began is adopted rather than overwritten.canonicalMax, so a prior generation's own write is never mistaken for a competing remote and adopted away, and a stale generation cannot sign or publish after a newer edit exists (guarded both after the pre-publish fetch and immediately before signing).created_at DESC, id ASC— lowest event id wins), so the UI converges on the event the relay actually stored instead of the largest id it happened to see first.Channel sort (whole-blob LWW)
Sort preference is a whole-blob value on the same divergence-prone lifecycle path Will reported, but it lacked the durable machinery sections got: its publish catch only logged (no retry),
destroy()discarded a pending edit, and bootstrap whole-blob-replaced local state. It now mirrors the sections manager's durable lane exactly — the per-windowbuzz-channel-sort-outboxabove, generation-CAS single-flight publishes with re-drive, bounded-backoff retry, adopt-winner on lost LWW that writes through to state andlocalStorage, and the shared future-drift clamp. The hook mirrorsuseChannelSections: a pending-awareapplyRemotethat defers to an in-flight local edit instead of cancelling it, adopt-sink write-through, outbox resume on bootstrap, and the 60s reconcile loop with visibility refresh.Stars and mutes (per-entry Lamport-rev max-merge)
Stars and mutes are per-channel sets, not a single blob, so whole-blob LWW is the wrong model: an integer-second
updatedAtregister cannot distinguish a local click from a stale remote landing in the same wall-clock second, and four rounds of ownership/dirty-set/canonical-supersession machinery layered on top could not close it. This PR folds them onto the same monotonic, order-independent model the read-state layer already uses.rev(missing ⇒ 0; the payload staysversion: 1so older builds keep parsing our blobs). A singlemergeStoresorders byupdatedAt→rev→ thestarred/muted-true leaf — a commutative, associative, idempotent total order that ends in the 500-entry bound. Every observation path (bootstrap, live, reconnect, reconcile, pre-publish, cross-windowstorage) calls the same merge with no ordering, staleness guard, or ownership overlay. This deletesmergeApplyingRemote,mergeStoresWithTie,mergeCanonicalSupersession,dirtyChannelIds, and the event-clock/canonical-supersession branch.updatedAt = max(now, localEntry.updatedAt, maxUpdatedAtSeen(id))and mintsrev = max(localEntry.rev, maxRevSeen(id)) + 1, so its(updatedAt, rev)tuple strictly dominates every state the replica has observed for that channel — it can never lose to a same-second remote, andrevearns its place only in the one second the wall clock cannot resolve. This mirrors the read-state layer'smax(now, maxFetchedCreatedAt + 1)idiom. The high-waters live in the sync manager as one per-channel map with two fields, fed by a singleobserve()on every ingest path..has(channel.id)over the active community's channel list produces no cross-relay bleed.Residuals are documented and accepted: an old build behind a future-stamped entry cannot reverse it until its wall clock catches up (identical to today's shipped LWW); a >500-same-second-entry eviction + same-second-remount window can lose a single click deterministically (never a divergence); and two windows' truly-concurrent whole-blob edits resolve by LWW/max-
queuedAt(the newest intent wins, as with two physical devices) — the guarantee is that no window's publish erases another's still-unpublished intent, not a merge of concurrent whole-blob edits.