fix(readState): v4 plan conformance — pending-intents consolidation, single-flight retry controller, biting witnesses

Closes all 7 IMPORTANT + 2 MINOR findings from Thufir pass-1.

Core changes:
- Consolidate pending-intents storage into override-state.v2 blob (Paul's
  design ruling). Single write point for registers, receipts, and intents
  eliminates the double-commit window; no migration needed since
  pending-intents.v1 never shipped.
- Implement retryLoad() single-flight controller: bounded exponential backoff
  (1s->2s->4s...60s cap), reconnect coalescing into pendingRetryOnComplete,
  destroy/identity cancellation, retryAttempt reset on complete verdict.
- sourceScope + readTarget captured in enqueue path so drain performs exact
  source cleanup at the correct frontier position.
- refused-unread rollback: snapshot prior forced-unread entry before ordered
  write; restore exact snapshot on refusal; delete only when no prior existed.
- Surfaced drain outcomes via onDrainOutcome hook; genuine refusals
  (uint32_overflow, budget_exhausted, storage_failed) trigger toast;
  already_inactive is silent success; load_incomplete never surfaces.
- Cleanup atomicity: intent + receipt deleted in a single persistLocalState()
  call (one v2 blob write) so they are never observable separately.
- createDrainContext typed factory replaces this-as-unknown-as-DrainContext cast.
- NIP-RS.md: added normative refusal-reason vocabulary list.

Biting witnesses added (readStateManager.test.mjs, +12 tests):
- Amendment A: gen2 enqueued mid-replay, gen1 register rolled back
- Both inversion directions (unread->read, read->unread in-flight)
- compare-delete preserves newer intent
- Amendment C via production fetchAndMerge load path (not direct register write)
- Atomic intent+receipt deletion witness
- Controller: automatic backoff, reconnect during loadInFlight, stale-gen ordering
- Controller: destroy-with-timer, exactly-one-drain-per-generation
- Store/UI: identity-swap-during-drain, deferred refusal surfaces outcome

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-07 18:53:39 -04:00
parent ba097710b0
commit 24e87e85c7
16 changed files with 1553 additions and 417 deletions

View File

@@ -536,13 +536,20 @@ Every action below requires a complete full-state load (see Full-State Load). On
- (a) **`applied`** — the action was applied against a complete full-state load; the register was mutated, the canonical form was updated, and publication was scheduled.
- (b) **`queued`** — the action was accepted as a pending intent; the register was NOT mutated; a forced local presentation MAY be shown optimistically; the client MUST drain the intent on the next complete-load transition and surface the definitive `applied` or `refused` result then.
- (c) **`refused`** — the action was rejected; neither the register nor local presentation was modified; a reason MUST be provided (see reasons below).
- (c) **`refused`** — the action was rejected; neither the register nor local presentation was modified; a reason MUST be provided (one of the refusal reasons below).
**Refusal reasons:**
- `load_incomplete` — the full-state load was not proven complete; no register mutation was performed.
- `already_inactive` — the override register was already inactive (or absent) at the time of the mark-read; no C-bump was needed. This is a definitive success condition: the channel is already cleared.
- `uint32_overflow` — `max(S, C)` has reached the uint32 maximum (4294967295); no representable counter increment exists.
- `budget_exhausted` — the new register would exceed the encoding budget; the client MUST refuse rather than silently drop other channels' overrides.
A client that does not implement pending intents MUST report the action as `refused` with reason `load_incomplete` rather than act on a partial view of its own override state.
**Mark-unread:** increment S to `max(S, C) + 1`; set B to the current effective frontier value for the context. C is unchanged. If `max(S, C) == 4294967295` (uint32 maximum), the client MUST refuse the mark-unread action and leave the register unchanged; wrapping or resetting to zero is prohibited.
**Mark-read (explicit):** advance the frontier to cover the context as normal; increment C to `max(S, C) + 1`. S and B are unchanged. If `max(S, C) == 4294967295`, no representable counter increment exists; wrapping or resetting to zero is prohibited. The client MUST then complete the action only if the resulting state satisfies `override_active == false` — i.e. the frontier advance alone deactivates the override, or the override was already inactive. Otherwise the counters MUST be left unchanged and the client MUST NOT report the mark-read as `applied` while `override_active` remains true; instead the client MUST report the outcome as `refused` (see reason `already_inactive` and other reasons above). A client MAY report `queued` for a mark-read on an incomplete load per (b) above, but MUST NOT apply the counter mutation until a complete load is available.
**Mark-read (explicit):** advance the frontier to cover the context as normal; increment C to `max(S, C) + 1`. S and B are unchanged. If `max(S, C) == 4294967295`, no representable counter increment exists; wrapping or resetting to zero is prohibited. The client MUST then complete the action only if the resulting state satisfies `override_active == false` — i.e. the frontier advance alone deactivates the override, or the override was already inactive. Otherwise the counters MUST be left unchanged and the client MUST NOT report the mark-read as `applied` while `override_active` remains true; instead the client MUST report the outcome as `refused` with reason `already_inactive`. A client MAY report `queued` for a mark-read on an incomplete load per (b) above, but MUST NOT apply the counter mutation until a complete load is available.
**Natural read (frontier advance):** advance the frontier past B. No counter update is needed — the liveness predicate's `F <= B` condition automatically deactivates the override when the frontier dominates the baseline.