docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS - #2864
Conversation
…w round 1) Close all 8 findings from Thufir pass 1/3 on PR #2864. CRITICAL 1 — ov_* durability vs generic horizon pruning: - Add Override State Durability section: ov_* MUST NOT be dropped by age pruning or budget eviction; tombstone floors are permanent; no finite GC horizon without a separately proved protocol. - Reconcile Non-Goals, Fetching horizon prose, and Debounce/Pruning to explicitly exempt ov_* keys and cross-reference the new section. - Qualify Privacy Considerations monotonic-replay claim to distinguish frontier (strictly harmless) from override (one-cycle suppression risk, already documented). CRITICAL 2 — multi-slot lifecycle vs singular-slot ownership rules: - Extend d Tag section: distinguish single-slot (degenerate, common) from multi-slot (clients implementing override layer); define persisted active slot set as the ownership record. - Rewrite Writing duplicate-detection rule: active split slots MUST NOT be deleted; stale-duplicate rule applies only to coordinates not in the active slot set. - Rewrite Read-Before-Write: multi-slot clients MUST fetch ALL active coordinates and union them; provide #d filter example. - Update Fetching duplicate detection for multi-slot awareness. - Update Client-ID Rotation, Orphaned Blob Deletion for multi-slot. IMPORTANT 3 — counter exhaustion: - Actions: if max(S,C) == 4294967295, client MUST refuse the action and leave register unchanged; wrapping/reset prohibited. IMPORTANT 4 — validation precedence + accepted group shapes: - Content Validation: collect complete override group BEFORE decode/ zero-fill/merge/canonicalize; define only two accepted wire shapes (complete live 3-key group, or ov_c-only tombstone); any other shape rejects the whole group while retaining the frontier. - Narrow Verification Artifact claim: malformed-group validation is normative but outside the formal artifact's verified scope; add Scope of formal verification paragraph. IMPORTANT 5 — canonical-vs-raw re-publish suppression: - Live Subscription step 2-3: canonicalize merged override state before comparing to last-published; canonical-to-canonical comparison prevents retained live peer blob from triggering identical write on every replay. IMPORTANT 6 — verification-claim overclaim: - Verification Artifact: separate three safety requirements (tombstone floor, canonical publication, atomic grouping+unescape) from the clear-wins policy recommendation; describe M3/M5/M6 accurately. MINOR — byte figures: - Label existing figures as small-counter examples; add uint32-maxima figures (~164 B live, ~54 B tombstone for UUID context). MINOR — tombstone cap and budget scope: - Clarify tombstones accumulate permanently; distinguish active-live- override cap from total ov_* entry count over unbounded lifetime. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Close the four findings from Thufir pass 2/3 on PR #2864. CRITICAL 1 — durability defined over retrievable logical state: The entry-level pruning exemption could not hold, because a finite event-level `since` filter, coordinate retirement, or orphan deletion can remove the only event carrying a tombstone floor. Amendment-aware clients now MUST fetch without a `since` filter (bounded by instances x slots, not by time), and no coordinate carrying ov_* entries may be retired, aged out, or deleted before its componentwise-max — every tombstone ceiling included — is republished under an active coordinate and accepted. Scope the accepted-loss and "harmless orphan" claims to frontier-only state. CRITICAL 2 — publish-before-retire ordering for rebalancing: An in-place slot swap removes a group from the relay before its replacement exists, and a crash makes the gap durable. Add an ordered transition: merge and canonicalize all active coordinates, allocate fresh slot IDs (in-place reuse prohibited), persist the union of old and new coordinates, publish every new slot and confirm acceptance, then persist the new set and retire the old coordinates. IMPORTANT 3 — exhausted explicit mark-read: Refusing only the counter increment left the frontier advance to "succeed" while the override stayed live. The action now completes only if the resulting state has override_active == false; otherwise it fails visibly and the register is unchanged. IMPORTANT 4 — clear-wins is normative: The tie verdict is not encoded on the wire, so two conforming clients using different policies diverge permanently on both the unread verdict and the canonical wire form. Clear-wins becomes MUST; a selectable policy would need an encoded policy field and its own interoperability design. The verification wording still attributes this to interoperability, not merge safety. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Close the four findings from Thufir pass 2/3 on PR #2864. CRITICAL 1 — durability defined over retrievable logical state: The entry-level pruning exemption could not hold, because a finite event-level `since` filter, coordinate retirement, or orphan deletion can remove the only event carrying a tombstone floor. Amendment-aware clients now MUST fetch without a `since` filter (bounded by instances x slots, not by time), and no coordinate carrying ov_* entries may be retired, aged out, or deleted before its componentwise-max — every tombstone ceiling included — is republished under an active coordinate and accepted. Scope the accepted-loss and "harmless orphan" claims to frontier-only state. CRITICAL 2 — publish-before-retire ordering for rebalancing: An in-place slot swap removes a group from the relay before its replacement exists, and a crash makes the gap durable. Add an ordered transition: merge and canonicalize all active coordinates, allocate fresh slot IDs (in-place reuse prohibited), persist the union of old and new coordinates, publish every new slot and confirm acceptance, then persist the new set and retire the old coordinates. IMPORTANT 3 — exhausted explicit mark-read: Refusing only the counter increment left the frontier advance to "succeed" while the override stayed live. The action now completes only if the resulting state has override_active == false; otherwise it fails visibly and the register is unchanged. IMPORTANT 4 — clear-wins is normative: The tie verdict is not encoded on the wire, so two conforming clients using different policies diverge permanently on both the unread verdict and the canonical wire form. Clear-wins becomes MUST; a selectable policy would need an encoded policy field and its own interoperability design. The verification wording still attributes this to interoperability, not merge safety. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
9f53486 to
d2f6b22
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Reviewed at d2f6b22. The revised register algebra, validation, canonicalization, clear-wins rule, and publish-before-retire transition are coherent, but one lifecycle/bounds contradiction remains.
docs/nips/NIP-RS.md:305 says the mandatory no-since full-state fetch is bounded by client instances x active slots per instance. That is not the set the same paragraph requires clients to fetch: lines 313 and 426-428 explicitly recognize orphaned/retired coordinates outside every persisted active-slot set, and deletion is optional after their override state has been carried forward. Such parameterized replaceable coordinates can therefore remain queryable indefinitely. A history of rotations/rebalances/backups can make the returned set grow with historical coordinates, not current active slots, so “This is cheap” and the stated bound are not guaranteed by this protocol.
Please state the actual bound (all extant read-state coordinates, including historical/orphan coordinates), or add a lifecycle requirement that makes the active-slot bound true. If cleanup is intended to establish the bound, specify what a client MUST delete and when, and account for relays where NIP-09 deletion is unavailable or not honored. The permanent tombstone/key growth is otherwise acknowledged and can be handled by additional slots; this finding is specifically about the mandatory fetch set and its claimed operational bound.
Current applicable CI is fully green; this is a protocol-completeness issue rather than a CI failure.
Amends NIP-RS with a CRDT override layer enabling manual mark-as-unread
sync across devices. The design was verified by bounded exhaustive model
checking (docs/formal/nip-rs-unread/) with a 9-mutant harness before
this amendment was written.
Changes:
- Remove Non-Goals line stating mark-as-unread is out of scope
- Add Reserved Namespace section: ov_ stem + esc: marker reserved;
escape on publish, unescape on receive; bijection, not idempotent
- Add validation rule for override counter entries in Content Validation
- Extend Merge Rule to reference the new override layer
- Add Manual-Unread Override Layer section covering:
- Wire encoding: ov_s:<ctx>, ov_c:<ctx>, ov_b:<ctx> as uint32
sibling keys in the existing contexts map
- Merge rule: componentwise max() per counter, identical to frontiers
- Liveness predicate: S > 0 AND F <= B AND S > C (clear-wins)
- Actions: mark-unread (bump S, set B), mark-read (bump C), natural
read (frontier advance past B dominates stale set automatically)
- Tombstone floor: dead register folds to RegB(0, max(S,C), 0);
virgin register omitted (0 keys); prevents counter-reuse resurrection
- Mandatory canonical publication: protocol requirement, not advisory;
prevents two independently-dead raw registers from producing a live
join on merge
- Atomic slot-grouping transport rule: context group (frontier +
all ov_* siblings) MUST travel in the same slot; unescape-before-
group is a corollary requirement with equal normative weight
- Tie policy: clear-wins RECOMMENDED
- Bounds and budget: byte/key analysis, 32 KiB headroom confirmed
- Verification artifact reference: docs/formal/nip-rs-unread/
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…w round 1) Close all 8 findings from Thufir pass 1/3 on PR #2864. CRITICAL 1 — ov_* durability vs generic horizon pruning: - Add Override State Durability section: ov_* MUST NOT be dropped by age pruning or budget eviction; tombstone floors are permanent; no finite GC horizon without a separately proved protocol. - Reconcile Non-Goals, Fetching horizon prose, and Debounce/Pruning to explicitly exempt ov_* keys and cross-reference the new section. - Qualify Privacy Considerations monotonic-replay claim to distinguish frontier (strictly harmless) from override (one-cycle suppression risk, already documented). CRITICAL 2 — multi-slot lifecycle vs singular-slot ownership rules: - Extend d Tag section: distinguish single-slot (degenerate, common) from multi-slot (clients implementing override layer); define persisted active slot set as the ownership record. - Rewrite Writing duplicate-detection rule: active split slots MUST NOT be deleted; stale-duplicate rule applies only to coordinates not in the active slot set. - Rewrite Read-Before-Write: multi-slot clients MUST fetch ALL active coordinates and union them; provide #d filter example. - Update Fetching duplicate detection for multi-slot awareness. - Update Client-ID Rotation, Orphaned Blob Deletion for multi-slot. IMPORTANT 3 — counter exhaustion: - Actions: if max(S,C) == 4294967295, client MUST refuse the action and leave register unchanged; wrapping/reset prohibited. IMPORTANT 4 — validation precedence + accepted group shapes: - Content Validation: collect complete override group BEFORE decode/ zero-fill/merge/canonicalize; define only two accepted wire shapes (complete live 3-key group, or ov_c-only tombstone); any other shape rejects the whole group while retaining the frontier. - Narrow Verification Artifact claim: malformed-group validation is normative but outside the formal artifact's verified scope; add Scope of formal verification paragraph. IMPORTANT 5 — canonical-vs-raw re-publish suppression: - Live Subscription step 2-3: canonicalize merged override state before comparing to last-published; canonical-to-canonical comparison prevents retained live peer blob from triggering identical write on every replay. IMPORTANT 6 — verification-claim overclaim: - Verification Artifact: separate three safety requirements (tombstone floor, canonical publication, atomic grouping+unescape) from the clear-wins policy recommendation; describe M3/M5/M6 accurately. MINOR — byte figures: - Label existing figures as small-counter examples; add uint32-maxima figures (~164 B live, ~54 B tombstone for UUID context). MINOR — tombstone cap and budget scope: - Clarify tombstones accumulate permanently; distinguish active-live- override cap from total ov_* entry count over unbounded lifetime. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
d2f6b22 to
4f6af53
Compare
4f6af53 to
510352d
Compare
Scope the manual-unread amendment to a single override-bearing coordinate per installation, and give the full-state load an executable completeness contract. This is a net reduction in normative surface. Single primary coordinate: All ov_* entries, and the frontier entries of the contexts they belong to, MUST live in one stable primary coordinate per installation. Additional coordinates remain legal for frontier volume but MUST NOT carry ov_*, so they stay freely rewritable and freely deletable. Because override state never moves between coordinates, the slot-rebalancing lifecycle has nothing to protect: the five-step transition, the active slot set as a safety-bearing record, and the multi-slot read-before-write fetch variants are all removed. Rotation is the only event that changes an override-bearing coordinate, so the layer's durability obligation collapses to one carry-forward rule. Carry-forward acceptance is per relay: The prior text required acceptance by "a relay", which let a client land replacements on one relay, fail on another, and still delete the second relay's only carrier of a tombstone floor. Acceptance is now required on every relay from which the old primary will be deleted or allowed to lapse; a rejecting or unreachable relay means the old primary is retained there. Full-state load completeness: Removing the `since` filter did not make the result complete, and no test against the client's requested `limit` can detect truncation: the effective cap is the relay's, a relay may cap below what was asked for, and an advertised maximum limit is not necessarily the limit enforced. Clients now enumerate by strictly decreasing cursor — collect a page, exhaust the lowest `created_at` second with a window pinned to it, then continue below that second — and treat only an empty continuation as complete. The pinned window is load-bearing because a relay may apply tag constraints after its result cap, so a page can come back short while older coordinates still exist. The old cost claim (bounded by instances x active slots) was false for this filter and is replaced with the honest bound: one coordinate per installation that has ever used the layer, plus not-yet-deleted rotation predecessors, growing with device history rather than elapsed time. Slot-id shape is now normative: The `d` tag slot-id was "a random opaque string" of 1-64 ASCII characters, which let a conforming client publish coordinates a relay cannot recognize as read-state and therefore cannot apply per-coordinate protections to. It is now exactly 32 lowercase hex characters, with the rationale stated: structural recognizability from the `d` tag alone, without decryption. Recognizable coordinates are also what let a relay replace superseded versions outright instead of accumulating one retained row per publish, which keeps the coordinate count a full-state load must enumerate near one per installation. The `t` tag claim that it "enables relay-side filtering without fetching all kind:30078 events" is false where tag constraints are applied after the result cap; it is now described as a discoverability marker that clients must still apply locally. Budget ceiling is now terminal: Confining ov_* to one blob makes its plaintext budget a hard lifetime ceiling — roughly 600 ever-overridden contexts at the worst-case tombstone size against 32 KiB. At the ceiling a client MUST refuse mark-unread and MUST NOT split override state, drop floors, or publish a truncated override set. Same policy shape as counter exhaustion: visible failure, never silent degradation. The bounded model is narrower than this NIP in one direction only — it permits override groups in any slot, so verified atomicity still covers every arrangement this NIP allows. It does not verify the single-primary rule, the completeness procedure, or carry-forward; the spec now says so. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
510352d to
b94d495
Compare
A relay may apply tag constraints only after its result cap and withhold the events that fail them, so under a tag-constrained filter the number of events delivered is not the number the cap selected. A delivered page can then be empty while older coordinates still exist below it, which made the previous empty-page termination rule unsound: it declares a truncated load complete, drops the sole carrier of a tombstone floor, and reports a manually-unread context as read permanently. Dropping the tag from the filter makes delivery observable, so termination on an empty page holds. Per-second exhaustion is discharged by comparing a pinned window's delivery against the largest delivery the relay has already demonstrated, which fails safe toward 'cannot prove complete'. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A single-installation client has one event at one second permanently, so the cap lower bound C never exceeds its own pinned-window delivery and the exhaustion test could not be discharged by the most common conforming deployment. A stated floor of two events per query discharges it without relying on relay-advertised limits, which are not necessarily enforced. Steps 5 and 7 previously rendered opposite verdicts on the same load with no stated precedence. An undischarged second is now terminal, because a continuation below it can be empty simply by being the oldest. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The full-state load's completeness verdict rested on three relay properties stated as background assumptions. None is observable from a response, so a violation produced a false complete rather than a refusal: an arbitrary-subset cap can omit an event above the derived cursor, and a cap that shrinks mid-load makes a truncated pinned window look exhausted. State them as conformance preconditions instead — a client must not load against a relay it has evidence violates them. Conditioning complete on positive proof would withdraw the layer from every client, since no such proof exists on the standard filter surface. Descending enumeration also cannot see a coordinate that moves above the cursor while it runs, which addressable replacement makes reachable and which removes the old version at the same time. Require a live subscription fence over the load; fence deliveries are collected but do not move the cursor. The exhaustion threshold compared a delivery against a floor asserted of relay caps, while a delivery is also bounded by the requested limit, so a client requesting fewer than the floor declared a truncated second exhausted. Bind the requested limit to the floor. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… full-state load The mutation fence guaranteed only eventual delivery of a replacement, not delivery before the verdict that depends on it: a relay whose accept path and query path proceed independently can answer an enumeration query from storage the replacement has already changed while its push is still pending, so the load discharges as complete in the interval between the two. Fence establishment is now defined observably as receipt of end-of-stored-events, and a fifth relay precondition orders accepted matching events ahead of a query's end-of-stored-events on the same connection. Step 2's coordinate dedup retained the greatest created_at with no tie rule, so an equal-timestamp replacement -- legal, and the version a relay retains under NIP-01's lowest-id rule -- could be discarded in favour of the version it superseded even when the fence delivered it. Dedup now uses the full addressable ordering. The abstract, non-goals, and backwards-compatibility sections claimed no relay-side logic or relay-behaviour change, which stopped being true once the section carried normative relay MUSTs. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The late-push paragraph read as current normative permission when the delivery barrier now makes exactly that a violation, and Backwards Compatibility claimed every load against a nonconforming relay resolves to cannot prove complete — untrue for a relay whose nonconformance the client cannot detect, which can still produce a complete verdict the guarantee does not cover. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…d model Replaces delete-on-dominance compaction with a tombstone-floor rule (RegB(0, max(S,C), 0)) so dead/dominated registers never lose the counter ceiling that blocks stale-replay resurrection; only virgin (never-set) registers still compact to None. Adds a directed deep-history parameter cube (2,016 points) covering compact -> local set/clear counter reuse -> delayed stale delivery (single- and 2-slot-split) for the same-device replay shape, proving the tombstone floor holds where the depth-4 BFS explorer structurally cannot reach. Reworks the M4 mutant to revert to the old delete-on-dominance rule and directly reproduce Thufir's exact resurrection witness (RegB(3,0,10) -> None -> reuse -> RegB(3,2,20), override_is_set=True). Adds a collision-safe reserved-key escape (ov_/esc: prefixes) so a pre-existing opaque NIP-RS context ID cannot be misparsed as override control state, with an adversarial round-trip test. Adds a cross-device compaction-transparency check: a tombstone's counter ceiling can one-shot suppress an unrelated device's concurrent fresh set (never resurrect it, per a structural merge-monotonicity lemma over 1,728 points), recoverable by one more local action. This requalifies I5c's original "compaction never changes the semantic outcome" claim, which only holds for same-device replay of a device's own pre-compaction ancestor; NOTE.md states the narrower proven property and cites the pre-existing clear-wins false-negative tradeoff this shares with an uncompacted stale clear. Makes canonical publication mandatory rather than 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 (1 key, ov_c: only), virgin omitted (0 keys) -- regardless of whether do_compact was ever called locally first. This closes a CRITICAL found in independent review: two individually-dead but *uncompacted* published registers from different device histories could componentwise-max-merge into a live resurrection, because publication previously depended on an unstated compact-before-publish discipline. do_compact remains a separate, optional local storage-GC transition, decoupled from publication correctness. Adds test_published_merge_closure: Thufir's exact witness pair under both tie policies plus a general search over a 300-point-per-policy cube of independently-dead published states pairwise-joined in both delivery orders, including a one-hop relay-republish path for delayed/multi-hop delivery (45,074 pairs checked, 0 violations). Adds mutant M7 (publish-without-canonicalization), caught directly via the witness reproduction. Fixes the tombstone wire shape to match the sizing claims: a dead override now serializes as exactly one ov_c: key instead of three zero-padded keys, closing an IMPORTANT finding that the schema and storage-budget tables assumed a 1-key tombstone while the serializer emitted 3. Adds an exact wire-shape regression (live=3 keys, tombstone=1 key, virgin=0 keys, both tie policies) and extends I8/test_bounded_growth to check the tombstone shape after 100 set/clear round trips. Documents (rather than silently carries) the model's legacy drop-on-receive simplification: DeviceB(is_legacy=True) does not forward ov_* sibling keys it receives, unlike production's sanitizeContexts pass-through -- explained with the reason the BFS's own convergence/compaction verdicts are unaffected (legacy devices never act on overrides in the explorer). States canonical publication in NOTE.md as a protocol requirement (spec-amendment implication: production clients MUST canonicalize before publish) and scopes the reserved-namespace escape codec explicitly as a backward-compatibility limitation, not a collision-safe migration of pre-existing unescaped ov_-prefixed legacy data. BFS explorer: 7,129 states/policy; published-state merge closure: 45,074 pairs/both policies; both suites (exhaustive.py, mutation.py) exit 0, 7/7 mutants caught. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…nread model Resolves Thufir's final-pass NOT CLEAR findings on PR #1958: CRITICAL (transport atomicity): encode atomic slot-grouping rule. A context's frontier entry and all ov_s:/ov_c:/ov_b: sibling entries MUST travel in the same slot. Adds DeviceB.split_blob_into_slots (model.py) which round-robins per-context groups, not per-entry. Updates _ancestor_split_ctx_dicts and test_multi_slot_union to use grouped splits. Adds test_interleaved_delivery_grouping exercising both slot orders and delayed transient re-publication to a third observer (Thufir's exact witness). Adds mutant_m8 (M8_PerEntrySplit) which reverts to per-entry splitting and confirms the new test catches the false-clear counterexample. Documents the rule in NOTE.md under Multi-slot union with same normative weight as canonical publication. IMPORTANT (Candidate A coverage): NOTE.md invariant table marks I2, I3, I4, I6 as 'not exercised' for A instead of incorrect PASS. IMPORTANT (I5e cube-size wording): '300 points per tie policy' corrected to '156 clear-wins + 144 set-wins = 300 total'; 45,074 = 156^2 + 144^2 + 2 directed witnesses. MINOR (mutation section): count updated from 6 to 8; M7 and M8 added to table and narrative; M1-M6 subclass list updated to include M7,M8. Cosmetic MINOR: restore Candidate-comparison heading consumed by prior I5e insertion. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…e rewrite, M9, cleanup
CRITICAL: fix escaped-context-ID identity mismatch in split_blob_into_slots
(model.py). Frontier wire keys are escaped (e.g. 'esc:ov_s:evil') while ov_*
sibling keys use the raw suffix ('ov_s:evil'). Without unescaping the frontier
before grouping, the two entries land in different slots — a compliant publisher
can still produce a split register for any context whose raw ID starts with a
reserved prefix. Old/new slot-coordinate mixtures across publication cycles then
reproduce the round-1 partial-reconstruction poison. Fix: derive group identity
via unescape_context_key(wire_key) for frontier keys. Add M9 mutant reverting to
escaped-key grouping; add test_escaped_context_slot_grouping regression (two
publication cycles, old/new slot-coordinate mixture, canonical re-pub in loop).
IMPORTANT: rewrite test_interleaved_delivery_grouping with correct oracle. Prior
version never delivered first_slot to final_a/final_b and never performed the
receive-rest → re-publish step (substituted source's full blob). Rewritten to
exactly: partial_obs receives first_slot → publishes transient_1 → receives
second_slot → publishes transient_2; three representative delivery orders for
third-party finals covering the toxic path and both orderings. Re-validated: M8
caught in clear-wins toxic branch (3 violations), zero false violations under
set-wins.
IMPORTANT: fix NOTE prose vs executable coverage.
- NOTE:178 'Both candidates converge under all tested delivery permutations'
rephrased as algebraic property with note that I2-I4/I6 are exercised for
Candidate B only.
- NOTE:615 'Both pass all merge invariants' scoped to Candidate B (I2-I8
exhaustively verified; A exercised only on I1/I7/I9).
- NOTE:656-658 distinguished abstract grouping model (modeled by
split_blob_into_slots, including unescape fix) from production TypeScript
splitter (not modeled).
- Added unescape-before-group rule and M9 to Multi-slot section and mutation
table; updated count 8→9.
IMPORTANT: delete docs/formal/nip-rs-unread/__pycache__/*.cpython-314.pyc
(107,366 bytes, embedded absolute worktree path). Add __pycache__/ and *.pyc
to .gitignore to prevent recurrence.
MINOR: collapse deep-history delivery shapes 3→1 (2,016→672 points). The
split_fwd/split_rev shapes became semantically identical to single once the
atomic-grouping rule made a single-context compliant split whole-register+empty;
removed _ancestor_split_ctx_dicts (now dead code). Update NOTE cube-size figure.
Fix M8 witness narrative: block comment said 'ov_s+ov_b' in one slot; actual
M8 puts frontier+ov_s in slot 0, ov_b+ov_c in slot 1.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-style * origin/main: docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (block#2864) chore(release): release Buzz Desktop version 0.5.3 fix(release): make immutable desktop release operable (block#3943) Signed-off-by: Joah Gerstenberg <joah@squareup.com>
## Summary - validate desktop release candidates before merge and keep the repository squash-only - tag the squash commit only after proving frozen-base parent and complete-tree identity with the validated PR head - accept either an exact-head approval or the durable Default-ruleset bypass record as release authorization - remove the unusable App-backed preparation workflow; retain `just release-desktop` ## Ruleset follow-up After this PR merges, update Default ruleset `13596885` to: - enable strict required status checks - dismiss stale reviews on push and require approval after the last push - require the integration-bound `Desktop Release Candidate` check The next desktop release should be cut only after that settings update. ## Verification At commit `d8c254db427eedbcffac1a6e078e90d1d0f5e151` with a clean worktree: - `scripts/test-release-ref-contract.sh` - `scripts/test-desktop-release-candidate.sh` - `bash -n scripts/verify-desktop-release-merge.sh scripts/prepare-desktop-release.sh scripts/test-release-ref-contract.sh` - `git diff --check` The bypass test fixture is the captured rule-suite shape from real squash merge PR #2864 / suite `3520068134`. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
…-phase2-integration * origin/main: (38 commits) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 fix(release): make immutable desktop release operable (#3943) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) ... Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
…el-label-registry-sync * origin/main: (39 commits) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 fix(release): make immutable desktop release operable (#3943) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) ... Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…bsub * origin/main: fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events (#3999) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
…bsub * origin/main: fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events (#3999) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
…n-v1 * origin/main: fix(mesh): stop restarting a busy or loading shared-compute node (#3909) fix(desktop): preserve first huddle speech (#3962) feat(desktop): Agent Trading Cards — mintable agent-snapshot card PNGs with optional NIP-44 lock (#3278) fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events (#3999) feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) fix(release): preserve main in desktop PR body (#3979) chore(release): release Buzz Desktop version 0.5.3 (#3972) fix(release): require exact-head approval for desktop tags (#3973) fix(release): make desktop tagging squash-safe (#3965) Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) chore(release): release Buzz Desktop version 0.5.3 Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Summary
Amends
docs/nips/NIP-RS.mdwith the manual mark-as-unread override layer and includesdocs/formal/nip-rs-unread/, the bounded exhaustive verification model that preceded and informed the spec.All
ov_*override state lives in exactly one coordinate per installation. That single constraint is what makes the rest of the amendment small: override state never moves between coordinates, so there is no slot lifecycle to make crash-safe, and the only durability obligation is carry-forward onclient_idrotation.Spec changes (
docs/nips/NIP-RS.md)Non-Goals: drop the stale line stating mark-as-unread is out of scope; state the
ov_*durability exception to the best-effort/time-horizon model.Reserved Namespace:
ov_stem andesc:escape marker reserved. Escape on publish (prependesc:to raw IDs beginning withov_oresc:), unescape on receive (strip exactly oneesc:). Bijection, with the pre-amendment backward-compat residual documented as a stated limitation.Content Validation: override entries are collected and validated as a complete logical group before any decoding, zero-filling, merging, or canonicalizing. Only two wire shapes are accepted — a complete live three-key group, or an
ov_c:-only tombstone floor. Any other shape rejects the whole group while retaining the frontier entry; applying the generic per-entry discard rule first is prohibited.dTag:<slot-id>is exactly 32 lowercase hexadecimal characters, replacing "a random opaque string" of 1–64 ASCII characters. The fixed shape lets a relay recognize a read-state coordinate structurally from thedtag alone, without decrypting anything, and apply per-coordinate protections to it — under the old wording a conforming client could pick a shape that silently forfeits them. Recognizable coordinates are also what let a relay replace superseded versions outright rather than accumulating one retained row per publish, which keeps the coordinate count a full-state load must enumerate near one per installation. Every client designates one primary coordinate with a stable<slot-id>for the installation's lifetime. Allov_*entries, and the frontier entries of the contexts they belong to, MUST live in the primary. Additional coordinates remain legal for frontier volume but MUST NOT carryov_*, which keeps them freely rewritable and freely deletable.tTag: described as a discoverability marker rather than a guarantee of relay-side selectivity. A relay MAY apply tag constraints after its result cap, andkind:30078is shared with unrelated application data, so clients MUST apply the tag as a correctness filter locally, MUST NOT infer completeness from a short result, and MUST omit the tag entirely when performing a full-state load.Fetching / Full-State Load: clients implementing the override layer MUST NOT apply a finite
sincefilter — an encrypted payload means a relay filter cannot select for override-bearing events, so any event-level window can exclude the only coordinate holding a tombstone floor. Removingsinceis not sufficient: relays MAY cap historical results, MAY cap below the requestedlimit, and emit end-of-stored-events after the capped query, so neither EOSE nor a short page proves completeness. No test against the client's requestedlimitcan detect truncation either: the effective cap belongs to the relay, a relay MAY cap below what was requested, and an advertised maximum limit is not necessarily the limit enforced.A full-state load is therefore enumerated on
{"kinds": [30078], "authors": [<pubkey>], "limit": <n>}with no tag constraint. A relay MAY apply tag constraints only after its result cap and withhold the events that fail them, so under a tag-constrained filter the delivered count is not the count the cap selected — a delivered page can be empty while older coordinates still exist below it, andkind:30078is arbitrary application data whosedtag namespace is open to every application that has written under the user's key. Omitting the tag makes delivery observable; read-state selection moves client-side, where the validation rules already place it.Completeness is then established by enumeration on a strictly decreasing cursor: collect a page, descend on the lowest
created_atacross all delivered events, exhaust that second with a window pinned to it, continue below it, and treat only an empty delivery as complete. Every query carries the same explicitlimitnwithn >= L. Per-second exhaustion is discharged by comparing the pinned window's delivery against the largest delivery the relay has already demonstrated in the same load, floored atL = 2so that the ordinary single-coordinate installation can reach complete at all. The comparison fails safe: an inconclusive window reports cannot prove complete rather than complete, and that verdict is terminal for the load.Because these are addressable events, a coordinate republished mid-load moves above the descending cursor while its previous version stops existing, so neither is reachable by any later query. A full-state load is therefore fenced by a live subscription on the same tag-free filter, established — defined as receipt of end-of-stored-events — before the first enumeration query and held unbroken on the same connection for the load's duration. Fence deliveries are collected like enumerated events but do not contribute to the cursor or to the demonstrated-delivery bound. Collection deduplicates coordinates on the full NIP-01 addressable ordering — greatest
created_at, lowest event id on ties — because an equal-timestamp replacement is legal and is the version the relay retains. A lapsed or reconnected fence makes the load potentially incomplete, and a client MUST NOT publish to its own coordinates during its own load.Five relay behaviours the complete verdict rests on are stated as normative conformance preconditions rather than assumptions, because none is verifiable from the responses a client receives: newest-first prefix delivery with lowest-id tie-breaking (what NIP-01 already specifies for
limit), a non-decreasing effective cap within a load, the floorL, push delivery on an open subscription, and a delivery barrier ordering accepted matching events ahead of a query's end-of-stored-events on the same connection. Conditioning complete on positive proof of these instead would withdraw the override layer from every client rather than from the non-conforming relays. A client MUST NOT load against a relay it has evidence violates them, and MUST treat any such load as potentially incomplete.A load that is potentially incomplete, or that failed on any relay the client publishes to, MUST NOT authorize canonical compaction, publishing a canonicalized override blob, deleting or abandoning a coordinate, or reporting a mark-read as successful; the client falls back to local state.
Client-ID Rotation / Orphaned Blob Deletion: rotation is the only event that changes an override-bearing coordinate. Before deleting or abandoning its previous primary, a client MUST republish the componentwise
max()of every register that primary holds — every tombstone ceiling included — under its new primary, and MUST confirm acceptance on every relay from which the old primary will be deleted or allowed to lapse. Acceptance on one relay does not authorize deletion on another. Frontier-only orphans are deletable unconditionally; an unknown same-client_idcoordinate is treated as a live carrier until merged.Live Subscription and Convergence: the re-publish trigger and its suppression are evaluated on canonicalized state, so a retained live peer blob the client has already tombstoned cannot trigger an identical write on every replay.
Manual-Unread Override Layer (new section):
ov_s:<ctx>,ov_c:<ctx>,ov_b:<ctx>as uint32 siblings in the existingcontextsmap.max()per counter — no new wire merge logic.S > 0 AND F <= B AND S > C, transcribed frommodel.py::override_set_b.override_active == false— otherwise it fails visibly rather than reporting success over a still-live override.RegB(0, max(S,C), 0)— a singleov_c:key. A virgin register is omitted entirely. This blocks counter reuse and the resulting resurrection.ov_*siblings MUST travel in the same event, and that event MUST be the primary coordinate. An override-bearing context therefore has exactly one legal destination for its whole group; only frontier-only groups may be distributed across additional coordinates. Grouping is per logical context, never per key.ov_*entries are exempt from age pruning and budget eviction permanently, and durability is defined over retrievable logical state — the containing event must stay reachable and the load must establish completeness, not merely retain keys. There is no safe finite GC horizon.ov_*to one blob makes its plaintext budget a hard lifetime ceiling on ever-overridden contexts — roughly 600 tombstones at the worst-case ~54 bytes against 32 KiB, ~730 at the common ~45 bytes, ~199 simultaneously live overrides at ~164 bytes. At the ceiling a client MUST refuse mark-unread and MUST NOT split override state, drop floors, or publish a truncated override set. Same policy shape as counter exhaustion: visible failure, never silent degradation.docs/formal/nip-rs-unread/. The model is a broader predecessor of this NIP: itssplit_blob_into_slotspermits override groups in any slot, so verified atomicity covers every arrangement this NIP allows, but the converse does not follow. The model does not verify the single-primary rule, the completeness procedure, the relay conformance requirements or the mutation fence, or carry-forward; malformed-group wire validation is likewise normative but outside verified scope.Abstract / Non-Goals / Backwards Compatibility: the absolute "no relay-side logic" and "no relay behavior changes" claims are narrowed to what remains true — no new event kind, no new wire message, no relay-stored read-state logic — with the override layer's relay conformance contract named as the exception. Frontier sync and clients that skip the override layer are unaffected on any relay.
Verification model (
docs/formal/nip-rs-unread/)Four Python files constituting a bounded exhaustive verification model for the override layer's register algebra.
What it does: constructs a toy universe — 2–3 devices, 2 channels, every action that can happen (mark-unread, mark-read, late/duplicate syncs, app reinstall, storage compaction) — and brute-forces every reachable ordering (14,258 BFS states; 672-point deep-history parameter cube; 9-mutant harness over ~45,000 merge pairs). After each world-state it asks: did all devices converge? Did any unread flag get resurrected after being cleared, or vanish while live?
What it found and fixed:
Scope and caveats: bounded to 2–3 devices and 2 channels. Can't prove the infinite case.
NOTE.mddocuments the exact verification scope and the gap between the model'ssplit_blob_into_slotsgenerality and the single-primary rule the spec adds on top.Why it's in the repo: the spec asserts "verified by bounded exhaustive model checking." Keeping the artifact in-repo means anyone who later amends the merge/compaction rules can
python3 exhaustive.py && python3 mutation.py(deterministic, exit 0) and confirm the guarantees hold. Without it the spec claims a proof nobody can check.Diff scope
docs/nips/NIP-RS.md— spec amendment, zero product code.docs/formal/nip-rs-unread/{NOTE.md,model.py,exhaustive.py,mutation.py}— bounded exhaustive verification model, zero product code..gitignore—__pycache__/and*.pycentries for the model directory.