Files
buzz/docs/formal/nip-rs-unread/NOTE.md
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

39 KiB
Raw Blame History

title, tags, status, created
title tags status created
NIP-RS manual-unread: bounded exhaustive model — candidates A vs B
nostr
nip-rs
read-state
formal-model
buzz
active 2026-07-16

NIP-RS manual-unread encoding model

Bounded exhaustive model comparing two candidate CRDT encodings for a manual mark-as-unread override layer within NIP-RS read state.

Run

python3 exhaustive.py
python3 mutation.py

Both scripts are deterministic and exit 0 on success.

Context

NIP-RS v1 encodes read state as grow-only max(timestamp) frontiers per context. Manual mark-as-unread requires a second source of truth (an override layer) because the frontier cannot be lowered — a lower value is indistinguishable from a stale replica under max() merge.

The override layer must converge across devices, survive legacy client rewrite cycles, and remain bounded within the existing 32 KiB plaintext budget. Two candidate encodings are modeled:

  • A — lexicographic operation register: per context, one register {counter, client_tiebreak, op, baseline} in a NEW top-level field.
  • B — two grow-only counters + baseline: per context, S (set counter), C (clear counter), B (frontier-at-set-time) encoded as sibling keys under contexts.

Model universe

  • 2 upgraded devices + 1 legacy device
  • 2 contexts (c0, c1)
  • Actions: mark-unread, mark-read (with frontier advance), advance-frontier, compact, reinstall (client_id loss), deliver (including duplicate/replay)
  • BFS over canonical global states with interleaved actions and deliveries (not phased), depth-bounded
  • All delivery permutations of published blobs at terminal states
  • Multi-slot union (split blob across 2 slots, deliver separately)
  • Directed deep-history check: compact → new local actions (counter reuse) → delayed stale delivery, over a 672-point parameter cube (stale (S,C,B) × post-compaction frontier × 7 action sequences × 2 tie policies × 1 delivery shape). The prior 2,016-point count included two duplicate split-delivery shapes (split_fwd/split_rev) that became semantically identical to single once the atomic-grouping rule made a single-context compliant split always whole-register+empty; collapsed to one meaningful shape without loss of register-level coverage.
  • Cross-device compaction transparency check: same tombstone, delivered to an unrelated device with its own live concurrent state, over a 312-point parameter cube (stale (S,C,B) × post-compaction frontier × 4 fresh-frontier values × 2 tie policies), plus a monotonicity lemma over 1,728 points (2 tie policies × 4×4×3×3 receiving-register/frontier combinations × 6 ceiling values) proving the ceiling can never strengthen a receiving register's set-counter standing
  • States explored: 7,129 per tie policy (14,258 total)
  • Published-state merge closure: every override is canonicalized against the device's own effective frontier at serialization time before hitting the wire (mandatory, not optional) — live unchanged, dead folded to the tombstone floor, virgin omitted. Checked over a directed witness (Thufir's exact dead+dead pair) plus a general search: every pairwise join of a bounded cube of 300 independently-dead published states (156 clear-wins + 144 set-wins = 300 total across both tie policies), including a one-hop relay republication to cover delayed/multi-hop delivery — 45,074 pairs checked total (156² + 144²
    • 2 directed witnesses)

Invariants checked

# Invariant A B (clear-wins) B (set-wins)
I1 Join associative/commutative/idempotent PASS PASS PASS
I2 Convergence (all delivery orders) not exercised PASS PASS
I3 No frontier regression not exercised PASS PASS
I4 Concurrent set/clear winner stable not exercised PASS PASS
I5 Compaction: no loss, no resurrection (immediate merge-back) n/a PASS PASS
I5c Deep-history: compact → reuse → delayed stale delivery (same-device replay) n/a PASS PASS
I5d Cross-device compaction transparency (suppress-only, not zero-divergence) n/a PASS PASS
I5e Published-state merge closure: dead+dead join stays inactive n/a PASS PASS
I6 Replay harmless not exercised PASS PASS
I7 Legacy rewrite safety FAIL (witness) PASS PASS
I8 Bounded key growth (3 keys/ctx live, 1 key/ctx tombstone) n/a PASS PASS
I9 DeviceA counter absorption PASS n/a n/a

Note: Candidate A is exercised only for I1, I7, and I9. BFS/convergence, frontier-regression, concurrent-winner, and replay tests (I2I4, I6) are Candidate B-only; adding A variants would fail minimalism since A is already dead on I7 (legacy-rewrite erasure).

I5 covers the immediate compacted-vs-pre-compaction merge shape (both merge orders). I5c is the same-device deep-history property this round was originally opened to close: it directly targets the ~9-transition history a depth-4 BFS cannot structurally reach (compact → new local set/clear → delayed stale delivery, including from a second slot), asserting that compaction never resurrects a dead override or drops a live one when the delayed delivery is the compacting device's own pre-compaction ancestor (or an exact copy of it, e.g. a peer that never advanced past the original snapshot).

I5c does not cover, and NOTE.md previously overstated, the cross-device case. Compaction is a storage optimization from the compacting device's own point of view — its dead register's baseline B was frontier-relative to that device's history, and dropping S in favor of the C ceiling is safe against replays of its own past. But once published, the tombstone's C ceiling is globally comparable via componentwise max(), while the baseline-relative death that produced it is not. I5d proves the resulting property precisely: merging in a tombstone can suppress — never resurrect, per the test_tombstone_merge_monotonic structural lemma — a different device's concurrent fresh set whose own counters happen to be at or below the tombstone's ceiling, and the suppression always recovers with one more local mark-unread (verified replay-stable against the same tombstone). This is a one-shot false-negative risk, not a correctness violation of the CRDT join (idempotent/commutative/associative still hold per I1) and not new: an uncompacted stale explicit clear already suppresses a fresh concurrent set under clear-wins with no compaction anywhere (verified directly — see "Tie policy evidence" below); the tombstone extends the same false-negative-preferring shape to baseline-dominated dead sets that were never explicitly cleared.

I5e — published-state merge closure — is a protocol requirement, not an optimization. I5d's suppress-only guarantee assumes the tombstone was actually on the wire before the merge. Nothing forces that: compact_b()/do_compact are a local storage-GC transition a device may or may not have called before it serializes. Without a mandatory canonicalization step, publish_blob() can emit a register's raw (S, C, B) — dead by construction (baseline-dominated, clear-dominated, or a clear-wins tie) but not yet folded into the tombstone's globally-comparable C ceiling. Two such raw-dead registers, published by two different devices for unrelated reasons, can componentwise-max into a live join: each register's S and B came from a different device history, and the merge recombines them independent of either history's own death cause. This is a distinct hazard from I5d's suppression (I5d is a live register losing to a stale dead one; I5e's witness is two dead registers producing a live one) but the same root cause — components taken from independent histories can be recombined in ways neither history's own frontier ever permitted.

Fix: canonical publication is mandatory, not advisory. DeviceB.publish_blob() now canonicalizes every override against the device's own effective frontier at serialization time, unconditionally — live unchanged (3 keys), dead folded to the tombstone floor RegB(0, max(S,C), 0) (1 key), virgin omitted (0 keys) — regardless of whether do_compact was ever called locally first. This is a spec-amendment requirement for any production client implementing this override layer: publication MUST canonicalize before serialization, the same way it MUST advance the frontier monotonically. It is load-bearing correctness, not a storage optimization a client can opt out of. do_compact remains available separately to mutate a device's own self.overrides for local storage-GC purposes; it is no longer a prerequisite for correct publication, because publication no longer depends on prior local state having been compacted.

Proof obligation closed: exhaustive.py::test_published_merge_closure checks two ways — Thufir's exact witness pair (RegB(3,2,0)@baseline-dead-50 join RegB(1,2,100)@clear-dead-100, raw join is live RegB(3,2,100)) as a directed case under both tie policies, and a general search over every pairwise join of a bounded cube of 300 independently-dead published states (156 clear-wins + 144 set-wins = 300 total across both tie policies), including a one-hop relay republication step to cover delayed/multi-hop delivery (a relay that receives one operand alone and republishes — re-canonicalizing — before forwarding). The 45,074 ordered pairs checked comes from 156² + 144² + 2 directed witnesses. mutation.py::mutant_m7 reverts publish_blob to the pre-fix raw-serialization behavior and reproduces Thufir's exact resurrection witness directly, confirming the new invariant has teeth.

Candidate comparison

Convergence

Both candidates converge under all tested delivery permutations (algebraic property). Candidate B achieves this with componentwise max() merge (a standard state-based CRDT join). Candidate A uses a register with lexicographic tuple comparison — also convergent, but the register requires a client-identity tiebreak field. (Convergence for Candidate B is verified by exhaustive BFS over all reachable states; I2I4 and I6 are exercised for Candidate B only — see invariant table.)

Legacy compatibility matrix

Scenario A B
Upgraded publishes, legacy reads blob Legacy drops overrides field Legacy preserves ov_* sibling keys
Legacy rewrites same slot Overrides erased (expected-witness confirmed) Sibling keys survive sanitization
Upgraded reads legacy-rewritten blob Override state lost Override state intact
Legacy reads its own frontier Inert (correct) Inert (correct)
Legacy frontier advance past baseline Cannot clear override (erased) Stale set dominated (correct)

Candidate A's legacy erasure is the decisive defect. The desktop and mobile parsers (readStateFormat.ts:82-108, read_state_format.dart:100-141) reconstruct only {v, client_id, contexts}. A same-slot legacy rewrite drops the top-level overrides field entirely and republishes without it. There is no safe migration path: any user with a single legacy device loses all manual-unread state on the next rewrite cycle.

Candidate B's sibling keys (ov_s:, ov_c:, ov_b:) pass all legacy validation gates — keys are <= 256 UTF-8 bytes, values are uint32 — and round-trip through legacy rewrite unmodified.

Legacy carry-through simplification (documented divergence). Row "Legacy preserves ov_* sibling keys" is proven two different ways in this model, and they are not the same claim:

  • legacy_sanitize_blob — the byte-sanitization function alone (drop keys >256 UTF-8 bytes or non-uint32 values) — genuinely preserves unknown keys as opaque pass-through, matching production sanitizeContexts. test_legacy_rewrite_b (I7) exercises exactly this: an upgraded device's blob is sanitized and received by a second upgraded device; the sibling keys survive because sanitization never touches keys it doesn't recognize.
  • DeviceB(is_legacy=True) — the explorer's legacy device object used in the multi-device BFS (exhaustive.py) — does not carry through ov_* keys it receives. receive_merge parses them into a local dict but the store step is gated on not self.is_legacy (model.py:268), so a legacy device's own publish_blob only ever republishes its own frontier keys, never sibling keys it received from an upgraded peer. This is a deliberate model simplification, not a claim about production: production's legacy client is a single sanitizeContexts pass with no in-memory override model to gate on, so it forwards unknown keys unchanged; the model's DeviceB needed an explicit legacy/upgraded split to represent "does not understand or act on overrides" for the BFS explorer's mark-unread/mark-read action space, and that split was implemented as drop-on-receive rather than store-opaque-and-forward.
  • Why this doesn't hide a defect: every invariant that asserts sibling-key survival through a legacy hop (I7) is checked via the sanitize function directly, never via a DeviceB(is_legacy=True) relay round-trip — the two paths are never conflated in a single assertion. The BFS explorer's own legacy-device transitions are also gated: enabled_transitions only enqueues mark_unread/mark_read/ compact for a device if not d.is_legacy (exhaustive.py:118-124), so a legacy device in the BFS never even attempts to act on overrides; do_mark_unread/do_mark_read (model.py:210-222) additionally carry an explicit if self.is_legacy: return no-op guard as defense-in-depth for the same property. do_compact (model.py:227-236) carries no such explicit guard — it is a no-op for a legacy device only transitively, because self.overrides is never populated for one (every write path into self.overrides is already gated on not self.is_legacy), so do_compact finds self.overrides.get(ctx) is always None and returns immediately. Either way, the drop-on-receive simplification never changes the BFS's own convergence or compaction verdicts (I2, I3, I5, I5c, I5d) — those are computed only over upgraded devices' override_is_set. The one place a real production legacy client does matter for override survival — sanitizing an upgraded device's own re-published blob — is I7's scope, and I7 uses the accurate function.
  • Implication for implementation: production's sanitizeContexts pass-through behavior is correct and required; this note exists so a future reader of DeviceB.receive_merge doesn't mistake the model's drop-on-receive simplification for a claim that legacy relaying loses override state in production — it doesn't, per the function-level proof above.

Identity dependence

  • A requires client_id for the tiebreak field. After reinstall (new client_id), the tiebreak changes. Convergence is preserved only because the counter is strictly higher; a same-counter reinstall would create an ambiguous merge.
  • B needs no client identity — componentwise max() is identity-free. Confirmed: reinstall with new client_id preserves convergence.

Bytes per manually-unread context

Sizes computed with realistic context IDs. Envelope cost ({"v":1,"client_id":"...","contexts":{}}) is ~60 bytes and shared across all contexts — amortized to near zero per context.

Context type Context ID example ID length Live override keys (3) Tombstone key (1)
Channel b68cd7cb-6f8d-4641-b743-a7349eb4114b 36 138 bytes 45 bytes
Message msg: + 64-hex event ID 68 234 bytes 77 bytes
Thread thread: + 64-hex event ID 71 243 bytes 80 bytes

Live-override bytes are unchanged by the reserved-namespace escaping (below): every context ID Buzz actually generates (channel UUID, msg:hex64, thread:hex64) is a no-op under escape_context_key — none begin with ov_ or esc: — so the escape marker costs 0 bytes in the common case. Tombstone bytes are new in this revision: canonical publication no longer serializes a dead register at 3 keys (see "Compaction behavior" below and "Published-state merge closure" above) but a single ov_c: key with the counter ceiling — this is now the literal output of publish_blob() for any dead override, not merely the output of the optional do_compact storage-GC step.

Breakdown for channel context (worst real-world common case, live):

"ov_s:b68cd7cb-6f8d-4641-b743-a7349eb4114b":1    → 44 chars
"ov_c:b68cd7cb-6f8d-4641-b743-a7349eb4114b":0    → 44 chars
"ov_b:b68cd7cb-6f8d-4641-b743-a7349eb4114b":10   → 45 chars
                                          total  ≈ 138 bytes (+ 2 commas)

Tombstone floor for channel context (dead override after compaction):

"ov_c:b68cd7cb-6f8d-4641-b743-a7349eb4114b":3    → 45 chars ≈ 45 bytes

Candidate A for comparison: {"counter":1,"tiebreak":"dev0","op":"SET","baseline":10} ≈ 56 bytes per context as a JSON object, plus the top-level overrides field overhead. However, this is moot since A's top-level field is erased by legacy clients.

Reserved key namespace

NIP-RS v1 context IDs are arbitrary UTF-8 (spec :89, :113-114), so a pre-existing opaque context could legitimately begin with ov_s:, ov_c:, or ov_b: and, once flattened into the same contexts map, be misparsed as a control key for a different context.

Reservation: the 3-byte stem ov_ and the escape marker esc: are reserved at the spec-amendment level. A raw context ID that begins with either is escaped on publish by prepending esc:, and unescaped on receive by stripping exactly one leading esc: (model.py: escape_context_key, unescape_context_key). This is a bijection, not an idempotent no-op: a context literally named esc:foo escapes to esc:esc:foo on the wire and unescapes back to exactly esc:foo on receipt — the two operations are inverses, so no collision or data loss occurs even for context IDs that already contain the marker.

Cost: zero bytes for every context ID Buzz generates today (channel UUID, msg:hex64, thread:hex64 — none start with ov_ or esc:). Only a context ID that happens to start with the reserved stem pays the 4-byte esc: prefix.

Backward-compatibility limitation (Thufir's qualification — not a collision-safe migration of existing data): a context published unescaped by a client that predates this amendment, and that happens to start with ov_ (e.g. an already-published, pre-existing ov_s:evil-style context), is not safely migrated by this scheme. Retroactive escaping cannot rewrite a blob the original publisher never knew needed escaping — the codec protects contexts generated by amendment-aware clients going forward, not history that predates the amendment. This is a theoretical concern for the reasons in the ">256-byte key drop hazard" section: Buzz's own key shapes cannot trigger it, and no legacy client is known to generate ov_-prefixed context IDs. Documented as a residual, unsolved, backward-compatibility gap — not modeled further — per the same practical-risk reasoning already applied to the 256-byte hazard below.

Verified: exhaustive.py::test_reserved_namespace_collision — a context literally named ov_s:evil round-trips through publish/receive as frontier state (not misparsed as an override), and a real override on a different context in the same blob is unaffected.

Counter headroom (uint32)

Each counter (S, C) is a uint32: 2^32 - 1 = 4,294,967,295. At one toggle per second, ~136 years. No practical concern for manual right-click actions.

>256-byte key drop hazard

Legacy sanitizeContexts drops any key with len(key.encode('utf-8')) > 256. Adding the ov_s: prefix (5 bytes) to a context key creates a key of len(context_id) + 5 bytes. If the original context key is at or near the 256-byte limit, the prefixed override key exceeds it and is silently dropped by legacy sanitization.

In practice, context keys are UUIDs (36 bytes), hex event IDs (64-68 bytes), or thread IDs (71 bytes) — all well under 256 bytes. The longest common override key (ov_b:thread: + 64-hex = 76 bytes) has 180 bytes of headroom. This hazard is theoretical but should be documented in the spec.

10,000-key validation limit

Legacy isValidBlob rejects blobs with >10,000 context keys. Live override keys consume 3 entries per overridden context; a compacted (tombstoned) override consumes 1:

Overridden contexts Live override keys Typical frontier keys Total Headroom
50 150 ~500 650 93.5%
100 300 ~1,000 1,300 87%
500 1,500 ~2,000 3,500 65%
3,000 9,000 ~1,000 10,000 0% (limit)

The 32 KiB byte budget is the binding constraint long before key count.

Compaction behavior (tombstone-floor, policy-dependent)

Revision note: the prior "compacts to zero" design (delete-on- dominance: a dead register was dropped entirely, 0 keys) is retracted. Thufir's pass-3 review found a stale-replay resurrection: dropping all (S,C) state made counters reusable, so a new local set/clear pair restarting from S=0,C=0 could be dominated by a delayed stale peer snapshot on replay (RegB(3,0,10) → compact → None → local set+clear → RegB(1,2,20) → stale replay merges in → RegB(3,2,20), S>C, resurrected). Fixed by a tombstone floor: any register with recorded activity (S>0 or C>0) is never fully deleted — dead state compacts to RegB(0, max(S,C), 0) instead of None. Only a virgin register (never set, S==0 and C==0) has no ceiling to protect and compacts to None.

The compaction rule is now uniform across the dead cases — the per-branch table collapses to a single test:

Condition Clear-wins Set-wins
override_set_b(reg) is True (live) Do not compact Do not compact
override_set_b(reg) is False and S>0 or C>0 (dead, ever-active) Compact to tombstone floor RegB(0, max(S,C), 0) Compact to tombstone floor (same)
S == 0, C == 0 (virgin, never set) Drop entirely (None) Drop entirely (same)

Because override_set_b is already policy-aware, "live" vs. "dead" differs by policy exactly where it did before (S == C, S > 0 is dead under clear-wins, live under set-wins) — the tombstone floor rule itself does not need to branch on policy; compact_b calls override_set_b once and only tombstones the false branch.

Under clear-wins, a dead override compacts to the ~45-byte tombstone (one ov_c: key, channel context) — not to zero, because C must persist as the reuse-blocking ceiling. Under set-wins, S == C overrides remain live and are never compacted (3 keys, ~138 bytes for channel contexts) — unchanged from the prior revision.

Proof obligation closed (same-device replay): exhaustive.py::test_deep_history_compaction (672-point parameter cube) and test_tombstone_stale_merge_direct verify no resurrection and no loss of a genuinely-live override across the compact → new-action → delayed-stale-delivery shape, for both tie policies. mutation.py::mutant_m4 reverts to the old delete-on-dominance rule and reproduces the exact resurrection witness (final_reg=RegB(s=3, c=2, b=20), override_is_set=True) — confirming the suite would have caught the defect this round was opened to fix.

Proof obligation closed (cross-device transparency, requalified — suppress-only, not zero-divergence): exhaustive.py::test_cross_device_compaction_suppression (312-point cube: stale ancestor (S,C,B) × post-compaction frontier × 4 fresh-frontier values on the receiving device × 2 tie policies) proves every divergence between "receive the tombstone" and "receive the uncompacted ancestor" is a suppression of an unrelated device's live set — never a resurrection — and that every suppression recovers with one more local mark-unread and stays recovered after re-receiving the same tombstone. test_tombstone_merge_monotonic proves the direction structurally (not just over the bounded cube): merging in a tombstone RegB(0, k, 0) for any ceiling k can only raise the receiving register's C, never its S or B, so it can only weaken — never strengthen — the receiving register's live/dead standing under override_set_b. Together these close the compaction-safety proof obligation to exactly what it can honestly claim: no resurrection ever, one-shot suppression is a known and recoverable false-negative risk inherent to the clear-wins/tombstone design, not an unbounded correctness gap.

GC/tombstone behavior

Override keys with ov_ prefix (legacy prune): Legacy pruneStaleContexts only drops msg:/thread:-prefixed keys past the 7-day horizon. Unknown-prefix keys (including ov_*) are kept forever:

  • Permanent tombstones: every override that is ever compacted while dead leaves a permanent ov_c: key (~45 bytes, channel context) — this is no longer a "harmless, can shrink to zero" cost; it is a durable floor kept forever to block stale-replay resurrection. This is the direct storage consequence of fixing the CRITICAL above and must be budgeted, not treated as free.
  • Live overrides: an override still live (per override_set_b) keeps all 3 keys (~138 bytes, channel context) until it becomes dead and is compacted down to the tombstone.

Alternative: nesting under msg:/thread: prefixes — confirmed state-loss hazard. Legacy prune would delete overrides at the 7-day horizon, silently losing active unread markers. Rejected.

Legacy trim interaction

Legacy trimContextsToBudget evicts only msg:/thread: keys. Override ov_* keys (including tombstones) are never evicted. Budget analysis by context type, worst case (all overrides still live, 3 keys each — the tombstone floor only ever reduces this cost):

Overridden contexts Context type Live override bytes With ~10 KiB frontiers Fits 32 KiB?
50 Channel (UUID) ~6.9 KiB ~16.9 KiB Yes
100 Channel (UUID) ~13.8 KiB ~23.8 KiB Yes
150 Channel (UUID) ~20.7 KiB ~30.7 KiB Marginal
50 Message (hex64) ~11.9 KiB ~21.9 KiB Yes
100 Message (hex64) ~23.7 KiB ~33.7 KiB No

At the 100-override cap with every override compacted to its tombstone floor instead: ~4.5 KiB (channel contexts, 100 × 45 bytes) — well within budget alongside a full frontier set. The permanent-tombstone floor from the CRITICAL fix costs storage but is bounded and small; it does not change the 32 KiB conclusion below.

Mitigation: Upgraded clients should compact aggressively (any dead override, not just baseline-dominated ones) and enforce a cap on active override count. A cap of 100 channel-context overrides keeps live override budget under ~14 KiB and tombstoned budget under ~4.5 KiB, both within the 32 KiB limit alongside a full frontier set.

Tie policy evidence: clear-wins vs set-wins

Both tie policies pass all invariants. The choice is a product-semantics decision:

  • Clear-wins (S == C → read): If two devices concurrently set and clear the same context, the result is "read." Conservative — no spurious unread badges. Matches the "I already read this" signal being more definitive than the "remind me" signal. Compaction advantage: S == C states are compactable.
  • Set-wins (S == C → unread): Concurrent set and clear results in "unread." Preserves the reminder intent. Risk: a user who reads on one device while another has a stale mark-unread gets a persistent badge they can't clear without an explicit action. Compaction disadvantage: S == C states are live and cannot be compacted.

Recommendation: Clear-wins. A false negative (missing badge) is recovered by re-marking unread. A false positive (badge that won't clear) is more frustrating. This matches Slack's behavior: reading anywhere clears everywhere. The compaction advantage further favors clear-wins.

Pre-existing false-negative risk (independent of compaction). Under clear-wins, a stale explicit clear (RegB(0,1,0), no compaction involved) merging into a device with a fresh concurrent set (RegB(1,0,30)) already produces RegB(1,1,30), tied, suppressed — verified directly by evaluating merge_reg_b/override_set_b on those two registers with no compact_b call anywhere in the path. The cross-device tombstone-suppression finding (I5d, "Compaction behavior" above) is the same tie shape reached via a different route: a baseline-dominated dead set (never explicitly cleared) that gets compacted to a C-ceiling tombstone, which is then globally comparable in a way its pre-compaction, frontier-relative death was not. Compaction widens the set of histories that can reach the tie, but clear-wins already accepted this one-shot, re-mark-recoverable false-negative shape as its stated tradeoff.

Multi-slot union

Production splits blobs across up to 8 slots (READ_STATE_MAX_SLOTS). mergeReadStateEvents merges all slots with per-context max(). Override sibling keys are individual context entries and follow the same merge path.

Atomic slot-grouping rule (spec-amendment requirement): a context's frontier entry and ALL of its ov_* sibling entries MUST travel in the same slot, including during slot growth/rebalancing. This is the transport half of the same closure property as mandatory canonical publication:

  • Without it, an observer holding only a slot containing ov_s:ctx (but not ov_b:ctx) reconstructs RegB(s=1, c=0, b=0) — baseline-dead at any nonzero frontier — and canonically publishes tombstone RegB(0,1,0). After full eventual delivery of all original slots plus that transient tombstone, the merged result is RegB(s=1, c=1, b=10) — dead under clear-wins — permanently suppressing a live override.
  • With the rule, a receiver always sees either the complete register group or none of it; partial reconstruction is structurally impossible from a compliant publisher's output.

Implementation: amend splitContextsIntoBudgetedSlots to round-robin per-context groups (frontier key + all ov_* sibling keys for that context) rather than per-entry. DeviceB.split_blob_into_slots in model.py models this correctly.

Unescape-before-group rule (corollary — spec-amendment requirement): When grouping context entries, a frontier wire key MUST be unescaped to its raw logical context ID before being used as the group key. A raw context ID starting with a reserved prefix (e.g. ov_s:evil) escapes to esc:ov_s:evil as its frontier wire key, while its ov_* siblings are keyed by the raw suffix (ov_s:evil). Without unescaping the frontier key before grouping, these resolve to different groups and the register splits across slots — reproducing the same partial-reconstruction poison across publication cycles via old/new slot-coordinate mixtures. Fix: derive group identity via unescape_context_key(wire_key) for frontier keys. mutation.py::mutant_m9 reverts to escaped-key grouping and confirms test_escaped_context_slot_grouping catches the witness.

mutation.py::mutant_m8 reverts to per-entry splitting (M8's split puts frontier+ov_s: in slot 0 and ov_b:+ov_c: in slot 1) and confirms test_interleaved_delivery_grouping catches Thufir's exact witness.

This rule carries the same normative weight as mandatory canonical publication: both are protocol requirements for any client implementing this override layer, not optional optimizations.

Confirmed: splitting a published blob across 2 grouped slots and delivering each separately produces the same final override and frontier state as delivering the full blob, regardless of delivery order. Interleaved-delivery test (test_interleaved_delivery_grouping) additionally verifies that receive-one-slot → re-publish → receive-rest permutations, including delayed transient delivery to a third observer, preserve the live override verdict.

Mutation harness

9 mutants, all caught with recorded counterexamples:

Mutant Rule dropped Counterexample
M1 Baseline dominance check RegB(1,0,10) at frontier=100: correct=inactive, mutant=active (stale set persists)
M2 max(S,C)+1 counter bump After set→set→clear: correct RegB(2,3,10) (clear wins), mutant RegB(2,1,10) (set persists)
M3 Tie policy RegB(1,1,10) at frontier=10: clear-wins=False, set-wins=True
M4 Tombstone-floor compaction (delete-on-dominance revert) RegB(3,0,10) at frontier=20 compacts to None (vs. tombstone RegB(0,3,0)); local set+clear reuses counters from zero; delayed stale replay resurrects — final_reg=RegB(s=3,c=2,b=20), override_is_set=True (reproduces Thufir's pass-3 CRITICAL)
M5 uint32 value range Value 4,294,967,296 rejected by legacy sanitization
M6 Componentwise-max merge LWW delivery-order-dependent: convergence breaks under permutation
M7 Canonical publication (raw register serialization) RegB(3,2,0)@frontier-50 join RegB(1,2,100)@frontier-100 = live RegB(3,2,100) (reproduces Thufir's pass-1/2 CRITICAL dead+dead resurrection)
M8 Atomic slot-grouping rule (per-entry split) Live RegB(1,0,10) at frontier=10 split as {frontier+ov_s:} / {ov_b:+ov_c:}; partial observer reconstructs RegB(1,0,0), publishes tombstone RegB(0,1,0); final merge = RegB(1,1,10) → inactive (reproduces Thufir's pass-2/2 CRITICAL transport witness)
M9 Unescape-before-group rule (escaped-key grouping) Live override on raw ctx ov_s:evil (frontier wire key esc:ov_s:evil); escaped-key grouping splits frontier from ov_* siblings; old/new slot-coordinate mixture → RegB(1,0,0) → tombstone RegB(0,1,0) → final merge = RegB(1,1,10) → inactive (reproduces Thufir's round-2 CRITICAL)

Each mutant is injected into the model via DeviceB subclass (M1, M2, M4, M6, M7, M8, M9) or direct function evaluation (M3, M5), then the applicable invariant suite is rerun. M4 reverts to the pre-fix delete-on-dominance compaction rule and directly reproduces Thufir's pass-3 CRITICAL resurrection witness — the exact RegB(3,0,10)None → counter-reuse → stale replay → RegB(3,2,20),override_is_set=True sequence — with a fallback to the directed deep-history cube (test_deep_history_compaction) if the hand-built scenario doesn't trigger under a given tie policy. M7 reverts publish_blob to raw serialization and reproduces Thufir's pass-1/2 CRITICAL dead+dead resurrection. M8 reverts split_blob_into_slots to per-entry assignment (frontier+ov_s: / ov_b:+ov_c:) and reproduces Thufir's pass-2/2 CRITICAL transport witness via test_interleaved_delivery_grouping. M9 reverts split_blob_into_slots to escaped-key grouping (groups frontier by its wire key instead of its unescaped logical ID) and reproduces Thufir's round-2 CRITICAL for escaped contexts via test_escaped_context_slot_grouping.

Recommendation

Candidate B (two grow-only counters + baseline) with clear-wins tie policy.

Evidence:

  1. Legacy safety: B's sibling keys survive legacy rewrite; A's top-level field is erased. Hard blocker for A — no migration path tolerates a single legacy device.
  2. Identity-free: B needs no client_id for correctness; A's tiebreak creates a reinstall fragility.
  3. CRDT properties: Candidate B passes all merge invariants (I2I8) in the exhaustive model. Candidate A's join is also correct algebraically (I1, I9), but I2I4 and I6 are not exercised for A — A is dead on I7 regardless. B's componentwise max is simpler and more standard.
  4. Bytes: B at 3 live keys costs 138 bytes/context (channel UUID) to 243 bytes/context (thread hex64); a dead override compacts to a single ~45-80 byte tombstone key instead. Cap of 100 overrides stays within 32 KiB budget for both live and tombstoned cases.
  5. Compaction: B supports safe policy-aware compaction — no resurrection, ever (proved structurally, not just over a bounded cube). Clear-wins allows compacting S == C states (set-wins does not). Cross-device delivery of a tombstone can one-shot suppress an unrelated device's concurrent fresh set whose counters are at or below the tombstone's ceiling; this is recoverable by re-marking and is the same false-negative shape clear-wins already accepts for a stale explicit clear with no compaction involved (see "Tie policy evidence").
  6. Tie policy: Clear-wins avoids persistent false-positive badges and enables more aggressive compaction.

Honest limits

  • The model enumerates bounded abstract operations, not real encrypted NIP-59 payloads or relay replacement semantics.
  • Counter values in the general BFS explorer are bounded by its exploration depth (max ~4 via BFS depth 4); the directed deep-history cube (test_deep_history_compaction) reaches counter values up to the stale parameter range (0-3) plus post-compaction action sequences, covering the ~9-transition witness the BFS explorer cannot structurally reach. Real uint32 overflow/wrap is tested only via the legacy sanitization mutant (M5).
  • The BFS explorer (I5/I5c) checks compaction safety over reachable multi-device histories up to depth 4, but its own terminal-state compaction check (check_compaction_safety) only merges a device's compacted register with its own pre-compaction snapshot — it does not, by construction, exercise an unrelated device's independently- live concurrent register. test_cross_device_compaction_suppression (I5d) covers that shape directly but over a hand-parameterized cube, not the full BFS state space; the accompanying test_tombstone_merge_monotonic lemma is what extends the no-resurrection guarantee beyond the cube's specific points.
  • Two contexts are modeled. Production users may have hundreds of contexts, but the CRDT properties are per-context — cross-context interactions are limited to the shared byte budget (tested via trim/prune interaction).
  • Multi-slot behavior is confirmed via split+merge convergence test, and the atomic slot-grouping rule is modeled by DeviceB.split_blob_into_slots (including the escaped-context identity fix — split_blob_into_slots unescapes frontier keys before grouping). The production TypeScript implementation (splitContextsIntoBudgetedSlots) is NOT modeled — only the abstract grouping property is verified here. Implementation-level testing is still needed for slot placement, slot rebalancing, and the production d-tag coordinate assignment.
  • The model assumes eventual delivery (all blobs eventually reach all devices). Permanent message loss is not modeled.
  • Byte sizes are computed from JSON serialization of realistic key names. Actual encrypted blob overhead (NIP-59 envelope, relay metadata) adds to the total but does not affect the 32 KiB plaintext budget.