feat(read-state): wire right-click mark-read/unread to NIP-RS override layer (slice 3) - #4005
Open
wpfleger96 wants to merge 8 commits into
Open
feat(read-state): wire right-click mark-read/unread to NIP-RS override layer (slice 3)#4005wpfleger96 wants to merge 8 commits into
wpfleger96 wants to merge 8 commits into
Conversation
…e layer (slice 3) Right-click mark-as-unread now publishes a NIP-RS kind:30078 override event (S bump + B set to effective frontier) via ReadStateManager before updating the local forced-unread cache. Mark-as-read clears the override (C bump) if an active override exists. Both operations surface user-visible toasts on failure: budget exhaustion, counter overflow, or incomplete load. forcedUnreadStore becomes a local-device cache of the synced override layer rather than the sole source of truth; its doc comment is updated accordingly. Adds readStateOverride.ts with the applyOverrideUnread / applyOverrideRead / persistForcedUnread helper seam, and extends useReadState to expose the three manager override callbacks (markChannelUnread / markChannelRead / getOverrideLiveness) used by useUnreadChannels. Includes nip-rs-unread-ui.test.mjs with 22 behaviour-focused tests covering all refusal paths and the local-cache contract. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This was referenced Jul 31, 2026
…ice 3) CRITICAL 1: Make manager liveness sole verdict for manual-unread. - Active-community unread memo now derives forced-unread from getOverrideLiveness(channelId)?.active, not forcedUnreadRef membership. forcedUnreadRef is an optimistic local cache; liveness is authoritative. - communityUnreadObserver: switch from mergeReadStateEvents (frontier-only) to mergeReadStateEventsStructured; evaluate isOverrideActive(S,C,B,F) from the merged override registers instead of gating the local cache against the frontier baseline. CRITICAL 2: Explicit mark-read as one outcome-bearing transition. - applyOverrideRead now returns OverrideReadOutcome instead of void. Order: frontier advance → C-bump → re-read liveness. Clears local presentation only when liveness is confirmed inactive. null ≠ inactive: pre-init returns overrideStillActive (fail-closed). already_inactive race: silent success (overrideCleared). - markChannelRead and markAllChannelsRead gate the local cache delete on outcome === "overrideCleared". IMPORTANT 1: No local mutation on refusal. - useChannelUnreadState.handleMarkUnread now calls markChannelUnread first and does not add to forcedUnreadRef; the session-level timeline overlay is not set before or on refusal. IMPORTANT 2: Tests exercise production code. - Delete handwritten simulations and assert.ok(true) placeholder. - Import and invoke applyOverrideUnread, applyOverrideRead, persistForcedUnread, overrideErrorMessage directly with injected deps. - Witnesses: null-liveness pre-init, inactive-without-C-bump, active-success, active-uint32_overflow refusal, active-load_incomplete refusal, already_inactive race, persistForcedUnread refresh-on-existing-entry. - Test count: 19 → 27. MINOR 1: OverrideAPIs functions now required (no optional-chain fail-open). MINOR 2: Remove duplicate MarkResult/OverrideLiveness from forcedUnreadStore; move overrideErrorMessage/toast policy to readStateOverride.ts. Also fix persistForcedUnread to refresh existing entries (re-mark after a dead override uses the current frontier as baseline). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add two tests that were missing from the pass-1 fix commit: - applyOverrideUnread_refusal_caller_must_not_mutate_overlay: witnesses that applyOverrideUnread returns false on budget_exhausted and that the caller does not mutate its session overlay (covers the useChannelUnreadState.handleMarkUnread active-channel path). - applyOverrideRead_partial_refusal_pattern_clears_only_inactive: witnesses the per-channel gating pattern used by markAllChannelsRead — a channel whose C-bump resolves to inactive liveness is removed from the forced map; a channel whose C-bump refuses while liveness stays active is retained. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/duncan/nip-rs-unread-manager: fix(nip-rs): add fencedLoader integration and manager/storage fixes fix(nip-rs): correct fencedLoader lapse detection and manager semantics fix(read-state): close Thufir pass-1 findings in readStateManager/Storage Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Four findings closed: CRITICAL: inactive rail — remove seven-day horizon and forcedUnreadMap fallback from fetchCommunityUnread. Override state is exempt from finite- horizon fetching. Authoritative sources are now (a) readStoredReadState overrideRegisters (persisted full-state projection) and (b) coordinate- deduped fetched events merged via max() per field. The stale-cache fallback that produced false positives and false negatives on old/tombstoned registers is removed; the observer test is updated to match the new authoritative model. IMPORTANT: restore divider suppression — markChannelUnread returns boolean; applyMarkUnreadDividerOverlay (extracted pure helper in useChannelUnreadState) gates forcedUnreadRef.add() on success. handleMarkUnread calls this helper instead of the old unconditional/void path. AppShellContext default updated. IMPORTANT: explicit read always attempts C-bump (NIP-RS:537-539) — applyOverrideRead step logic reordered: !isReadStateReady fails closed; liveness===null (ready, no register) = known absence, cleared; register exists → always call markChannelRead regardless of active/inactive, then inspect resulting liveness. isReadStateReady in OverrideAPIs distinguishes manager-unavailable (fail closed) from known-no-register (cleared). IMPORTANT: hook witnesses drive production code — applyOverrideRead tests replaced with seven new tests using makeApis() reflecting the new semantics; two new applyMarkUnreadDividerOverlay tests exercise the extracted production transition (success adds to overlay, refusal does not). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> * origin/duncan/nip-rs-unread-manager: fix(nip-rs): apply max() for v2 frontier in hydration; add stale-ancillary witness fix(read-state): close timer race, hydration publishability, v2 durable leakage fix(nip-rs): close C1/I2/I3 residuals — fence timeout, commit point, real-primitive tests fix(nip-rs): add fence primitive, parsed-record loader, transactional storage (contracts 1-3) fix(nip-rs): close Thufir pass-3 findings (fence, parsed records, transactional state) Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
…omplete ready gate, evidence retention CRITICAL 1 (stale-stored + fetched tombstone resurrection): Replace communityUnreadObserver's stored+fetched componentwise max join with getProjection(). When loadComplete, projection.overrides is the sole authoritative override source — no per-field merge with fetched registers that can resurrect a superseded tombstone. effectiveFrontier() = max(fetched, projection) per channel. CRITICAL 2 (register evaluated against F=0 instead of real frontier): effectiveFrontierMap now merges projection.frontiers with fetched frontiers using max() per channel, so isUnreadExternalEvent always evaluates against the true committed frontier, not a zero default. IMPORTANT 1 (isReadStateReady = initialized, not loadComplete): Add isLoadComplete return from useReadState (manager.getProjection().loadComplete). Wire overrideApis.isReadStateReady to isLoadComplete — null liveness after an incomplete load is ambiguous, not known absence. readStateOverride.ts JSDoc updated. IMPORTANT 2 (markAllChannelsRead unconditionally wipes evidence): latestByChannelRef.delete and observedUnreadEventsByChannelRef.delete now inside the overrideCleared branch only — refused marks preserve the channel's observed evidence. IMPORTANT 3 (adversarial witnesses): Four tests added: - adversarial-1: stale live register + fetched tombstone → dark (no resurrection) - adversarial-2: projection frontier deactivates register → dark - adversarial-3: incomplete-load null liveness ≠ known absent → fail-closed - adversarial-4: mark-all refusal keeps per-channel observed evidence Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…dering, mark-all transition
CRITICAL 1 (production rail never receives manager projection):
Wire getProjection through the actual production path:
useCommunityUnread: destructure getProjection from useAppShell()
pollCommunityUnread: add getProjection param, pass to fetchCommunityUnread
AppShellContext: add getProjection: () => ReadStateProjection | null to context type
and default value; AppShell destructures from useUnreadChannels and supplies
to AppShellProvider. Production polls now receive the live manager projection.
CRITICAL 2 (stale complete projection overrides fresher fetched tombstone):
Add fetchedTombstoneChannels guard in fetchCommunityUnread. When completeProjection
is present, iterate readState.overrides; any channel whose fetched register has s===0
(wire tombstone: ov_c present, ov_s absent) is added to the set. The projection's
liveness check is skipped for those channels — a fresher relay tombstone is
authoritative even when loadComplete=true, because loadComplete establishes
enumeration completeness, NOT temporal ordering against this observer's independent
fetch. The projection live register (S=5,C=0,B=100,F=50) can no longer resurrect
a channel cleared on the relay. Adversarial-5 witness added:
stale_complete_projection_yieldsTo_fresher_fetched_tombstone
IMPORTANT (mark-all transition tested by simulation):
Extract applyMarkAllReadTransition to readStateOverride.ts: exported function that
calls applyOverrideRead and conditionally deletes from forcedUnread, latestByChannel,
observedUnreadEvents only on overrideCleared. Also export MarkAllEvidenceMaps type.
useUnreadChannels.ts calls the production function directly. adversarial-4 test
updated to call applyMarkAllReadTransition (not a reimplementation), asserting both
branches: cleared purges all three maps; refused preserves all three.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wire the right-click mark-as-unread and mark-as-read paths to the NIP-RS
ov_*override layer implemented in slice 2.What changes
forcedUnreadStore.ts— store is the local-device cache of the NIP-RS override layer. DuplicateMarkResult/OverrideLivenesstype copies removed;overrideErrorMessageand toast policy moved toreadStateOverride.ts.readStateOverride.ts(new) —applyOverrideUnread/applyOverrideRead/persistForcedUnread/applyMarkUnreadDividerOverlayhelpers forming the seam betweenuseUnreadChannels/useChannelUnreadStateand the manager override APIs.OverrideAPIsincludesisReadStateReadyto distinguish manager-unavailable (fail-closed) from known-no-register (cleared).applyOverrideReadsemantics follow NIP-RS:537-539: register exists → always attempt C-bump regardless of active/inactive state, then inspect resulting liveness.nullliveness withisReadStateReady: true= known absence (cleared);isReadStateReady: false= fail closed.readState/useReadState.ts— exposesmarkChannelUnread,markChannelRead,getOverrideLiveness,isReadStateReadycallbacks proxying the manager. Additive proxying only.useUnreadChannels.ts— active-community unread memo derives forced-unread fromgetOverrideLiveness(channelId)?.active.markChannelReadandmarkAllChannelsReadgate local cache deletion onapplyOverrideReadreturning"overrideCleared".markChannelUnreadreturnsbooleanso callers can gate session-local overlays on success.useChannelUnreadState.ts—applyMarkUnreadDividerOverlayextracted as a pure helper: callsmarkFn(channelId)and adds to theforcedUnreadRefoverlay only ontrue.handleMarkUnreadcalls this helper — refused marks no longer spuriously suppress the timeline "New" divider.communityUnreadObserver.ts— override state is exempt from finite-horizon fetching. Authoritative sources: (a)readStoredReadState(pubkey).overrideRegisters(persisted full-state projection) and (b) coordinate-deduped registers from fetched events, merged viamax()per field. The staleforcedUnreadMapfallback that produced false positives/negatives on old or tombstoned registers is removed.nip-rs-unread-ui.test.mjs— 32 behaviour-focused tests exercising production code via injectedOverrideAPIs:applyOverrideRead: manager-not-ready fails closed; ready+null = cleared (no C-bump); inactive register → C-bump attempted; active → C-bump → re-check;already_inactiverace; post-call null treated as cleared; partial-refusal loopapplyMarkUnreadDividerOverlay: success adds to overlay; refusal does notapplyOverrideUnreadsuccess and refusal pathspersistForcedUnreadnew entry, same-ts noop, different-ts refreshforcedUnreadStoreround-trip andresolveChannelReadMarkerboundary casescommunityUnreadObserver.test.mjs— updated to usereadStoredRegistersinjection; new witnesses: stored active register lights rail; stored tombstoned register does not; old active register beyond 7-day horizon still lights rail; frontier advance deactivates stored register; muted channel with active register stays dark.Gates (head
3b4b511a3a1cebf33de9c6a060947ebf3baa3aa4)just desktop-check— 0 errors, 8 pre-existing infosjust desktop-typecheck— cleanjust desktop-test— 4038/4038 pass across 59 suitesMerged-in manager head:
e5bdaa9804aab1cd82ca3478d3b714f5ecfa4d37Stack: #3966 → #4002 → this PR