Skip to content

feat(readState): add NIP-RS override layer format and parse (slice 1) - #3966

Open
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/nip-rs-unread-format
Open

feat(readState): add NIP-RS override layer format and parse (slice 1)#3966
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/nip-rs-unread-format

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 31, 2026

Copy link
Copy Markdown
Member

What this does

Implements the format/parse layer for NIP-RS manual-unread override (slice 1 of 3): pure functions in readStateFormat.ts, readStateSnapshot.ts, and the test suite.

Architecture

Single decode core

decodeContexts(raw) — internal function; the only place the 3-pass validation algorithm lives.

Returns three projections of the same validated data:

  • wire — flat Record<string, number> with ov_* keys in wire form (backward compat for ReadStateBlob.contexts)
  • frontiersMap<rawCtx, number> with frontier keys unescaped
  • overridesMap<rawCtx, OverrideRegister> keyed by the same raw context IDs

sanitizeContexts() and parseContexts() are thin projections of decodeContexts — validation logic exists exactly once.

Validation rules (spec NIP-RS §Content Validation)

  1. Bucket all ov_* entries by suffix
  2. Validate each suffix as a whole group: complete live (S+C+B) or tombstone floor (C-only). Any other shape drops the entire group; the frontier entry for that suffix is retained.
  3. 256-byte UTF-8 length check on EVERY wire key. One oversized sibling drops its whole group; an oversized frontier key drops only that entry.
  4. Unescape frontier wire keys (esc:… → raw ctx) for the structured maps.

Structured event merge

mergeReadStateEventsStructured(events, pubkey, decrypt?) — primary public entry point. Returns MergedReadState { frontiers, overrides } using mergeOverrideRegisterMaps internally.

computeOverrideLiveness(merged, rawCtxId) — evaluates OverrideLiveness from a merged result. This is the production path for OverrideLiveness.

mergeReadStateEvents() — legacy wrapper returning only .frontiers; byte-identical for communityUnreadObserver and other existing callers.

Exported surface (slices 2–3 contract)

Export File Purpose
ParsedContexts readStateFormat.ts Structured {frontiers, overrides} from one blob
parseContexts() readStateFormat.ts Structured decode entry point
MergedReadState readStateSnapshot.ts Merged {frontiers, overrides} across all events
mergeReadStateEventsStructured() readStateSnapshot.ts Primary structured merge
computeOverrideLiveness() readStateSnapshot.ts Liveness producer for slice 3
mergeReadStateEvents() readStateSnapshot.ts Legacy frontier-only wrapper
mergeOverrideRegisterMaps() readStateSnapshot.ts Componentwise max across register maps
OverrideRegister readStateFormat.ts (S, C, B) triple
OverrideLiveness readStateFormat.ts {active, frontier}
isOverrideActive() readStateFormat.ts Liveness predicate
mergeOverrideRegisters() readStateFormat.ts Componentwise max for two registers
encodeOverrideGroup() readStateFormat.ts Canonical wire encoding
escapeFrontierKey() / unescapeFrontierKey() readStateFormat.ts Bijection for reserved-namespace frontier keys

Test coverage (3978/3978 pass)

  • Escape/unescape bijection, isOverrideKey, isOverrideActive (liveness predicate + uint32 boundaries)
  • mergeOverrideRegisters: commutative, associative, idempotent, uint32 max
  • encodeOverrideGroup: all 6 canonical shapes (live, 4 dead forms, virgin-omit)
  • parseContexts: every illegal partial-group subset (S-only, B-only, S+C, S+B, C+B), invalid sibling at each position, uint32 boundaries, 256/257-byte ASCII and multibyte exact boundary cases for both live and floor shapes
  • Reserved-ID collision witness: ov_s:evil with its own register + evil with its own register — distinct namespaces post-decode
  • Event-level integration through parseReadStateEvent + mergeReadStateEventsStructured: two-blob merge asserting frontier AND register components, liveness evaluation (active/inactive), unknown-pubkey empty result, full collision witness across two events

Stack

Stack: this PR → #4002#4005

Implements the manual-unread override register algebra from the merged
NIP-RS spec (docs/nips/NIP-RS.md, merge commit 209536a) 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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner July 31, 2026 17:44
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits July 31, 2026 14:15
Replace the flat-map decode path with a parseContexts() function that
returns {frontiers, overrides} — separate namespaces keyed by raw
context ID.  This fixes three correctness defects in the previous
implementation:

- Frontier keys were stored with their esc: prefix intact, so
  isOverrideActive evaluated against the wrong frontier for any raw
  context ID starting with ov_ or esc: (e.g. raw ov_s:evil had its
  frontier stored as esc:ov_s:evil while the override register was
  keyed by ov_s:evil).
- The 256-byte UTF-8 key-length check was applied only to frontier
  entries; ov_* sibling keys were written back unchecked, allowing
  wire keys up to 5 + suffix bytes through.
- extractOverrideRegisters accepted an arbitrary flat map and zero-filled
  missing components, reconstructing partial groups that group-first
  validation had already rejected.

Changes:
- readStateFormat.ts: add ParsedContexts interface and parseContexts();
  make partitionOverrideGroups internal; add 256-byte enforcement to
  every wire key (frontier and override siblings alike); fix the
  sanitizeContexts doc comment.
- readStateSnapshot.ts: add contexts: ParsedContexts to
  ParsedReadStateEvent; parseReadStateEvent populates both blob.contexts
  (flat, for readStateManager backward compat) and contexts (structured);
  mergeReadStateEvents iterates parsed.contexts.frontiers so callers
  receive unescaped raw ctx IDs; remove extractOverrideRegisters; update
  mergeOverrideRegisterMaps to accept ReadonlyMap inputs.
- readStateOverride.test.mjs: rewrite around parseContexts(); add the
  full partial-group rejection matrix (S-only, B-only, S+B, C+B, S+C),
  invalid sibling at each position, uint32 boundary values, 256/257-byte
  key-length cases for live and floor shapes, multibyte UTF-8 boundary,
  and Thufir's exact collision witness (raw ctx ov_s:evil alongside ctx
  evil with its own ov_s:evil counter).  3968/3968 pass.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… and liveness

Addresses all three Thufir pass-2 IMPORTANTs and the MINOR:

decodeContexts() — single internal 3-pass validator returning
{ wire, frontiers, overrides }.  sanitizeContexts() and parseContexts()
are now thin projections of that one result; partitionOverrideGroups is
deleted.  Validation logic exists exactly once.

mergeReadStateEventsStructured() — primary public merge function returning
MergedReadState { frontiers, overrides } using mergeOverrideRegisterMaps
internally.  computeOverrideLiveness() evaluates OverrideLiveness from a
merged result, giving OverrideLiveness its first production path.
mergeReadStateEvents() becomes a thin frontier-only wrapper over
mergeReadStateEventsStructured, byte-identical for existing callers.

ParsedContexts doc comment updated: wire escaping is removed at decode;
raw IDs may themselves begin with reserved prefixes such as ov_* or esc:.

Tests: 3978/3978 pass (+10 tests).  Added event-level integration suite
exercising parseReadStateEvent + mergeReadStateEventsStructured directly:
two-blob merge asserting both frontier and register components, liveness
after join (active and inactive cases), unknown-pubkey empty result, and
the full collision witness (ov_s:evil with its own ov_s:ov_s:evil register
alongside evil's plain register and both frontiers through two events).
Added exact multibyte 256/257-byte boundary cases for both live and floor
shapes (é × 125 + a/aa = 251/252 bytes suffix → wire key 256/257 bytes).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…/Snapshot

Finding 1 — effective frontier for liveness:
computeOverrideLiveness now takes effectiveFrontier as a required third
argument (was reading merged.frontiers.get(rawCtxId) internally). NIP-RS
:169-196/:510-514 requires the hierarchical effective frontier, not the
context's own stored value. Callers supply max(own, parent) before calling.

Finding 2 — d-tag slot regex:
isValidReadStateDTag now validates against /^[0-9a-f]{32}$/ (was any
1-64 ASCII characters). Nonconforming coordinates are now correctly
rejected per NIP-RS :55/:68. Updated all test fixtures (slot1/slot2
→ valid 32-hex constants; read-state:test in communityUnreadObserver
→ valid slot). Removed the now-dead isAscii helper.

Finding 3 — null-prototype wire projection:
decodeContexts builds wire with Object.create(null) instead of {}, so a
legitimate __proto__ context ID is stored as an own property in both the
wire and frontiers projections rather than silently dropped from wire.

Finding 4 — decode once in parseReadStateEvent:
decodeContexts is now exported. parseReadStateEvent calls it once and
derives both blob.contexts (wire) and contexts (frontiers/overrides) from
the single result, eliminating the double traversal of up to 10k entries.
Deleted the unused exported ValidatedOverrideGroup type.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant