From 72b1d4f3687937882e587a0fd35bbe6ffb1671f5 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 31 Jul 2026 13:31:10 -0400 Subject: [PATCH] feat(readState): add NIP-RS override layer format and parse (slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the manual-unread override register algebra from the merged NIP-RS spec (docs/nips/NIP-RS.md, merge commit 209536ade) as pure functions in the format and snapshot layers. No manager or UI wiring — slice 1 only; slices 2 and 3 depend on the types exported here. Changes in readStateFormat.ts: - escapeFrontierKey / unescapeFrontierKey: bijection for raw context IDs that begin with ov_ or esc: (Reserved Namespace spec rule). - isOverrideKey: classifies ov_s:, ov_c:, ov_b: wire keys. - OverrideRegister interface: the (S, C, B) triple for one context. - OverrideLiveness interface: evaluation result for slice 3 consumers. - isOverrideActive: liveness predicate S>0 AND F<=B AND S>C (clear-wins, mandatory per spec Tie Policy section). - mergeOverrideRegisters: componentwise max() — commutative, associative, idempotent; matches model.py::merge_reg_b semantics exactly. - encodeOverrideGroup: canonical wire encoding (live three-key / tombstone floor / virgin-omit rules per Mandatory Canonical Publication section). - partitionOverrideGroups: group-first override validation required by Content Validation — collects complete logical groups before any per-entry processing; partial groups are dropped, frontier retained. - ValidatedOverrideGroup discriminated union: live | floor. - sanitizeContexts: extended to run group-first validation (step 1), then per-entry sanitization on non-override keys (step 2), then fold validated groups back as integers (step 3). Existing callers unaffected. Changes in readStateSnapshot.ts: - extractOverrideRegisters: reconstructs register map from a sanitized flat contexts map (read-side counterpart of encodeOverrideGroup). - mergeOverrideRegisterMaps: merges register maps from multiple blobs via componentwise max() (register-level analogue of frontier merge). New file readStateOverride.test.mjs (58 tests): - escape/unescape bijection including double-escape and round-trip cases. - isOverrideKey coverage for all three prefixes and negative cases. - isOverrideActive: live, virgin, frontier-dominated, tie (clear-wins), clear-exceeds-set, and tombstone-floor cases. - mergeOverrideRegisters: componentwise max, commutativity, idempotence, associativity. - encodeOverrideGroup: all canonical shapes (live, dead, tie, frontier- dominated, pre-compacted floor, virgin). - partitionOverrideGroups: live group, tombstone floor, partial group (s-only, s+c only), invalid value, negative value, invalid floor c, multi-context with mixed validity. - sanitizeContexts: live group, tombstone floor, partial-group dropped with frontier retained, normal entries, escaped frontier key pass-through. - extractOverrideRegisters: live group, floor, no overrides, plain record. - mergeOverrideRegisterMaps: two-map, disjoint, empty, single, three-way. - Round-trip: live register, tombstone floor, and virgin register. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/readState/readStateFormat.ts | 282 +++++++++- .../readState/readStateOverride.test.mjs | 519 ++++++++++++++++++ .../channels/readState/readStateSnapshot.ts | 72 +++ 3 files changed, 872 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/channels/readState/readStateOverride.test.mjs diff --git a/desktop/src/features/channels/readState/readStateFormat.ts b/desktop/src/features/channels/readState/readStateFormat.ts index 4cd61e9f43..3699e28982 100644 --- a/desktop/src/features/channels/readState/readStateFormat.ts +++ b/desktop/src/features/channels/readState/readStateFormat.ts @@ -35,6 +35,257 @@ export const READ_STATE_MAX_SLOTS = 8; export const MSG_PREFIX = "msg:"; export const THREAD_PREFIX = "thread:"; +// --------------------------------------------------------------------------- +// Reserved namespace — override key prefixes and escape marker +// +// NIP-RS spec (Reserved Namespace section): the `ov_` stem and `esc:` marker +// are reserved for the manual-unread override layer. Any raw context ID that +// starts with either prefix MUST be escaped before use as a frontier key. +// Override counter keys (`ov_s:`, `ov_c:`, `ov_b:`) are NEVER escaped — they +// are their own wire namespace and are not legitimate frontier keys. +// --------------------------------------------------------------------------- + +export const OV_S_PREFIX = "ov_s:" as const; +export const OV_C_PREFIX = "ov_c:" as const; +export const OV_B_PREFIX = "ov_b:" as const; +const OV_STEM = "ov_" as const; +const ESC_PREFIX = "esc:" as const; + +/** True for wire keys that are override counters (`ov_s:`, `ov_c:`, `ov_b:`). */ +export function isOverrideKey(key: string): boolean { + return ( + key.startsWith(OV_S_PREFIX) || + key.startsWith(OV_C_PREFIX) || + key.startsWith(OV_B_PREFIX) + ); +} + +/** + * Escape a raw context ID for use as a frontier key. + * Prepends `esc:` when the raw ID begins with `ov_` or `esc:`. + * No-op for the context shapes Buzz generates (UUID, msg:hex64, thread:hex64). + */ +export function escapeFrontierKey(rawCtx: string): string { + if (rawCtx.startsWith(OV_STEM) || rawCtx.startsWith(ESC_PREFIX)) { + return ESC_PREFIX + rawCtx; + } + return rawCtx; +} + +/** + * Recover the raw context ID from a frontier wire key. + * Strips exactly one leading `esc:` if present; never strips more. + */ +export function unescapeFrontierKey(wireKey: string): string { + if (wireKey.startsWith(ESC_PREFIX)) { + return wireKey.slice(ESC_PREFIX.length); + } + return wireKey; +} + +// --------------------------------------------------------------------------- +// Override register — the (S, C, B) triple for a single context +// --------------------------------------------------------------------------- + +/** + * A fully-validated override register for one context. + * + * A live override (`isOverrideActive === true`) carries all three counters. + * A tombstone floor carries only `c` (S=0, B=0). + * A virgin register (never activated) is represented by the absence of this + * type entirely — it is omitted from the wire. + * + * Slice 2 (manager) and slice 3 (UI) use this type as the wire-in/wire-out + * contract. Slices MUST NOT construct registers directly — use + * `encodeOverrideGroup` for encoding and rely on `extractOverrideRegisters` + * (snapshot.ts) for decoding. + */ +export interface OverrideRegister { + /** Set counter S — incremented on each mark-unread. */ + readonly s: number; + /** Clear counter C — incremented on each mark-read. */ + readonly c: number; + /** Baseline B — effective frontier at the time of the most recent mark-unread. */ + readonly b: number; +} + +/** + * Result of evaluating whether a context has an active manual-unread override. + * + * `active` means: `S > 0 AND F <= B AND S > C` (clear-wins tie policy). + * `frontier` is the merged effective frontier for the context (used by the + * unread verdict in the UI layer). + */ +export interface OverrideLiveness { + readonly active: boolean; + /** The merged effective frontier value passed to `isOverrideActive`. */ + readonly frontier: number; +} + +/** + * Evaluate the liveness predicate from the spec (clear-wins, mandatory). + * + * override_active(S, C, B, F) = S > 0 AND F <= B AND S > C + * + * F is the merged effective frontier for the context — typically + * `contexts.get(rawCtxId) ?? 0` after applying the hierarchical frontier rule. + */ +export function isOverrideActive( + reg: OverrideRegister, + frontier: number, +): boolean { + return reg.s > 0 && frontier <= reg.b && reg.s > reg.c; +} + +/** + * Componentwise `max()` merge of two override registers. + * Commutative, associative, idempotent — same as the frontier max() merge. + */ +export function mergeOverrideRegisters( + a: OverrideRegister, + b: OverrideRegister, +): OverrideRegister { + return { + s: Math.max(a.s, b.s), + c: Math.max(a.c, b.c), + b: Math.max(a.b, b.b), + }; +} + +/** + * Canonical wire encoding of an override register for a given context. + * + * Applies the tombstone-floor / virgin-omit rules from the spec + * (Mandatory Canonical Publication section) so publishers always emit + * a correctly-shaped group. + * + * Returns a partial `contexts` patch to be merged into the blob before + * serialization. Caller is responsible for including the frontier entry + * for `rawCtxId` separately. + * + * @param rawCtxId Raw (unescaped) context ID — e.g. `"abc-channel-uuid"`. + * @param reg Override register to encode. + * @param frontier Current merged effective frontier for this context. + */ +export function encodeOverrideGroup( + rawCtxId: string, + reg: OverrideRegister, + frontier: number, +): Record { + const virgin = reg.s === 0 && reg.c === 0; + if (virgin) { + // Virgin registers are omitted entirely from the wire. + return {}; + } + const active = isOverrideActive(reg, frontier); + if (active) { + // Live override: publish all three keys. + return { + [`${OV_S_PREFIX}${rawCtxId}`]: reg.s, + [`${OV_C_PREFIX}${rawCtxId}`]: reg.c, + [`${OV_B_PREFIX}${rawCtxId}`]: reg.b, + }; + } + // Dead override: tombstone floor — only ov_c: with value max(S, C). + return { + [`${OV_C_PREFIX}${rawCtxId}`]: Math.max(reg.s, reg.c), + }; +} + +// --------------------------------------------------------------------------- +// Override group validation — used inside sanitizeContexts +// +// The spec requires that override entries be validated as a complete logical +// group BEFORE any per-entry processing. This function partitions the raw +// contexts map into (a) validated override groups and (b) everything else, +// so that sanitizeContexts can apply group-first rules without mixing the two +// validation paths. +// --------------------------------------------------------------------------- + +/** + * A validated override group ready for merge. + * `kind === "live"` → three-key group with all counters present. + * `kind === "floor"` → tombstone, only `c` is meaningful (s=0, b=0). + */ +export type ValidatedOverrideGroup = + | { kind: "live"; reg: OverrideRegister } + | { kind: "floor"; c: number }; + +/** + * Partition the raw contexts map into validated override groups and + * non-override entries. + * + * Override entries that fail group validation are silently dropped (per spec: + * "the entire override group is rejected"); the corresponding frontier entry + * for `` is retained in `nonOverride`. + * + * @returns + * `overrides` — Map from raw context ID to validated group. + * `nonOverride` — Raw contexts map with all `ov_*` keys removed (frontier + * entries and escaped keys remain for standard processing). + */ +export function partitionOverrideGroups(contexts: Record): { + overrides: Map; + nonOverride: Record; +} { + // First pass: collect all ov_* entries by context suffix. + const sMap = new Map(); + const cMap = new Map(); + const bMap = new Map(); + const nonOverride: Record = {}; + + for (const [key, value] of Object.entries(contexts)) { + if (key.startsWith(OV_S_PREFIX)) { + sMap.set(key.slice(OV_S_PREFIX.length), value); + } else if (key.startsWith(OV_C_PREFIX)) { + cMap.set(key.slice(OV_C_PREFIX.length), value); + } else if (key.startsWith(OV_B_PREFIX)) { + bMap.set(key.slice(OV_B_PREFIX.length), value); + } else { + nonOverride[key] = value; + } + } + + // Second pass: validate each unique ctx suffix as a group. + const ctxSuffixes = new Set([...sMap.keys(), ...cMap.keys(), ...bMap.keys()]); + const overrides = new Map(); + + for (const ctx of ctxSuffixes) { + const hasS = sMap.has(ctx); + const hasC = cMap.has(ctx); + const hasB = bMap.has(ctx); + + const sVal = sMap.get(ctx); + const cVal = cMap.get(ctx); + const bVal = bMap.get(ctx); + + const isUint32 = (v: unknown): v is number => + typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295; + + if (!hasS && hasC && !hasB) { + // Tombstone floor: only ov_c: present — must be a valid uint32. + if (!isUint32(cVal)) continue; // invalid — drop group silently + overrides.set(ctx, { kind: "floor", c: cVal }); + } else if (hasS && hasC && hasB) { + // Complete live group: all three keys must have valid uint32 values. + if (!isUint32(sVal) || !isUint32(cVal) || !isUint32(bVal)) continue; + overrides.set(ctx, { + kind: "live", + reg: { s: sVal, c: cVal, b: bVal }, + }); + } else { + // Partial group or any other shape — reject the whole group (spec). + // The frontier entry for this ctx remains in nonOverride; do nothing here. + } + } + + return { overrides, nonOverride }; +} + +// --------------------------------------------------------------------------- +// Core validation and sanitization +// --------------------------------------------------------------------------- + const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/; export function maxReadAt(...markers: Array): number | null { @@ -94,16 +345,45 @@ export function isValidBlob(obj: unknown): obj is ReadStateBlob { return true; } +/** + * Sanitize and validate a raw contexts object from a decoded blob. + * + * Override entries (`ov_*` keys) are validated as complete logical groups + * BEFORE any per-entry processing, per the spec's Content Validation section. + * Partial groups are rejected (dropped) while their corresponding frontier + * entry is retained. Validated override groups are re-encoded into the result + * as raw entries so that the rest of the pipeline (merge, liveness) operates + * on a flat integer map — the same type as frontier entries. + * + * Escaped frontier keys (`esc:…`) pass through unchanged; unescaping happens + * at read time in the snapshot layer. + */ export function sanitizeContexts( contexts: Record, ): Record { + // Step 1: partition override groups from the rest (group-first validation). + const { overrides, nonOverride } = partitionOverrideGroups(contexts); + + // Step 2: sanitize non-override entries with the standard per-entry rules. const result: Record = {}; - for (const [key, value] of Object.entries(contexts)) { + for (const [key, value] of Object.entries(nonOverride)) { if (new TextEncoder().encode(key).length > 256) continue; if (typeof value !== "number" || !Number.isInteger(value)) continue; if (value < 0 || value > 4294967295) continue; result[key] = value; } + + // Step 3: fold validated override groups back as integer entries. + for (const [ctx, group] of overrides) { + if (group.kind === "floor") { + result[`${OV_C_PREFIX}${ctx}`] = group.c; + } else { + result[`${OV_S_PREFIX}${ctx}`] = group.reg.s; + result[`${OV_C_PREFIX}${ctx}`] = group.reg.c; + result[`${OV_B_PREFIX}${ctx}`] = group.reg.b; + } + } + return result; } diff --git a/desktop/src/features/channels/readState/readStateOverride.test.mjs b/desktop/src/features/channels/readState/readStateOverride.test.mjs new file mode 100644 index 0000000000..bb47eef10c --- /dev/null +++ b/desktop/src/features/channels/readState/readStateOverride.test.mjs @@ -0,0 +1,519 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + encodeOverrideGroup, + escapeFrontierKey, + isOverrideActive, + isOverrideKey, + mergeOverrideRegisters, + OV_B_PREFIX, + OV_C_PREFIX, + OV_S_PREFIX, + partitionOverrideGroups, + sanitizeContexts, + unescapeFrontierKey, +} from "./readStateFormat.ts"; + +import { + extractOverrideRegisters, + mergeOverrideRegisterMaps, +} from "./readStateSnapshot.ts"; + +// --------------------------------------------------------------------------- +// escapeFrontierKey / unescapeFrontierKey +// --------------------------------------------------------------------------- + +test("escapeFrontierKey_normalCtx_passesThrough", () => { + assert.equal(escapeFrontierKey("some-channel-uuid"), "some-channel-uuid"); +}); + +test("escapeFrontierKey_ovPrefix_prepentsEsc", () => { + assert.equal(escapeFrontierKey("ov_s:evil"), "esc:ov_s:evil"); +}); + +test("escapeFrontierKey_escPrefix_prepentsEsc", () => { + assert.equal(escapeFrontierKey("esc:foo"), "esc:esc:foo"); +}); + +test("escapeFrontierKey_ovStem_prepentsEsc", () => { + assert.equal(escapeFrontierKey("ov_c:something"), "esc:ov_c:something"); +}); + +test("unescapeFrontierKey_noEscPrefix_passesThrough", () => { + assert.equal(unescapeFrontierKey("some-channel-uuid"), "some-channel-uuid"); +}); + +test("unescapeFrontierKey_singleEscPrefix_strips", () => { + assert.equal(unescapeFrontierKey("esc:ov_s:evil"), "ov_s:evil"); +}); + +test("unescapeFrontierKey_doubleEscPrefix_stripsExactlyOne", () => { + // Must NOT strip more than one esc: prefix per spec. + assert.equal(unescapeFrontierKey("esc:esc:foo"), "esc:foo"); +}); + +test("escape_then_unescape_isIdentity", () => { + const raw = "ov_s:tricky"; + assert.equal(unescapeFrontierKey(escapeFrontierKey(raw)), raw); +}); + +test("escape_then_unescape_isIdentity_escPrefixed", () => { + const raw = "esc:already-escaped"; + assert.equal(unescapeFrontierKey(escapeFrontierKey(raw)), raw); +}); + +test("escape_then_unescape_isIdentity_normalCtx", () => { + const raw = "msg:deadbeef"; + assert.equal(unescapeFrontierKey(escapeFrontierKey(raw)), raw); +}); + +// --------------------------------------------------------------------------- +// isOverrideKey +// --------------------------------------------------------------------------- + +test("isOverrideKey_ovS_returnsTrue", () => { + assert.equal(isOverrideKey("ov_s:ctx"), true); +}); + +test("isOverrideKey_ovC_returnsTrue", () => { + assert.equal(isOverrideKey("ov_c:ctx"), true); +}); + +test("isOverrideKey_ovB_returnsTrue", () => { + assert.equal(isOverrideKey("ov_b:ctx"), true); +}); + +test("isOverrideKey_frontierKey_returnsFalse", () => { + assert.equal(isOverrideKey("some-channel"), false); +}); + +test("isOverrideKey_escapedKey_returnsFalse", () => { + // esc:ov_s:… is a FRONTIER key, not an override key. + assert.equal(isOverrideKey("esc:ov_s:ctx"), false); +}); + +// --------------------------------------------------------------------------- +// isOverrideActive — liveness predicate +// --------------------------------------------------------------------------- + +test("isOverrideActive_liveOverride_returnsTrue", () => { + // S=2, C=1, B=100, F=50 → S>0 ∧ F<=B ∧ S>C + assert.equal(isOverrideActive({ s: 2, c: 1, b: 100 }, 50), true); +}); + +test("isOverrideActive_virginRegister_returnsFalse", () => { + assert.equal(isOverrideActive({ s: 0, c: 0, b: 0 }, 0), false); +}); + +test("isOverrideActive_frontierExceedsBaseline_returnsFalse", () => { + // Frontier advance dominates: F=200 > B=100 + assert.equal(isOverrideActive({ s: 1, c: 0, b: 100 }, 200), false); +}); + +test("isOverrideActive_frontierEqualsBaseline_returnsTrue", () => { + // F <= B (equals case): still active + assert.equal(isOverrideActive({ s: 1, c: 0, b: 100 }, 100), true); +}); + +test("isOverrideActive_clearWinsTie_returnsFalse", () => { + // S == C with S > 0 → clear-wins: inactive + assert.equal(isOverrideActive({ s: 1, c: 1, b: 100 }, 50), false); +}); + +test("isOverrideActive_clearCounter_exceedsSet_returnsFalse", () => { + assert.equal(isOverrideActive({ s: 1, c: 2, b: 100 }, 50), false); +}); + +test("isOverrideActive_tombstoneFloor_returnsFalse", () => { + // Tombstone floor shape: s=0, c=k, b=0 + assert.equal(isOverrideActive({ s: 0, c: 5, b: 0 }, 0), false); +}); + +// --------------------------------------------------------------------------- +// mergeOverrideRegisters — componentwise max() +// --------------------------------------------------------------------------- + +test("mergeOverrideRegisters_takesMaxPerComponent", () => { + const a = { s: 3, c: 1, b: 50 }; + const b = { s: 1, c: 4, b: 80 }; + assert.deepEqual(mergeOverrideRegisters(a, b), { s: 3, c: 4, b: 80 }); +}); + +test("mergeOverrideRegisters_commutative", () => { + const a = { s: 5, c: 2, b: 10 }; + const b = { s: 2, c: 7, b: 30 }; + assert.deepEqual(mergeOverrideRegisters(a, b), mergeOverrideRegisters(b, a)); +}); + +test("mergeOverrideRegisters_idempotent", () => { + const a = { s: 3, c: 3, b: 100 }; + assert.deepEqual(mergeOverrideRegisters(a, a), a); +}); + +test("mergeOverrideRegisters_associative", () => { + const a = { s: 1, c: 0, b: 10 }; + const b = { s: 0, c: 2, b: 20 }; + const c = { s: 3, c: 1, b: 5 }; + assert.deepEqual( + mergeOverrideRegisters(mergeOverrideRegisters(a, b), c), + mergeOverrideRegisters(a, mergeOverrideRegisters(b, c)), + ); +}); + +// --------------------------------------------------------------------------- +// encodeOverrideGroup — canonical wire encoding +// --------------------------------------------------------------------------- + +test("encodeOverrideGroup_virginRegister_returnsEmpty", () => { + const patch = encodeOverrideGroup("ctx", { s: 0, c: 0, b: 0 }, 0); + assert.deepEqual(patch, {}); +}); + +test("encodeOverrideGroup_liveOverride_returnsThreeKeys", () => { + const patch = encodeOverrideGroup("ctx", { s: 2, c: 1, b: 100 }, 50); + assert.deepEqual(patch, { + [`${OV_S_PREFIX}ctx`]: 2, + [`${OV_C_PREFIX}ctx`]: 1, + [`${OV_B_PREFIX}ctx`]: 100, + }); +}); + +test("encodeOverrideGroup_deadOverride_returnsOnlyOvC", () => { + // Dead: S=1, C=2 (clear-wins) → tombstone floor ov_c = max(1,2) = 2 + const patch = encodeOverrideGroup("ctx", { s: 1, c: 2, b: 100 }, 50); + assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 2 }); +}); + +test("encodeOverrideGroup_tieRegister_returnsOnlyOvC", () => { + // Tie S==C → clear-wins → tombstone floor + const patch = encodeOverrideGroup("ctx", { s: 3, c: 3, b: 100 }, 50); + assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 3 }); +}); + +test("encodeOverrideGroup_frontierDominated_returnsOnlyOvC", () => { + // Frontier past baseline: live rule fails → tombstone floor + const patch = encodeOverrideGroup("ctx", { s: 1, c: 0, b: 100 }, 200); + assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 1 }); +}); + +test("encodeOverrideGroup_tombstoneFloorShape_returnsOnlyOvC", () => { + // Pre-compacted floor: s=0, c=5, b=0 → dead → tombstone max(0,5)=5 + const patch = encodeOverrideGroup("ctx", { s: 0, c: 5, b: 0 }, 0); + assert.deepEqual(patch, { [`${OV_C_PREFIX}ctx`]: 5 }); +}); + +// --------------------------------------------------------------------------- +// partitionOverrideGroups — group-first validation +// --------------------------------------------------------------------------- + +test("partitionOverrideGroups_liveGroup_validates", () => { + const raw = { + "channel:x": 1000, + "ov_s:channel:x": 1, + "ov_c:channel:x": 0, + "ov_b:channel:x": 900, + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 1); + const group = overrides.get("channel:x"); + assert.equal(group?.kind, "live"); + assert.deepEqual(group?.reg, { s: 1, c: 0, b: 900 }); + assert.deepEqual(nonOverride, { "channel:x": 1000 }); +}); + +test("partitionOverrideGroups_tombstoneFloor_validates", () => { + const raw = { + "channel:x": 1000, + "ov_c:channel:x": 5, + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 1); + const group = overrides.get("channel:x"); + assert.equal(group?.kind, "floor"); + assert.equal(group?.c, 5); + assert.deepEqual(nonOverride, { "channel:x": 1000 }); +}); + +test("partitionOverrideGroups_partialGroup_droppedFrontierRetained", () => { + // Only ov_s without ov_c and ov_b → partial group → rejected + const raw = { + "channel:x": 1000, + "ov_s:channel:x": 1, + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 0); // group rejected + assert.deepEqual(nonOverride, { "channel:x": 1000 }); // frontier retained +}); + +test("partitionOverrideGroups_sConly_droppedFrontierRetained", () => { + // ov_s + ov_c but no ov_b → partial group + const raw = { + "channel:x": 1000, + "ov_s:channel:x": 1, + "ov_c:channel:x": 0, + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 0); + assert.deepEqual(nonOverride, { "channel:x": 1000 }); +}); + +test("partitionOverrideGroups_invalidValue_groupDropped", () => { + // ov_s has non-integer value → invalid → whole group dropped + const raw = { + "channel:x": 1000, + "ov_s:channel:x": "not-a-number", + "ov_c:channel:x": 0, + "ov_b:channel:x": 900, + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 0); + assert.deepEqual(nonOverride, { "channel:x": 1000 }); +}); + +test("partitionOverrideGroups_negativeValue_groupDropped", () => { + const raw = { + "channel:x": 1000, + "ov_s:channel:x": -1, + "ov_c:channel:x": 0, + "ov_b:channel:x": 900, + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 0); + assert.deepEqual(nonOverride, { "channel:x": 1000 }); +}); + +test("partitionOverrideGroups_ovCInvalidValue_tombstoneDropped", () => { + // Tombstone floor with non-integer c + const raw = { + "channel:x": 1000, + "ov_c:channel:x": 1.5, + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 0); + assert.deepEqual(nonOverride, { "channel:x": 1000 }); +}); + +test("partitionOverrideGroups_multipleContexts_allValidated", () => { + const raw = { + "ch:a": 100, + "ov_s:ch:a": 1, + "ov_c:ch:a": 0, + "ov_b:ch:a": 90, + "ch:b": 200, + "ov_c:ch:b": 3, // tombstone floor for ch:b + "ch:c": 300, + "ov_s:ch:c": 1, // partial — only s, no c/b → rejected + }; + const { overrides, nonOverride } = partitionOverrideGroups(raw); + assert.equal(overrides.size, 2); // ch:a and ch:b, ch:c rejected + assert.equal(overrides.get("ch:a")?.kind, "live"); + assert.equal(overrides.get("ch:b")?.kind, "floor"); + assert.equal(overrides.has("ch:c"), false); + // Frontier entries all retained + assert.equal(nonOverride["ch:a"], 100); + assert.equal(nonOverride["ch:b"], 200); + assert.equal(nonOverride["ch:c"], 300); +}); + +// --------------------------------------------------------------------------- +// sanitizeContexts — end-to-end with override group validation +// --------------------------------------------------------------------------- + +test("sanitizeContexts_liveGroup_passesThrough", () => { + const raw = { + "channel:x": 1000, + "ov_s:channel:x": 1, + "ov_c:channel:x": 0, + "ov_b:channel:x": 900, + }; + const result = sanitizeContexts(raw); + assert.equal(result["channel:x"], 1000); + assert.equal(result[`${OV_S_PREFIX}channel:x`], 1); + assert.equal(result[`${OV_C_PREFIX}channel:x`], 0); + assert.equal(result[`${OV_B_PREFIX}channel:x`], 900); +}); + +test("sanitizeContexts_tombstoneFloor_passesThrough", () => { + const raw = { + "channel:x": 1000, + "ov_c:channel:x": 5, + }; + const result = sanitizeContexts(raw); + assert.equal(result["channel:x"], 1000); + assert.equal(result[`${OV_C_PREFIX}channel:x`], 5); + assert.equal(result[`${OV_S_PREFIX}channel:x`], undefined); + assert.equal(result[`${OV_B_PREFIX}channel:x`], undefined); +}); + +test("sanitizeContexts_partialGroup_droppedFrontierRetained", () => { + // Partial group rejected; frontier entry kept + const raw = { + "channel:x": 1000, + "ov_s:channel:x": 1, + }; + const result = sanitizeContexts(raw); + assert.equal(result["channel:x"], 1000); + assert.equal(result[`${OV_S_PREFIX}channel:x`], undefined); +}); + +test("sanitizeContexts_normalEntriesUnaffected", () => { + const raw = { + "ch:normal": 5000, + "msg:abc": 3000, + "thread:def": 2000, + }; + const result = sanitizeContexts(raw); + assert.equal(result["ch:normal"], 5000); + assert.equal(result["msg:abc"], 3000); + assert.equal(result["thread:def"], 2000); +}); + +test("sanitizeContexts_invalidFrontierEntry_dropped", () => { + const raw = { + "good:key": 100, + "bad:key": "not-a-number", + }; + const result = sanitizeContexts(raw); + assert.equal(result["good:key"], 100); + assert.equal(result["bad:key"], undefined); +}); + +test("sanitizeContexts_escapedFrontierKey_passesThrough", () => { + // esc: prefixed frontier keys must pass through unchanged + const raw = { "esc:ov_s:suspicious-ctx": 1000 }; + const result = sanitizeContexts(raw); + assert.equal(result["esc:ov_s:suspicious-ctx"], 1000); +}); + +// --------------------------------------------------------------------------- +// extractOverrideRegisters — from sanitized flat map +// --------------------------------------------------------------------------- + +test("extractOverrideRegisters_liveGroup_reconstructsRegister", () => { + const contexts = new Map([ + ["channel:x", 1000], + [`${OV_S_PREFIX}channel:x`, 2], + [`${OV_C_PREFIX}channel:x`, 1], + [`${OV_B_PREFIX}channel:x`, 900], + ]); + const regs = extractOverrideRegisters(contexts); + assert.equal(regs.size, 1); + assert.deepEqual(regs.get("channel:x"), { s: 2, c: 1, b: 900 }); +}); + +test("extractOverrideRegisters_tombstoneFloor_reconstructsRegister", () => { + const contexts = new Map([ + ["channel:x", 1000], + [`${OV_C_PREFIX}channel:x`, 5], + ]); + const regs = extractOverrideRegisters(contexts); + assert.equal(regs.size, 1); + // Floor shape: s=0, c=5, b=0 + assert.deepEqual(regs.get("channel:x"), { s: 0, c: 5, b: 0 }); +}); + +test("extractOverrideRegisters_noOverrideKeys_returnsEmptyMap", () => { + const contexts = new Map([["channel:x", 1000]]); + const regs = extractOverrideRegisters(contexts); + assert.equal(regs.size, 0); +}); + +test("extractOverrideRegisters_acceptsPlainRecord", () => { + const contexts = { + "channel:x": 1000, + [`${OV_S_PREFIX}channel:x`]: 1, + [`${OV_C_PREFIX}channel:x`]: 0, + [`${OV_B_PREFIX}channel:x`]: 900, + }; + const regs = extractOverrideRegisters(contexts); + assert.deepEqual(regs.get("channel:x"), { s: 1, c: 0, b: 900 }); +}); + +// --------------------------------------------------------------------------- +// mergeOverrideRegisterMaps — across multiple blobs +// --------------------------------------------------------------------------- + +test("mergeOverrideRegisterMaps_twoMaps_takesComponentwiseMax", () => { + const a = new Map([["ctx", { s: 3, c: 1, b: 50 }]]); + const b = new Map([["ctx", { s: 1, c: 4, b: 80 }]]); + const merged = mergeOverrideRegisterMaps(a, b); + assert.deepEqual(merged.get("ctx"), { s: 3, c: 4, b: 80 }); +}); + +test("mergeOverrideRegisterMaps_disjointContexts_bothPresent", () => { + const a = new Map([["ctx:A", { s: 1, c: 0, b: 10 }]]); + const b = new Map([["ctx:B", { s: 0, c: 2, b: 0 }]]); + const merged = mergeOverrideRegisterMaps(a, b); + assert.equal(merged.size, 2); + assert.deepEqual(merged.get("ctx:A"), { s: 1, c: 0, b: 10 }); + assert.deepEqual(merged.get("ctx:B"), { s: 0, c: 2, b: 0 }); +}); + +test("mergeOverrideRegisterMaps_emptyMaps_returnsEmpty", () => { + const merged = mergeOverrideRegisterMaps(new Map(), new Map()); + assert.equal(merged.size, 0); +}); + +test("mergeOverrideRegisterMaps_singleMap_isIdentity", () => { + const m = new Map([["ctx", { s: 2, c: 1, b: 50 }]]); + const merged = mergeOverrideRegisterMaps(m); + assert.deepEqual(merged.get("ctx"), { s: 2, c: 1, b: 50 }); +}); + +test("mergeOverrideRegisterMaps_threeWayMerge_takesGlobalMax", () => { + const a = new Map([["ctx", { s: 5, c: 0, b: 10 }]]); + const b = new Map([["ctx", { s: 1, c: 3, b: 20 }]]); + const c = new Map([["ctx", { s: 2, c: 1, b: 30 }]]); + const merged = mergeOverrideRegisterMaps(a, b, c); + assert.deepEqual(merged.get("ctx"), { s: 5, c: 3, b: 30 }); +}); + +// --------------------------------------------------------------------------- +// Round-trip: encode → sanitize → extract produces original register +// --------------------------------------------------------------------------- + +test("roundTrip_liveRegister_survivesEncodeAndExtract", () => { + const reg = { s: 2, c: 1, b: 100 }; + const frontier = 50; + const rawCtx = "channel-uuid-1234"; + + // Encode (as a publisher would) + const patch = encodeOverrideGroup(rawCtx, reg, frontier); + // Simulate inclusion in a blob's contexts map (add frontier entry too) + const rawContexts = { [rawCtx]: frontier, ...patch }; + // Sanitize (as a receiver would) + const sanitized = sanitizeContexts(rawContexts); + // Extract registers + const regs = extractOverrideRegisters(sanitized); + + assert.deepEqual(regs.get(rawCtx), reg); +}); + +test("roundTrip_tombstoneFloor_survivesEncodeAndExtract", () => { + // A dead register compacts to tombstone floor on encode + const reg = { s: 0, c: 5, b: 0 }; // already a floor shape + const frontier = 0; + const rawCtx = "channel-uuid-5678"; + + const patch = encodeOverrideGroup(rawCtx, reg, frontier); + const rawContexts = { [rawCtx]: frontier, ...patch }; + const sanitized = sanitizeContexts(rawContexts); + const regs = extractOverrideRegisters(sanitized); + + // Floor is s=0, c=5, b=0 + assert.deepEqual(regs.get(rawCtx), { s: 0, c: 5, b: 0 }); +}); + +test("roundTrip_virginRegister_omittedFromWire", () => { + const reg = { s: 0, c: 0, b: 0 }; + const frontier = 0; + const rawCtx = "channel-uuid-virgin"; + + const patch = encodeOverrideGroup(rawCtx, reg, frontier); + assert.deepEqual(patch, {}); // nothing emitted + const rawContexts = { [rawCtx]: frontier }; + const sanitized = sanitizeContexts(rawContexts); + const regs = extractOverrideRegisters(sanitized); + assert.equal(regs.has(rawCtx), false); +}); diff --git a/desktop/src/features/channels/readState/readStateSnapshot.ts b/desktop/src/features/channels/readState/readStateSnapshot.ts index c20e1d9f20..3934925e38 100644 --- a/desktop/src/features/channels/readState/readStateSnapshot.ts +++ b/desktop/src/features/channels/readState/readStateSnapshot.ts @@ -3,7 +3,12 @@ import type { RelayEvent } from "@/shared/api/types"; import { isValidBlob, isValidReadStateDTag, + mergeOverrideRegisters, + OV_B_PREFIX, + OV_C_PREFIX, + OV_S_PREFIX, sanitizeContexts, + type OverrideRegister, type ReadStateBlob, } from "@/features/channels/readState/readStateFormat"; @@ -54,6 +59,73 @@ export async function parseReadStateEvent( } } +/** + * Extract all override registers from a sanitized contexts map. + * + * Returns a Map from raw context ID to `OverrideRegister`. A tombstone floor + * (`ov_c:` only) is returned as `{ s: 0, c: , b: 0 }` — the same + * shape as a dead register, which is correct: the floor's sole purpose is to + * block counter reuse, and the liveness predicate evaluates to false for it. + * + * This is the read-side counterpart of `encodeOverrideGroup`: it reconstructs + * the register from the flat integer map that `sanitizeContexts` produced. + * The input MUST already have been through `sanitizeContexts` (so partial + * groups are absent — they were rejected at validation time). + */ +export function extractOverrideRegisters( + contexts: ReadonlyMap | Record, +): Map { + // Collect s/c/b values per context suffix. + const sVals = new Map(); + const cVals = new Map(); + const bVals = new Map(); + + const iterate = + contexts instanceof Map + ? [...contexts.entries()] + : Object.entries(contexts); + + for (const [key, value] of iterate) { + if (key.startsWith(OV_S_PREFIX)) + sVals.set(key.slice(OV_S_PREFIX.length), value); + else if (key.startsWith(OV_C_PREFIX)) + cVals.set(key.slice(OV_C_PREFIX.length), value); + else if (key.startsWith(OV_B_PREFIX)) + bVals.set(key.slice(OV_B_PREFIX.length), value); + } + + const result = new Map(); + const ctxSet = new Set([...sVals.keys(), ...cVals.keys(), ...bVals.keys()]); + for (const ctx of ctxSet) { + result.set(ctx, { + s: sVals.get(ctx) ?? 0, + c: cVals.get(ctx) ?? 0, + b: bVals.get(ctx) ?? 0, + }); + } + return result; +} + +/** + * Merge override registers from multiple sanitized contexts maps. + * + * Applies componentwise `max()` across all sources for each context suffix, + * returning a single Map from raw context ID to merged `OverrideRegister`. + * This is the register-level analogue of the frontier `max()` merge. + */ +export function mergeOverrideRegisterMaps( + ...maps: Array> +): Map { + const result = new Map(); + for (const source of maps) { + for (const [ctx, reg] of source) { + const existing = result.get(ctx); + result.set(ctx, existing ? mergeOverrideRegisters(existing, reg) : reg); + } + } + return result; +} + export async function mergeReadStateEvents( events: RelayEvent[], pubkey: string,