diff --git a/.changeset/hold-consistency-batch-2.md b/.changeset/hold-consistency-batch-2.md new file mode 100644 index 000000000..188e39248 --- /dev/null +++ b/.changeset/hold-consistency-batch-2.md @@ -0,0 +1,12 @@ +--- +"@solidjs/signals": patch +--- + +Five hold-consistency fixes (#3456, #3458, #3460, #3463, #3469) + +- #3456: a pass that re-parks on a new pending source set retires the sources it stopped carrying from its dependents, so a conditional whose async branch was cancelled no longer stays pending forever on a flight it has no path to. +- #3458: a stale render reader that is a flight's first observer registers the flight with the transaction it reveals a reader of (INV-3, via the reader's queue chain), so the transaction waits for it instead of revealing its other inputs beside the reader's pre-flight value. +- #3460: lanes mirror transitions from the outside — a render effect off a held lane (mounted mid-hold, or re-run by an unrelated sync write) is served the committed value, publishes at once, entangles nothing, and re-runs at the lane's release; `latest()` and `createOptimistic` sources alike. Only direct reads return the override while the lane holds. Off the lane is provenance, not membership: a pass under the lane's own transaction (its async's landing) is the lane's work. +- Lanes stage (#3479 review): an optimistic derivation is an override. A lane pass on a memo publishes its speculative result as a _derived override_ instead of direct-committing `_value`, so the committed view an outsider sees is a whole frame — the held source and its derivations together, never a committed shadow beside a speculative memo. The revert promotes a derived override the truth confirmed (no re-ask waterfall) and re-derives one it superseded. +- #3463: a reader whose removal is staged in a live transaction (a zombie) is still on screen and keeps holding until the commit that disposes it; it is moot only for that transaction's own verdict. +- #3469 (A30): a pass that changed nothing replaced nothing either — its dependency trim waits on the flush's verdict (`heldTrims`), so a same-value branch switch under a hold still follows its committed inputs. diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index db75e4fa4..bf09f21b0 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -44,7 +44,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - `(held value, active value)` **+ `CONFIG_OVERRIDE_SUPERSEDED`** (#3331, A18 supersession) — the held value came from the node's source — its own async landing, or a sync recompute driven by an upstream change — and differs from the override. The pair is read two ways: tracked readers - (`read` with a computed observer) get the held value via `GlobalQueue._supersededRead` — the + (`read` with a computed observer) get the held value via `GlobalQueue._overrideRead` (the engine's selection for every tracked read of an override; #3479 folded `_supersededRead` and the lane outside-view rule into it) — the graph derives from the truth — while untracked reads and the applied frame still get the override. The bit is set by `supersedeOverride` (optimistic.ts), reached from every own-source publish under an override: `asyncWrite`'s override branch and both of `recompute`'s @@ -89,7 +89,8 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - Lane lifecycle: created on optimistic write → nodes join via `insertSubs(node, true)` → `assignOrMergeLane` → lane-routed effects run when the lane is not held (`runLaneEffects` → `laneHeld`) → cleaned up by `cleanupCompletedLanes` when the owning transition completes (or when orphaned, `_transition === null`). - `_pendingAsync` add/delete sites: added in `recompute`'s async catch under a lane (core.ts ~264), removed on async resolution (`asyncWrite`, async.ts ~214) and on lane-corrected recompute (core.ts ~254). The set records the async the lane _owns_, not what holds it. - Replay gating (#3330): `laneReadsCommitted` hands a lane reader the committed `_value` of a staged node and records the reader in the batch's `_gatedSubs` for a re-run at commit — only when `_pendingValue !== _value`. A lane recompute that already published the value (INV-11) leaves the two equal; recording the reader anyway replayed its effects against an unchanged frame. -- Late readers of a transaction hold (#3330 store twin → general): the stale-reader term of `read()`'s value selections (`heldFromStale`, core.ts — the fast paths and the slow path) serves a render effect the committed `_value` of a node another live transaction staged, and records the reader in that transaction's `_gatedSubs`. The commit is silent (the staging walk was the notification), so a reader that linked after the walk — an effect created during the hold, a store key first read under it — would otherwise show the old value past the reveal. An effect the transaction itself computed (`_valueTransition` resolves to it) is not recorded: it re-derives at the commit through its parked run or the contested re-derive (#3322), and a replay would publish the frame twice. Pinned in `tests/spec-async-semantics.test.ts` ("a reader that links to a held node during the hold"). +- The outside view of a held lane (#3460, `readsHeldCommitted`, lanes.ts): a held lane is a transaction seen from the outside. A render effect OFF the lane (`currentOptimisticLane` null or another root) that reads a value the lane is revealing — an override (`read()`'s override arm → the engine's `_overrideRead`, one hook for every tracked read of an override, which also carries the A18 supersession selection) or a `latest()` shadow (`latestRead`) — is served the committed `_value`, publishes now, entangles nothing (a sync write is never held by a lane, exactly as a stale reader of a held transaction), and is queued on the lane's own render queue (`_effectQueues[0]`, `enqueueSub` unless disposed) so the release re-runs it — `runLaneEffects` at the reveal, or `cleanupCompletedLanes` at the owning transaction's commit. The committed value is what is on screen: the lane defers its own readers' runs, so the speculative value is nowhere visible until the release. A reader ON the lane computes the lane's reveal and takes the lane's value as before; the reader's demotion at body-end (A18, `endOptimism`) is unaffected — the lane is no longer held, so the override is the visible value. Was: only a reader under ANOTHER lane got the committed shadow; a mainline reader — mounted mid-hold (`Late: 1` beside the deferred `Value: 0`) or re-run by an unrelated sync write — showed the speculative value. Engine-owned: a lane implies the engine, so the plain core pays one `CONFIG_HAS_LANE` test. Pinned: `tests/lane-outside-view.test.ts`. +- Late readers of a transaction hold (#3330 store twin → general): the stale-reader term of `read()`'s value selections (`heldFromStale`, core.ts — the fast paths and the slow path) serves a render effect the committed `_value` of a node another live transaction staged, and records the reader in that transaction's `_gatedSubs`. The commit is silent (the staging walk was the notification), so a reader that linked after the walk — an effect created during the hold, a store key first read under it — would otherwise show the old value past the reveal. An effect the transaction itself computed (`_valueTransition` resolves to it) is not recorded: it re-derives at the commit through its parked run or the contested re-derive (#3322), and a replay would publish the frame twice. Pinned in `tests/spec-async-semantics.test.ts` ("a reader that links to a held node during the hold"). **First observer (#3458):** the stale reader's carve-out joins the transaction's reporters for the node when it has an entry (#3374); when it has none — the flight was up but nobody displayed it (`b` in flight beside the observed `a`), and this reader is the FIRST to — `heldFromStale` notifies the pending status up the reader's queue chain under the transaction (`runInTransition(txn, () => c._queue.notify(c, STATUS_PENDING, …))`), the one registration site (INV-3), so the transaction now waits on the flight it revealed a reader of. Before, the join found nothing, the transaction was judged complete on `a` alone, and revealed `Count: 1 | A: 1` beside the committed `B: 0`. A collecting boundary on the chain consumes the notification as ever (A33). Pinned: `tests/first-observer-stale-reader.test.ts`. - Hold rule (`laneHeld`, #3289; per-node lookup #3335): a lane is held iff some `_pendingAsync` node is in the `_asyncReporters` of **any live transaction** (`waitingTransition(node) !== null`) — i.e. a render effect observed it pending and no boundary consumed the status (INV-3, the one registration site). Not "its transaction's": lanes merge across transactions (#2912) and the merged root's transaction recorded only one member's observations. Same rule as `transitionComplete`: unrendered async and fallback-caught async hold nothing. The two facts arrive in either order (a node created by the lane's own reveal is observed first and stamped on a later re-ask), which is why the hold is a predicate over both records rather than a registration. The predicate prunes as it reads (#3426): `waitingTransition` asks `sourceObserved`, the same live-reporter test `transitionComplete` runs, so a reporter that died (disposed, behind a fallback, no longer reading the node) stops holding the lane at the next check. A live action parks its transaction without a verdict, so this is the only prune an optimistic frame whose last async reader unmounted mid-action ever gets — the lane used to hold on the dead registration until the flight nobody observed landed. ## 3. Transitions (`scheduler.ts`) @@ -104,13 +105,14 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - `_contested` — effects whose single value slot was written under this transaction and then overwritten by another live transaction or by mainline (#3322). Effects are not shared state, so a shared effect never merges transactions (memos do, via their `_transition` stamp; `recompute`'s stamp re-entry is memo-only since #3407 — an effect's pass belongs to whatever dirtied it); instead `Effect._valueTransition` records which view produced `_value`, `recompute`, when that owner changes, registers the effect on every owed live transaction, and `finalizePureQueue` re-dirties them **before** its heap run so the re-derive and the effect phase land in the same pass — the other view's value is never published. Exception: a settle that reverts optimism (a non-empty `_optimisticNodes`) re-dirties them **after** `_resolveOptimistic`, with the gated replay — between `commitPendingNodes` and the revert the truth is committed but the overrides still display, and a re-derive there composes the two (the #3164 tear; the reveal wake sits post-revert for the same reason). The slot meanwhile holds the frame already on screen, so nothing new is published early. Rules that fall out: a stale (render) reader with no transaction active is mainline and sees a foreign transaction's staged signal as committed (`read`'s fast path and `readNodeFast` apply `stale && el._transition !== null`, matching the slow path); a value computed mainline needs no protection (mainline publishes what it computes, and a transaction whose writes never touched the effect finds it still correct at commit). - `onSettled` and the revert re-derive (#3411): the settle drops the overrides in the commit pass and only _enqueues_ their subscribers (the revert's `insertSubs`, the contested and gated replays, the store clears); the pass after re-derives them, and reads do not pull (`prepareComputed(el, false)`). An unowned `onSettled` callback is a one-shot in the commit pass's user phase, so it read the optimistic source already reverted next to a sync memo of it still holding the optimistic value. The fire waits for the heap to drain instead — while `dirtyQueue` has work it re-enqueues itself (`run` swaps the queue, so the re-enqueue lands in the next pass; `enqueue` keeps the drain alive) — which is what settled means: no derivation outstanding. Forcing the re-derive into the commit pass is not an option: an optimistic write completing in its own flush shows its lane frame (applied by `cleanupCompletedLanes`) before the truth, and the store clears compose a torn shape when their readers re-derive inside finalize (#3164). - Finalize re-entry (#3319): `finalizePureQueue` can _enter_ a held transaction partway through — a store commit hook (`bumpDeep` on a node the transaction owns), a boundary `_checkSources` write, or a stamped recompute in its heap — and `initTransition` then adopts the batch being finalized. Two rules keep that consistent. **State:** finalize captures the batch it started with and, if `currentBatch` changed, commits/reverts nothing batch-derived (the entered transaction owns it now); a _completing_ transaction whose ambient batch was separate (the #2916 shape) still settles its own containers, since adoption never touched them. **Effects:** ownership. A run applies with the commit of the transaction that computed its value (`Effect._valueTransition`). The ordinary effect phase runs with `activeTransition` set only in a flush whose finalize entered one, so `runEffect` leaves runs owned by a still-held transaction queued — `_modified` stays set — for the next gate to stash with the owner, while everything computed mainline (the write that caused the flush) applies now. Lanes are exempt by construction: they apply their own effects ahead of their transaction (the optimistic view) and their runner ORs `LANE_RUN` into the `type` it passes; the creation-time immediate run in `effect()` passes it too. The exemption is keyed on the _effect_ still having a lane, not on the runner: after a supersession demotes the cascade (#3331, §1), a `LANE_RUN` runner reaching a now lane-less effect whose value was computed under a still-held transaction leaves it queued like any owned run — otherwise the lane would apply the corrected derivation ahead of the commit that is supposed to reveal it. Known residue: writes staged by a hook _before_ the entry are adopted (held) and, because finalize's heap runs after its hooks, their dependents recompute owner-stamped and park with them; an entry that happens _inside_ that heap can leave an earlier mainline-computed effect applied over an adopted source — narrow, and inherited from adoption rather than from this rule. -- `transitionComplete`: prunes dead reporters (`reporterBlocksSource`), transition is done when no live reporter still blocks a source that is **still pending** (a non-empty `_pendingSources`) and no active-override node is blocked on someone else's async. Judged by the set, not `_error.source` (#3375: a later-pending input overwrites it on propagation while the flight is still in the air) and not the self entry alone (#3462: an upstream re-ask that supersedes the source's own flight retires that entry and leaves the source pending on the re-ask — its reader still cannot render, and the landing folds the transaction in; judged complete instead, a re-entry before the landing, such as a repeated write to the held signal, committed the held writes beside the reader's stale frame). +- `transitionComplete`: prunes dead reporters (`reporterBlocksSource`), transition is done when no live reporter still blocks a source that is **still pending** (a non-empty `_pendingSources`) and no active-override node is blocked on someone else's async. Judged by the set, not `_error.source` (#3375: a later-pending input overwrites it on propagation while the flight is still in the air) and not the self entry alone (#3462: an upstream re-ask that supersedes the source's own flight retires that entry and leaves the source pending on the re-ask — its reader still cannot render, and the landing folds the transaction in; judged complete instead, a re-entry before the landing, such as a repeated write to the held signal, committed the held writes beside the reader's stale frame). **Zombies (#3463):** a reporter with `REACTIVE_ZOMBIE` — its owner's pass replaced it, its disposal staged in a live transaction (held children, #3404) — is still on screen and is live for every hold but one: `reporterBlocksSource(reporter, source, verdict)` walks the zombie's `_parent` chain to the first non-zombie owner and resolves the transaction staging its removal (the owner's `_transition`, or `activeTransition` for a `CONFIG_HELD_CHILDREN` owner); the zombie is moot only when that transaction is the one being judged (`verdict`) — done, and the commit disposes it; not done, and it stays parked regardless — or is already done. `sourceObserved(transition, source, verdict?)` passes `verdict ? transition : null`, and keeps (rather than prunes) a zombie the verdict passed over: moot for this verdict, it still holds a lane's reveal while the transaction stays parked on something else. Before, a zombie counted as disposed everywhere, and the lane revealed `Value: 1` beside the zombie's `Details: 0`. Pinned: `tests/lane-outside-view.test.ts` (#3463). - Fallback-caught async holds nothing — in both orders (A33; ruled 2026-09-12, #3375). A collecting boundary consumes the notification, so a reader under a fallback never registers. A reader registered while its boundary showed content (forwarded) stays registered when the boundary's `on` changes and it flips to the fallback; `reporterBlocksSource` therefore walks the reporter's `_queue._parent` chain and treats a reporter behind a collecting pending-type boundary (`_collectionType & STATUS_PENDING && !_initialized`) as not live. If nothing outside the boundary consumes the flight, the hold is over; a reader outside it still holds. The reset itself calls `wakeParked()` so the re-judgement happens in the same drain. The hold moves onto the boundary, not off the screen (#3459): the reset also collects, from every live transaction's `_asyncReporters` (INV-3, the one record of a forwarded reader), the sources of each reporter it routes — `_holds`: under this queue with no collecting pending-type boundary between — plus that reporter's `_pendingSources`, and flips to the fallback if it found any. A forwarded reader already pending never re-notifies (status propagation dedupes on its `_pendingSources`), so without this a sibling reader's fresh flight was the only source collected, and its landing revealed the still-flying one stale (`B: 1 | Fast: 1 | Slow: 0`). Pinned: `tests/loading-reset-collects-forwarded-3459.test.ts`. - Wake of parked transactions (`wokenTransitions`): the flush judges only the _active_ transaction; a parked one is re-entered by a stamped node's landing (`settleTransition`), a stamped recompute, or an action resuming. A reporter that stops counting for another reason — its boundary reset (above), or its disposal by ambient work (#3372: `disposeChildren(self)` on a node with `_transition` and `STATUS_PENDING`; a pending reader is always queued as a pending node, so the stamp is reliable) — is none of those: `reporterBlocksSource` would prune it at the next check, but no check comes, and the writes held with it stay staged. Such sites record the transaction (deduped) and schedule; the flush re-enters a woken transaction from the `finally` of a full pass — reached from the park exit and the normal exit alike — and only when idle: no `activeTransition` and `!scheduled`, which at that point means an empty dirty heap, no write since the heap ran (every write re-arms it) and, the finalize having reverted them, no optimistic ambient nodes. Entering adopts the ambient batch, and ambient work present at that instant would be held behind flights it never read; a wake in a pass with work just falls to the next. Entries are popped in a loop until one enters: a wake whose transaction completed by other means is a bare return (`initTransition` on `_done`) and must not strand the ones behind it. The fast drain defers to the full path while a wake is outstanding so such dead entries are still consumed. A wake with other live reporters re-parks; the idle pass is its only cost. Known shape: the ambient write that triggered the reset commits in its own pass and the released hold in the idle pass after it — two effect runs in one synchronous drain (`Sum: 1`, `Sum: 2` at the same clock time in the #3375 pin), never a visible tear. - Staged reads enter (A29, #3408): `read()` calls `enterStagedRead` on every selection that returns `_pendingValue` — the fast paths and the slow path — and it enters `el._transition` unless that is null (ambient batch), already active, or the read is a probe (`pendingCheckActive`). The third entry beside `setSignal` on a stamped node and `recompute` of a stamped node; rule text and the `Panel: 1` beside `Count: 0` shape live in the spec. A stale (render) reader never reaches it: the carve-out below serves it the committed value. -- Dependencies are the committed frame's (A30, #3410): `recompute`'s tail trims the previous pass's dependency tail only for a pass that published or changed nothing (`_pendingValue === NOT_PENDING` and no `_error`); a staged pass leaves it linked and `commitPendingNode` trims after a clean pass (`_error == null` — a set `_error` means the last pass threw, kept its full list, and `_depsTail` marks where it stopped); an effect pass that direct-committed but still owes a run (`_modified`, #3438 — the flush may stash that run into a transaction it opens later) leaves it for `runEffect` to trim once the run applies. `__OBSERVE__` fan-in counting walks the validated prefix only, so a held tail does not inflate distinct-source counts. Why commit-time and not the pass, and the `Selected: 0` beside `Count: 1` shape: the spec. +- Dependencies are the committed frame's (A30, #3410): `recompute`'s tail trims the previous pass's dependency tail only for a pass that published or changed nothing (`_pendingValue === NOT_PENDING` and no `_error`); a staged pass leaves it linked and `commitPendingNode` trims after a clean pass (`_error == null` — a set `_error` means the last pass threw, kept its full list, and `_depsTail` marks where it stopped); an effect pass that direct-committed but still owes a run (`_modified`, #3438 — the flush may stash that run into a transaction it opens later) leaves it for `runEffect` to trim once the run applies. `__OBSERVE__` fan-in counting walks the validated prefix only, so a held tail does not inflate distinct-source counts. Why commit-time and not the pass, and the `Selected: 0` beside `Count: 1` shape: the spec. **Unchanged pass (#3469, `heldTrims`, scheduler.ts):** a pass that changed nothing replaced nothing either, and cannot know at its own tail whether the flush that ran it will park with its inputs held — `b() ? b() : a()` computed `1` from the held `b=1`, equal to the `1` it had from `a`, trimmed `a`, and the mainline `a=2` never reached it. `recompute`'s tail now trims at once only for a creation pass, an OPT-dirty pass, or a tracked effect (its frame is replaceable like a direct commit — it runs after the commit and a spurious run is user-visible); any other unchanged pass with a stale tail is pushed to `heldTrims`, drained by `commitPendingNodes` (the flush committed: trim) and cleared when the flush parks (the tail stays linked until a committing pass trims it — one spurious recompute at most). Pinned: `tests/held-frame-dependencies.test.ts`. - Reveal-hold and its carve-out (#3305, #3334, re-ruled 2026-09-10): a reader landing on a node with `STATUS_PENDING` throws — the throw reaches `GlobalQueue.notify`, which opens a transaction for the reveal if none is active (#3305) and records the source as its reporter (INV-3); the reveal completes when the flight lands. One carve-out, the staged-value rule's twin for flights: a **stale** (render) reader of a node pending in some **other** transaction shows the node's committed value, does not entangle (its own writes stay outside that transaction), is recorded for that transaction's commit replay (`heldFromStale`), and joins the transaction's reporters for the node when it has an entry (#3374) — the reader displays the pre-flight value, so the transaction cannot commit the flight's inputs ahead of its answer just because the reader that opened the entry was disposed (a keyed remount). It is refused — the reader holds — when the committed value would tear against the frame: the node carries `CONFIG_INPUTS_PUBLISHED` (a batch or transaction committed with the node still pending, `commitPendingNode`'s computed branch: the flight's inputs are on screen; cleared when the node next enters pending from a settled state, `notifyStatus`), or the node is routed through a live lane (`GlobalQueue._laneLive` → `resolveLane`, exact rather than sticky: lane-revealed inputs, optimistic or `latest`), or the node is uninitialized (nothing committed to show). The stamp itself is pending-node bookkeeping and decides nothing. Replay hygiene: an effect recorded in `_gatedSubs` that later recomputes _under_ the transaction sees its staged view and is applied by the commit (ownership) — `recompute` drops the stale recording at its start (`activeTransition._gatedSubs.delete`), and a lane's committed-view read re-records during the run, so the lane replay (`laneReadsCommitted`) is untouched. - Settle-time re-entry, lane-routed nodes (#3334): `handleAsync`'s `settleTransition` re-enters `resolveTransition(el)` — for a lane-routed node the transaction that _owns_ the lane. That owner's commit is only the override's confirm/revert; the landing itself is revealed by the lane. If a transaction is _waiting_ on the node (`waitingTransition(el)`), the settle enters that one instead: entering the owner would fold a reveal that only waits on the flight into the owner's action (A18 node corollary, #2912). Every other transaction waiting on the flight then folds in explicitly (`enterWaiting`, #3407 — see the next bullet): each reveal that discovered the flight completes at its landing (A15). +- Re-park sweep (#3456, `recompute`'s catch): a pass that re-parks on a new pending source set drops what the earlier pass carried. `handleAsync` resets the node's `_pendingSources` to the new set, but a source the pass no longer reaches — its branch switched (`count === 1 ? details() : 0` → an own promise), or a fresh flight replaced its inputs' pending with its own — stays copied onto dependents that reached it only through here, and that source's landing walk stops at this node (nothing left to retire) before it finds them; a dependent then waits forever on a flight it has no path to (`Panel: hidden` after `selected` landed). The catch now settles, against the node, every previously carried source (`outgoingPendingSources`) absent from the new set (`settlePendingSource(el, source)`) — the re-park twin of the unchanged-value recovery sweep; a dependent with another path to the source keeps it (`retryReaches`). The sync twin recovers on its own (an errored pass keeps its dropped dep linked, so the landing still reaches the node); pinned beside it. Pinned: `tests/pending-source-repark.test.ts`. - Pending propagation onto a held memo entangles (#3443): `notifyStatus`'s dependent walk, on a pending propagation reaching a memo another live transaction _holds_ — stamped by it AND pending on its work or carrying its staged `_pendingValue`; not behind a boundary — enters that transaction — `initTransition(sub._transition)`, merging it into the active one or, with none active, entering it and adopting the ambient batch (the write that started this flight becomes its). A memo the first transaction holds and the second flight now feeds cannot reveal before the second lands (A15: a shared derivation of both), and propagation is the one moment that is known: it marks the memo pending without recomputing it (its inputs' _values_ are unchanged), so the memo's stamped re-entry — the entanglement's usual site — never ran, the first flight landed, its transaction's verdict saw only its own reporters (the memo's reader had registered for the second flight in the _second_ transaction, INV-3 keyed by transaction), and it revealed `A: 1` beside the memo's committed `Sum: 0`. The stamp alone decides nothing (#3334): a memo the transaction once queued but holds nothing of — status clear, nothing staged — is not entangled, or a `Dynamic` switched twice mid-flight would drag the superseded first call's gate into the live second call's reveal (`call-driven-lifecycle` args-switch-gate: the second write supersedes the first through the shared output memo, and only the live answer settles it). Effects are skipped (`_type`): an effect entangles nothing by itself (A15 shared-hole corollary) — its reader registers with the flight's transaction at queue notification and the landing folds waiters in (`enterWaiting`). Consequence pinned alongside: a write whose async work flows into a held memo is held with it (`page=1` beside `count=1` while `details` re-asks — before, the ambient page=1 committed a pass ahead), which is also why the #3375 boundary-reset pin now publishes `Sum: 2` once at the reset instead of `Sum: 1 | Sum: 2`. Pinned: `tests/overlapping-flights.test.ts`. - Pass provenance for effects (#3407): a render effect's pass belongs to whatever dirtied it. `recompute` re-enters a stamped node's transaction only for memos (their value _is_ that transaction's work); an effect stamped by a transaction — it observed that transaction's flight — and dirtied by another transaction's write, or by mainline, runs that writer's pass, reads the held flight as a stale reader (committed value, `heldFromStale`) and publishes with the writer. The pass entangles only if it _observes_ a pending flight (the carve-out refused: inputs published, lane-live, uninitialized) — the throw reaches `GlobalQueue.notify`, which registers the writer's transaction as a reporter, and the flight's landing folds it in. Before, `recompute` re-entered an effect's stamp whenever _any_ other transaction was active: a sync `action` write to a signal that merely shared a hole with a held async (`{b()}:{detailsA()}`) merged into the async's transaction and waited (`0:0 → 2:1`, no `1:0`), while the same write made plainly passed through; two independent flights read in one hole settled as one unit. Now both writers publish on their own (`1:0` at the write, `1:1` at the landing; two flights land at their own times). The re-entry's other job — delivering a landing to the transactions waiting on it — moves to the landing itself: `settleTransition` enters every parked transaction whose reporters still observe the node (`enterWaiting`, over `sourceObserved`), including the waiter of a stampless node (a flight started under a batch that committed beneath it, #3305), whose landing used to open a fresh batch that the stamped reader's re-entry folded into the waiter. Pinned: `tests/shared-effect-no-entangle.test.ts`; the reveal-completion pins (`spec-async-semantics` A15, `reveal-carve-out`, `stale-read-uninitialized-cross-transition`) are the regression net for the fold. - The action body's end starts the correction (#3427, `endOptimism`, called from `flush` after the heap and before the verdict): with the bodies over (`_acted` and no live `_actions`) and nothing _authoritative_ in flight — no override node's own source (`transitionBlocked`), no held flight that does not derive from an override (`sourceObserved` and not `resolveLane`) — each override's truth (the staged value an A17-silent landing left, else the committed value) supersedes it now, as an arriving differing truth does (A18): the graph re-derives from the truth as the transaction's held work, and the transaction settles when _that_ lands. The lane-derived flights were questions about the guess; nobody reads their answer. Before, the settle waited for the obsolete flight, revealed the obsolete optimistic frame when it landed, then reverted and re-asked — a waterfall with a flash. A co-written plain load the action asked for (`setSaving(true); setPage(2)`) is authoritative and keeps the optimistic world up until it lands. Optimistic **store** edits opt the whole transaction out (their truth is the base layer under an overlay with no tracked/displayed split); companions (`_parentSource`) answer for their owner and snap at settlement, not here. Pinned: `tests/optimistic-lane-release.test.ts`. @@ -194,20 +196,48 @@ Confidence: **high** = implementation self-consistency, assert now. verdict `true` forever (the declared-motion analogue of the INV-9 latch). - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality gate compares the new result against the slot it is about to publish to: - the override for an override-covered node, `_value` for a lane (OPT-dirty) - direct commit, `_pendingValue` for a transaction-staged run. "Unchanged" is + the override for an override-covered node (a written one, or the derived + one a previous lane pass left — `CONFIG_DERIVED_OVERRIDE`, #3479), `_value` + for a lane pass's FIRST publish (no override yet: the compare is against the + committed value the override will shadow) and for an effect's or a + reversion pass's direct commit, `_pendingValue` for a transaction-staged + run. **Lanes stage (#3479):** a lane pass on a memo no longer direct-commits + `_value`; it publishes into the override slot (`laneOverride`), so the + committed view an outsider is served (`readsHeldCommitted`) is a whole frame + — the source's shadow and its derivations — and the lane's own view is the + override end to end. The reversion pass (OPT-dirty with no live lane, the + override dropped) and an effect's lane pass still direct-commit. "Unchanged" is a statement about what the publishing view will show, so comparing against a different view produces torn frames: #3330 compared a lane recompute against a `_pendingValue` an earlier action write had staged, called the identical result unchanged, and revealed the override without its derivation. Pinned in `tests/spec-async-semantics.test.ts` (A17, #3330); not a runtime assertion because the publishing slot is decided inside the - same branch that compares. Corollary (#3377): a lane direct commit also - _retires_ the transaction-staged `_pendingValue` it supersedes, override - or not — a node that adopted the lane through its deps (a `latest()` read; - the companion is an optimistic node) may have staged a hold on an earlier, + same branch that compares. Corollary (#3377): a lane pass also _retires_ + the transaction-staged `_pendingValue` it supersedes, override or not — a + node that adopted the lane through its deps (a `latest()` read; the + companion is an optimistic node) may have staged a hold on an earlier, lane-free pass of the same transaction, and left in place that older frame - commits over the fresh `_value`. + commits over the fresh one. Derived-override lifecycle (#3479): joins the + lane's transaction's `_optimisticNodes` on its first publish (the ambient + batch would revert an async landing's at its own end); reverts with it — + _promoted_ to `_value` when not superseded (sources revert before their + derivations in that list, and a source whose truth differs dirties them, so + a derivation the revert did not dirty is what the truth yields), dropped + when superseded (the truth is staged); the slot disarms to `undefined` for + a plain memo and stays `NOT_PENDING` for a written node the lane corrected. + Demoted by its source's supersession (A18), its plain re-derivation runs + the sync twin and its landing takes `asyncWrite`'s hold-and-supersede + branch (never the plain `setSignal`, which would read the armed slot as a + fresh optimistic write and open a lane with the memo as source). Until then, + _every_ pass over it is the lane's pass, whatever channel dirtied it + (`recompute`'s derived-override branch): it is still a member, its inputs + serve the lane's view, and its result is the lane's — run plain, the sync + twin read a re-derived lane view (a fresh tuple) as a differing truth, + superseded and demoted, and the lane's next pass dropped that staged + "truth" and left the flag pointing at a `_value` never committed (fuzzer + latest-1 #2481: a boundary reset re-ran a lane-born memo). A fresh lane + publish clears `CONFIG_OVERRIDE_SUPERSEDED` for the same reason. Rejected for assertion (state space too dynamic, would need semantic rulings): whether `_optimisticLane` must always resolve to a live lane (stale lanes are diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index e9e5c2613..f934eeaa4 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -6,34 +6,34 @@ IDs are never renumbered or deleted — source comments cite them. A superseded, ## Vocabularies -| prefix | defined in | meaning | -| ------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `A` | `SPEC-ASYNC-SEMANTICS.md` Tier A | ruled, test-pinned propositions about isPending / latest / transitions / optimistic lanes | -| `B` `C` | `SPEC-ASYNC-SEMANTICS.md` Tier B/C | inferred / open items, all since ruled, closed or promoted into an A-rule (`(was B1)` — the alias row points at it) | -| `V` | `SPEC-ASYNC-SEMANTICS.md` Known violations | violations of A-rules found and fixed by the #2838 redesign; pinned in `spec-async-semantics.test.ts` | -| `INV-` | `INTERNALS-ASYNC-STATE.md` §5 | `__TEST__` invariants (asserted in `invariants.ts`) | -| `RUL-` | `INTERNALS-STORE-STATE.md` §8b, `rules-mining/FINDINGS.md` | store rulings mined from the suites (2026-08-16) | -| `-R` | `rules-mining/.md` | mined behavioral rules. **Each file numbers from R1**, so an R-id is only meaningful with its namespace: `CS` core-store · `OL` optimistic-lanes · `OS` optimistic-store · `PJ` projections · `RS` reconcile-snapshot. Comments qualify with `core`/`opt`/`proj`/`snap`; a bare `R` refers to the citing module's own file. | -| `§` | `INTERNALS-STORE-STATE.md` sections; `NODE-SHAPE.md` §11b, §12–§12e | design sections cited as rules. §11–§12 are the stage-3 node-shape decisions recovered from the deleted `DESIGN-PATCH-CHANNEL.md` (see NODE-SHAPE.md for provenance) | +| prefix | defined in | meaning | +|---|---|---| +| `A` | `SPEC-ASYNC-SEMANTICS.md` Tier A | ruled, test-pinned propositions about isPending / latest / transitions / optimistic lanes | +| `B` `C` | `SPEC-ASYNC-SEMANTICS.md` Tier B/C | inferred / open items, all since ruled, closed or promoted into an A-rule (`(was B1)` — the alias row points at it) | +| `V` | `SPEC-ASYNC-SEMANTICS.md` Known violations | violations of A-rules found and fixed by the #2838 redesign; pinned in `spec-async-semantics.test.ts` | +| `INV-` | `INTERNALS-ASYNC-STATE.md` §5 | `__TEST__` invariants (asserted in `invariants.ts`) | +| `RUL-` | `INTERNALS-STORE-STATE.md` §8b, `rules-mining/FINDINGS.md` | store rulings mined from the suites (2026-08-16) | +| `-R` | `rules-mining/.md` | mined behavioral rules. **Each file numbers from R1**, so an R-id is only meaningful with its namespace: `CS` core-store · `OL` optimistic-lanes · `OS` optimistic-store · `PJ` projections · `RS` reconcile-snapshot. Comments qualify with `core`/`opt`/`proj`/`snap`; a bare `R` refers to the citing module's own file. | +| `§` | `INTERNALS-STORE-STATE.md` sections; `NODE-SHAPE.md` §11b, §12–§12e | design sections cited as rules. §11–§12 are the stage-3 node-shape decisions recovered from the deleted `DESIGN-PATCH-CHANNEL.md` (see NODE-SHAPE.md for provenance) | Status legend: **live** stated and standing · **ruled** carries an explicit ruling date · **amended** re-ruled or re-scoped in place (the row text says how) · **superseded** replaced by a later rule (the text names it) · **retired** mechanism removed, ID kept for citations · **fixed / resolved / closed** a violation or open item with its outcome · **ruled out** a design that was tried and rejected. ## Summary | vocabulary | rules | cited in src | cited in tests | cited nowhere | -| ---------- | ----- | ------------ | -------------- | ------------- | -| A | 33 | 17 | 33 | 0 | -| V | 5 | 2 | 5 | 0 | -| B | 5 | 0 | 5 | 0 | -| C | 4 | 0 | 3 | 1 | -| INV | 11 | 11 | 5 | 0 | -| RUL | 13 | 6 | 6 | 5 | -| R (CS) | 59 | 18 | 16 | 31 | -| R (OL) | 37 | 0 | 0 | 37 | -| R (OS) | 46 | 2 | 0 | 44 | -| R (PJ) | 36 | 6 | 1 | 30 | -| R (RS) | 38 | 9 | 2 | 27 | -| § | 24 | 14 | 8 | 8 | +|---|---|---|---|---| +| A | 33 | 17 | 33 | 0 | +| V | 5 | 2 | 5 | 0 | +| B | 5 | 0 | 5 | 0 | +| C | 4 | 0 | 3 | 1 | +| INV | 11 | 11 | 5 | 0 | +| RUL | 13 | 6 | 6 | 5 | +| R (CS) | 59 | 18 | 16 | 31 | +| R (OL) | 37 | 0 | 0 | 37 | +| R (OS) | 46 | 2 | 0 | 44 | +| R (PJ) | 36 | 6 | 1 | 30 | +| R (RS) | 38 | 9 | 2 | 27 | +| § | 24 | 14 | 8 | 8 | ## Unresolved citations @@ -43,371 +43,360 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## A — spec propositions -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:249` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | -| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:257` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | -| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:265` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | -| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | -| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:281` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | -| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:289` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | -| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… | -| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | core.ts×1 verdict.ts×2 | createMemo.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | -| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | -| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | -| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | -| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | -| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×4 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 visibility-oracle.states.ts×6 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | -| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.test.ts×12 visibility-oracle.states.ts×25 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | -| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.test.ts×10 visibility-oracle.states.ts×24 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | -| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.states.ts×11 visibility-oracle.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | -| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:323` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | -| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:330` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | -| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | -| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | -| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×2 visibility-oracle.states.ts×3 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | -| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:239` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | -| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | -| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:231` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | -| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.test.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 latest-held-till-flush.test.ts×2 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | -| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | -| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | — | visibility-oracle-store.test.ts×6 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | -| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:251` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | +| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:259` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | +| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:267` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | +| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:275` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | +| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | +| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:291` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | +| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | core.ts×1 verdict.ts×2 | createMemo.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | +| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | +| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | +| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | +| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×4 lanes.ts×2 scheduler.ts×2 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 visibility-oracle.states.ts×6 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×4 constants.ts×2 core.ts×9 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.test.ts×12 visibility-oracle.states.ts×25 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×3 constants.ts×1 core.ts×6 optimistic.ts×6 scheduler.ts×3 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 lane-outside-view.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.test.ts×10 visibility-oracle.states.ts×24 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | +| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.states.ts×11 visibility-oracle.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPending(x)` ≡ the value… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:325` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | +| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:332` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | +| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | +| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | +| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×2 visibility-oracle.states.ts×3 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | +| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:241` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | +| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:233` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.test.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×3 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | — | visibility-oracle-store.test.ts×6 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | +| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:221` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | ## V — fixed violations -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ------ | ---------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------ | -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:393` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | -| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:403` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | -| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:409` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:416` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | -| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:428` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:395` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | +| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:405` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | +| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:411` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:418` | — | spec-async-semantics.test.ts×5 | - **V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | +| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:430` | — | spec-async-semantics.test.ts×3 | - **V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ------ | ---------------------------------- | ------------ | ------------------------------------------------------------------------------- | -------------------------------------------------- | -| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | -| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | -| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | -| B4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:41` | — | spec-async-semantics.test.ts×2 | PROMOTED → A18 (A18's section carries the ruling). | -| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | +| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | +| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | +| B4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:41` | — | spec-async-semantics.test.ts×2 | PROMOTED → A18 (A18's section carries the ruling). | +| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | ## C — tier C -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ------ | ---------------------------------- | ------------ | -------------------------------------------------- | --------------------------------------------------------------------------- | -| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | -| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:359` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | -| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:369` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | -| C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:31` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | +| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:361` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | +| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:371` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:31` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | ## INV — invariants -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ------- | ----------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:145` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | -| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:147` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | -| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:151` | boundaries.ts×1 core.ts×1 invariants.ts×2 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 loading-reset-collects-forwarded-3459.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside | -| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:158` | invariants.ts×3 | — | - **INV-4 (medium)** After any of the three write paths completes for node `el` | -| INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:162` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | -| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:168` | invariants.ts×2 | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in | -| INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:171` | core.ts×1 invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node | -| INV-8 | retired | `docs/INTERNALS-ASYNC-STATE.md:174` | invariants.ts×1 | rules-index.test.ts×1 | - **INV-8 (RETIRED 2026-07-07b, §5e)** Hold-provenance: a `_pendingValue` on an | -| INV-9 | live | `docs/INTERNALS-ASYNC-STATE.md:185` | invariants.ts×1 owner.ts×1 | — | - **INV-9 (high)** An `isPending` companion of a DISPOSED owner reads `false` | -| INV-10 | live | `docs/INTERNALS-ASYNC-STATE.md:190` | invariants.ts×2 | action-done-window.test.ts×1 | - **INV-10 (high)** Affects-count balance (question-scoped model, 2026-07-13; | -| INV-11 | live | `docs/INTERNALS-ASYNC-STATE.md:195` | core.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:147` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | +| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:149` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | +| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:153` | boundaries.ts×1 core.ts×1 invariants.ts×2 lanes.ts×1 scheduler.ts×2 | first-observer-stale-reader.test.ts×1 lane-hold-on-observation.test.ts×1 loading-reset-collects-forwarded-3459.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside | +| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:160` | invariants.ts×3 | — | - **INV-4 (medium)** After any of the three write paths completes for node `el` | +| INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:164` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | +| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:170` | invariants.ts×2 | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in | +| INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:173` | core.ts×1 invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node | +| INV-8 | retired | `docs/INTERNALS-ASYNC-STATE.md:176` | invariants.ts×1 | rules-index.test.ts×1 | - **INV-8 (RETIRED 2026-07-07b, §5e)** Hold-provenance: a `_pendingValue` on an | +| INV-9 | live | `docs/INTERNALS-ASYNC-STATE.md:187` | invariants.ts×1 owner.ts×1 | — | - **INV-9 (high)** An `isPending` companion of a DISPOSED owner reads `false` | +| INV-10 | live | `docs/INTERNALS-ASYNC-STATE.md:192` | invariants.ts×2 | action-done-window.test.ts×1 | - **INV-10 (high)** Affects-count balance (question-scoped model, 2026-07-13; | +| INV-11 | live | `docs/INTERNALS-ASYNC-STATE.md:197` | core.ts×1 optimistic.ts×2 | spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality | ## RUL — store rulings -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | -------- | ----------------------------------- | ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| RUL-1 | resolved | `docs/INTERNALS-STORE-STATE.md:512` | store.ts×2 target.ts×1 | next-smoke.test.ts×1 | - \*\*RUL-1 — RESOLVED (2026-08-16, verified per "mirror signals if verified" — | -| RUL-2 | ruled | `docs/INTERNALS-STORE-STATE.md:521` | optimistic.ts×1 target.ts×1 | createOptimisticStore.test.ts×1 | - \*\*RUL-2 — RULED (Ryan, 2026-08-17): no landing matrix. Two orthogonal | -| RUL-3 | resolved | `docs/INTERNALS-STORE-STATE.md:620` | optimistic.ts×1 target.ts×1 | — | - **RUL-3 — RESOLVED (verified 2026-08-16).** Ownership already lives | -| RUL-4 | resolved | `docs/INTERNALS-STORE-STATE.md:627` | — | optimistic-signal-refetch-hold.test.ts×1 | - **RUL-4 — RESOLVED (2026-08-17, signal parity — verified empirically).** | -| RUL-5 | live | `docs/INTERNALS-STORE-STATE.md:640` | reconcile.ts×1 | adoption-lane-rollback.test.ts×1 | - **RUL-5 — SPEC'D (2026-08-17): §6b.** Lane backing + lane-view/committed- | -| RUL-6 | live | `docs/INTERNALS-STORE-STATE.md:644` | — | — | - **RUL-6 — reclassified: SPEC WORK, not a ruling.** Live chaining (#2941 — | -| RUL-7 | live | `docs/INTERNALS-STORE-STATE.md:653` | — | — | - **RUL-7 — SPEC'D (2026-08-17): §6c.** One status field on the root target, | -| RUL-8 | live | `docs/INTERNALS-STORE-STATE.md:656` | — | adoption-lane-rollback.test.ts×1 | - **RUL-8 — SPEC'D (2026-08-17): §6 rewrite, resolves O2.** Per-transaction | -| RUL-9 | resolved | `docs/INTERNALS-STORE-STATE.md:659` | — | — | - **RUL-9 — RESOLVED (2026-08-17, parity by construction).** Every piece of | -| RUL-10 | live | `docs/INTERNALS-STORE-STATE.md:664` | optimistic.ts×1 | — | - **RUL-10 — The equality trio.** One precise rule needed spanning: no-op | -| RUL-11 | live | `docs/INTERNALS-STORE-STATE.md:669` | — | — | - **RUL-11 — SPEC'D (2026-08-17): §6d.** Sticky descendants flag ported from | -| RUL-12 | live | `docs/INTERNALS-STORE-STATE.md:671` | optimistic.ts×1 reconcile.ts×1 store.ts×2 | createProjection.async.test.ts×1 shared-child-multiparent.test.ts×1 | - **RUL-12 — Smaller rulings, each with a proposed default** (proceeding on | -| RUL-13 | resolved | `docs/INTERNALS-STORE-STATE.md:717` | — | — | - **RUL-13 — RESOLVED (verified 2026-08-16)**: `optimistic-lane-transaction- | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| RUL-1 | resolved | `docs/INTERNALS-STORE-STATE.md:512` | store.ts×2 target.ts×1 | next-smoke.test.ts×1 | - **RUL-1 — RESOLVED (2026-08-16, verified per "mirror signals if verified" — | +| RUL-2 | ruled | `docs/INTERNALS-STORE-STATE.md:521` | optimistic.ts×1 target.ts×1 | createOptimisticStore.test.ts×1 | - **RUL-2 — RULED (Ryan, 2026-08-17): no landing matrix. Two orthogonal | +| RUL-3 | resolved | `docs/INTERNALS-STORE-STATE.md:620` | optimistic.ts×1 target.ts×1 | — | - **RUL-3 — RESOLVED (verified 2026-08-16).** Ownership already lives | +| RUL-4 | resolved | `docs/INTERNALS-STORE-STATE.md:627` | — | optimistic-signal-refetch-hold.test.ts×1 | - **RUL-4 — RESOLVED (2026-08-17, signal parity — verified empirically).** | +| RUL-5 | live | `docs/INTERNALS-STORE-STATE.md:640` | reconcile.ts×1 | adoption-lane-rollback.test.ts×1 | - **RUL-5 — SPEC'D (2026-08-17): §6b.** Lane backing + lane-view/committed- | +| RUL-6 | live | `docs/INTERNALS-STORE-STATE.md:644` | — | — | - **RUL-6 — reclassified: SPEC WORK, not a ruling.** Live chaining (#2941 — | +| RUL-7 | live | `docs/INTERNALS-STORE-STATE.md:653` | — | — | - **RUL-7 — SPEC'D (2026-08-17): §6c.** One status field on the root target, | +| RUL-8 | live | `docs/INTERNALS-STORE-STATE.md:656` | — | adoption-lane-rollback.test.ts×1 | - **RUL-8 — SPEC'D (2026-08-17): §6 rewrite, resolves O2.** Per-transaction | +| RUL-9 | resolved | `docs/INTERNALS-STORE-STATE.md:659` | — | — | - **RUL-9 — RESOLVED (2026-08-17, parity by construction).** Every piece of | +| RUL-10 | live | `docs/INTERNALS-STORE-STATE.md:664` | optimistic.ts×1 | — | - **RUL-10 — The equality trio.** One precise rule needed spanning: no-op | +| RUL-11 | live | `docs/INTERNALS-STORE-STATE.md:669` | — | — | - **RUL-11 — SPEC'D (2026-08-17): §6d.** Sticky descendants flag ported from | +| RUL-12 | live | `docs/INTERNALS-STORE-STATE.md:671` | optimistic.ts×1 reconcile.ts×1 store.ts×2 | createProjection.async.test.ts×1 shared-child-multiparent.test.ts×1 | - **RUL-12 — Smaller rulings, each with a proposed default** (proceeding on | +| RUL-13 | resolved | `docs/INTERNALS-STORE-STATE.md:717` | — | — | - **RUL-13 — RESOLVED (verified 2026-08-16)**: `optimistic-lane-transaction- | ## R — core-store (`CS-R`) -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ------ | ------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| CS-R1 | live | `docs/rules-mining/core-store.md:9` | — | flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.test.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).\*\* | -| CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).\*\* | -| CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | reconcile.ts×1 store.ts×1 | — | Corollary R2a (Ryan, 2026-08-17): \*\*take no responsibility for mutation | -| CS-R3 | live | `docs/rules-mining/core-store.md:18` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.\*\* | -| CS-R4 | live | `docs/rules-mining/core-store.md:22` | — | — | Write isolation across a store chain: writing through the last store in a derived chain is visible only there; upstream stores and base objects untouched (shallow or deep middle).\*\* | -| CS-R5 | live | `docs/rules-mining/core-store.md:26` | — | visibility-oracle-store.test.ts×4 visibility-oracle.states.ts×4 | Upstream writes propagate downstream through the chain without re-running structural machinery.\*\* | -| CS-R6 | live | `docs/rules-mining/core-store.md:30` | — | — | No store write path ever mutates a user-provided source object.\*\* Aligned with 2026-08-16b. | -| CS-R7 | live | `docs/rules-mining/core-store.md:34` | — | — | Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).\*\* | -| CS-R8 | live | `docs/rules-mining/core-store.md:38` | — | — | `snapshot` returns fully unwrapped values (no proxy anywhere, `$TARGET` undefined), incl. frozen objects/arrays; reflects committed written values incl. writes over inherited prototype props.\*\* | -| CS-R9 | live | `docs/rules-mining/core-store.md:42` | store.ts×2 target.ts×1 | — | Proxy identity per logical slot is stable across writes and reconciles\*\* (mapArray keyed flows reuse rows across refetch/reconcile). | -| CS-R10 | live | `docs/rules-mining/core-store.md:48` | — | next-smoke.test.ts×1 | Per-property tracking; same-value writes (direct or functional path setter returning prev) do not re-trigger.\*\* | -| CS-R11 | live | `docs/rules-mining/core-store.md:52` | — | visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×2 | Per-path tracking: reading `state.user.firstName` subscribes to that leaf; reading the reference `store[0]` does not subscribe to `store[0].i`.\*\* | -| CS-R12 | live | `docs/rules-mining/core-store.md:56` | store.ts×1 | — | Reading an absent key subscribes to that key: other-key changes don't trigger; defining it later (assignment or defineProperty) does.\*\* | -| CS-R13 | live | `docs/rules-mining/core-store.md:60` | target.ts×1 | — | `in` tracks presence, not value: undefined-write doesn't retrigger; delete does; adding absent key does. `in`/`has` never invokes source getters.\*\* | -| CS-R14 | live | `docs/rules-mining/core-store.md:64` | — | — | `Object.keys` / `for…in` subscribe to key-set membership (root and nested) — distinct from property nodes.\*\* Aligned: key-set node. | -| CS-R15 | live | `docs/rules-mining/core-store.md:68` | store.ts×1 | — | Array structural tracking is uniform across idioms: indexed length loop, `for…of`, mapArray ($TRACK) all re-run exactly once per flush on add/update/removal.\*\* | -| CS-R16 | live | `docs/rules-mining/core-store.md:72` | — | — | `length` independently trackable; index write extending the array notifies length subscribers.\*\* | -| CS-R17 | live | `docs/rules-mining/core-store.md:76` | — | — | Truncating via `length = N` notifies tracked index reads of removed slots (re-run, observe undefined) and clears has/index/keys for removed indices.\*\* | -| CS-R18 | live | `docs/rules-mining/core-store.md:80` | — | — | `snapshot` is non-tracking.\*\* Aligned with read table. | -| CS-R19 | live | `docs/rules-mining/core-store.md:84` | — | — | `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.\*\* | -| CS-R20 | live | `docs/rules-mining/core-store.md:88` | store.ts×1 | — | Source getters (own, prototype, merge-installed) execute with the proxy as receiver, so their internal reads track — incl. through projections.\*\* | -| CS-R21 | live | `docs/rules-mining/core-store.md:92` | store.ts×3 | deep-chained-view.test.ts×1 | Structural subscriptions through a wrapper view (store-in-store) chain to the wrapped source: $TRACK/mapArray, ownKeys, snapshot/trackSelf through an outer derived store re-run when the inner store re… | -| CS-R22 | live | `docs/rules-mining/core-store.md:97` | — | — | Slots holding non-wrappable values (markRaw, Map/Date, function) track by reference: reassignment notifies; internal mutation doesn't.\*\* | -| CS-R23 | live | `docs/rules-mining/core-store.md:103` | store.ts×1 | — | The proxy is immutable from outside the setter: direct assignment and delete are silently ignored (no change, no notify, no TypeError — traps report success while discarding).\*\* | -| CS-R24 | live | `docs/rules-mining/core-store.md:107` | — | next-smoke.test.ts×1 | Writes batch like signals: inside the setter draft, reads are read-your-writes (values, length, `in` sync); outside the setter, ALL reads — value, `in`, length — return pre-write state until flush(). … | -| CS-R25 | live | `docs/rules-mining/core-store.md:112` | — | next-smoke.test.ts×1 | Writes to properties with ZERO observers still batch (no effects anywhere; pre-write value visible between setState and flush).\*\* | -| CS-R26 | live | `docs/rules-mining/core-store.md:117` | — | — | Setting a key to undefined is not deletion: key stays present (`in` true, no key-set notify); only delete / storePath.DELETE removes.\*\* | -| CS-R27 | live | `docs/rules-mining/core-store.md:121` | store.ts×1 | — | The setter may return a replacement value that swaps the root wholesale; symbol keys on the replacement preserved.\*\* | -| CS-R28 | live | `docs/rules-mining/core-store.md:125` | — | — | `storePath` addressing: string keys, numeric indices, index arrays, predicate filters ((value, index)), ranges, trailing functional setters address and update intended paths + trigger per-path subscri… | -| CS-R29 | live | `docs/rules-mining/core-store.md:129` | store.ts×2 | overlay.test.ts×1 | Merge/replacement preserve accessor descriptors and keep getters LIVE (re-evaluated per read, reactive reads track), for pre-existing and new keys.\*\* | -| CS-R30 | live | `docs/rules-mining/core-store.md:134` | store.ts×2 | — | Prototype pollution fully guarded: `__proto__` assignment inert; reading `constructor` on the draft returns undefined; storePath refuses `__proto__`/`constructor`/`prototype` segments; skips unsafe ow… | -| CS-R31 | live | `docs/rules-mining/core-store.md:138` | projection.ts×1 | reconcile-resend-identity.test.ts×1 | Derived-store manual writes win over the recompute for the tick: manual setStore beats a queued recompute in the same flush; a SAME-VALUE manual write still holds against the recompute for that tick; … | -| CS-R32 | live | `docs/rules-mining/core-store.md:143` | store.ts×1 | visibility-oracle-store.test.ts×1 | A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.\*\* Aligned: O7's resolution (a test already exists). | -| CS-R33 | live | `docs/rules-mining/core-store.md:147` | — | visibility-oracle-store.test.ts×2 | Action/async lane semantics on store properties: a write held by an action makes isPending true for that property (per-property, not whole-store) while showing the committed value; applies on settle.\*… | -| CS-R34 | live | `docs/rules-mining/core-store.md:151` | store.ts×1 | visibility-oracle-store.test.ts×1 | ~~Optimistic writes visible immediately at write time (before flush)~~, never touch base raw; ambient (non-action) optimistic writes auto-revert at flush end.\*\* \_Visibility superseded 2026-09-10 by A2… | -| CS-R35 | live | `docs/rules-mining/core-store.md:156` | — | — | Mid-refetch optimistic overlays are consumed when data lands — identical via direct reads, mapArray, wrapper views, Object.keys, snapshot.\*\* | -| CS-R36 | live | `docs/rules-mining/core-store.md:160` | — | — | An active optimistic hold on a wrapper view masks inner-store changes for the view's subscribers: mid-hold inner refresh landing causes ZERO re-runs of the view's structural subscribers; the reveal re… | -| CS-R37 | live | `docs/rules-mining/core-store.md:165` | — | visibility-oracle-store.test.ts×1 | Setting store state from effect callbacks and promise resolutions works, applying next flush.\*\* | -| CS-R38 | live | `docs/rules-mining/core-store.md:171` | — | — | Shallow stores: root keys reactive (per-key nodes, membership, length), values served raw by identity at every depth, arrays and objects.\*\* | -| CS-R39 | live | `docs/rules-mining/core-store.md:175` | — | visibility-oracle-store.test.ts×1 | Shallow setter-scope reads serve raws; in-place mutation of a served raw is reactively inert — records replaced, never edited.\*\* | -| CS-R40 | live | `docs/rules-mining/core-store.md:179` | — | — | Shallow reconcile is positional: per-index effects only where the reference changed; reference-identical rows skip entirely; length propagates; `key` option moot.\*\* Aligned: unowned-reference skip rul… | -| CS-R41 | live | `docs/rules-mining/core-store.md:183` | reconcile.ts×1 store.ts×2 | — | A plain record replaced into a shallow store is STICKY raw-marked: presents raw in this store AND in any deep store that later ingests it.\*\* | -| CS-R42 | live | `docs/rules-mining/core-store.md:188` | reconcile.ts×1 store.ts×1 | — | markRaw values never wrap through ANY store (deep included); leaves for reconcile (reference replacement, no recursion).\*\* | -| CS-R43 | live | `docs/rules-mining/core-store.md:192` | — | — | Store proxies are exempt from shallow raw treatment: shallow store ingesting another store's proxy passes it through unmarked and serves a live wrapped view (upstream visible, downstream isolated), se… | -| CS-R44 | live | `docs/rules-mining/core-store.md:196` | store.ts×1 | — | Ingesting an already-deep-tracked raw into a shallow store throws in dev.\*\* | -| CS-R45 | live | `docs/rules-mining/core-store.md:201` | — | — | A shallow store nested in a deep store participates in the parent's reconcile (raw replacement, per-index notify).\*\* | -| CS-R46 | live | `docs/rules-mining/core-store.md:205` | — | — | Shallow projections work end-to-end (derive re-runs, output reconciles at boundary, rows stay raw).\*\* | -| CS-R47 | live | `docs/rules-mining/core-store.md:211` | — | — | Platform objects (Map, Set, Date, Node instances, subclasses) are structurally non-wrappable: served raw by identity; internal-slot methods work on read and draft paths; draft mutations land on the ra… | -| CS-R48 | live | `docs/rules-mining/core-store.md:216` | — | — | User class instances (custom prototypes) DO wrap: prototype getters track; methods on the draft receive the proxy as `this` (reactive writes).\*\* | -| CS-R49 | live | `docs/rules-mining/core-store.md:220` | — | — | Null-prototype objects wrap and track; function-valued props callable through the proxy.\*\* | -| CS-R50 | live | `docs/rules-mining/core-store.md:224` | — | — | Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).\*\* | -| CS-R51 | live | `docs/rules-mining/core-store.md:229` | store.ts×3 | overlay.test.ts×1 write-floor.test.ts×2 | Proxy-invariant compliance via target indirection: keys/spread/descriptor reads never throw regardless of source rigidity; source-non-configurable prop readable, writable through the store, reported `… | -| CS-R52 | live | `docs/rules-mining/core-store.md:233` | — | — | Symbol-keyed properties first-class: read/write/descriptors/preserved through root replacement + storePath root merge; on arrays symbol writes are metadata (never affect length).\*\* | -| CS-R53 | live | `docs/rules-mining/core-store.md:237` | — | — | Array key hygiene: non-index string keys never affect length; `s[len] = undefined` grows length AND creates a present key.\*\* | -| CS-R54 | live | `docs/rules-mining/core-store.md:241` | — | — | Array natives work through the proxy on read (filter/reduce/map/iterate) and draft (push/pop/shift) paths.\*\* | -| CS-R55 | live | `docs/rules-mining/core-store.md:243` | — | — | Functions stored as values served raw, replaceable, slot-tracked.\*\* | -| CS-R56 | live | `docs/rules-mining/core-store.md:247` | — | — | Multiple setter calls before one flush coalesce: even a deep-reading (structural clone) effect re-runs exactly once per flush.\*\* | -| CS-R57 | live | `docs/rules-mining/core-store.md:251` | — | — | Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.\*\* | -| CS-R58 | live | `docs/rules-mining/core-store.md:255` | — | — | Mid-flush read coherence: untracked store reads inside internal machinery running WITHIN a flush (mapArray keyed:false under a Root owner) must observe the value being written in that flush, not stale… | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| CS-R1 | live | `docs/rules-mining/core-store.md:9` | — | flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.test.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** | +| CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** | +| CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | reconcile.ts×1 store.ts×1 | — | Corollary R2a (Ryan, 2026-08-17): **take no responsibility for mutation | +| CS-R3 | live | `docs/rules-mining/core-store.md:18` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.** | +| CS-R4 | live | `docs/rules-mining/core-store.md:22` | — | — | Write isolation across a store chain: writing through the last store in a derived chain is visible only there; upstream stores and base objects untouched (shallow or deep middle).** | +| CS-R5 | live | `docs/rules-mining/core-store.md:26` | — | visibility-oracle-store.test.ts×4 visibility-oracle.states.ts×4 | Upstream writes propagate downstream through the chain without re-running structural machinery.** | +| CS-R6 | live | `docs/rules-mining/core-store.md:30` | — | — | No store write path ever mutates a user-provided source object.** Aligned with 2026-08-16b. | +| CS-R7 | live | `docs/rules-mining/core-store.md:34` | — | — | Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).** | +| CS-R8 | live | `docs/rules-mining/core-store.md:38` | — | — | `snapshot` returns fully unwrapped values (no proxy anywhere, `$TARGET` undefined), incl. frozen objects/arrays; reflects committed written values incl. writes over inherited prototype props.** | +| CS-R9 | live | `docs/rules-mining/core-store.md:42` | store.ts×2 target.ts×1 | — | Proxy identity per logical slot is stable across writes and reconciles** (mapArray keyed flows reuse rows across refetch/reconcile). | +| CS-R10 | live | `docs/rules-mining/core-store.md:48` | — | next-smoke.test.ts×1 | Per-property tracking; same-value writes (direct or functional path setter returning prev) do not re-trigger.** | +| CS-R11 | live | `docs/rules-mining/core-store.md:52` | — | visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×2 | Per-path tracking: reading `state.user.firstName` subscribes to that leaf; reading the reference `store[0]` does not subscribe to `store[0].i`.** | +| CS-R12 | live | `docs/rules-mining/core-store.md:56` | store.ts×1 | — | Reading an absent key subscribes to that key: other-key changes don't trigger; defining it later (assignment or defineProperty) does.** | +| CS-R13 | live | `docs/rules-mining/core-store.md:60` | target.ts×1 | — | `in` tracks presence, not value: undefined-write doesn't retrigger; delete does; adding absent key does. `in`/`has` never invokes source getters.** | +| CS-R14 | live | `docs/rules-mining/core-store.md:64` | — | — | `Object.keys` / `for…in` subscribe to key-set membership (root and nested) — distinct from property nodes.** Aligned: key-set node. | +| CS-R15 | live | `docs/rules-mining/core-store.md:68` | store.ts×1 | — | Array structural tracking is uniform across idioms: indexed length loop, `for…of`, mapArray ($TRACK) all re-run exactly once per flush on add/update/removal.** | +| CS-R16 | live | `docs/rules-mining/core-store.md:72` | — | — | `length` independently trackable; index write extending the array notifies length subscribers.** | +| CS-R17 | live | `docs/rules-mining/core-store.md:76` | — | — | Truncating via `length = N` notifies tracked index reads of removed slots (re-run, observe undefined) and clears has/index/keys for removed indices.** | +| CS-R18 | live | `docs/rules-mining/core-store.md:80` | — | — | `snapshot` is non-tracking.** Aligned with read table. | +| CS-R19 | live | `docs/rules-mining/core-store.md:84` | — | — | `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.** | +| CS-R20 | live | `docs/rules-mining/core-store.md:88` | store.ts×1 | — | Source getters (own, prototype, merge-installed) execute with the proxy as receiver, so their internal reads track — incl. through projections.** | +| CS-R21 | live | `docs/rules-mining/core-store.md:92` | store.ts×3 | deep-chained-view.test.ts×1 | Structural subscriptions through a wrapper view (store-in-store) chain to the wrapped source: $TRACK/mapArray, ownKeys, snapshot/trackSelf through an outer derived store re-run when the inner store re… | +| CS-R22 | live | `docs/rules-mining/core-store.md:97` | — | — | Slots holding non-wrappable values (markRaw, Map/Date, function) track by reference: reassignment notifies; internal mutation doesn't.** | +| CS-R23 | live | `docs/rules-mining/core-store.md:103` | store.ts×1 | — | The proxy is immutable from outside the setter: direct assignment and delete are silently ignored (no change, no notify, no TypeError — traps report success while discarding).** | +| CS-R24 | live | `docs/rules-mining/core-store.md:107` | — | next-smoke.test.ts×1 | Writes batch like signals: inside the setter draft, reads are read-your-writes (values, length, `in` sync); outside the setter, ALL reads — value, `in`, length — return pre-write state until flush(). … | +| CS-R25 | live | `docs/rules-mining/core-store.md:112` | — | next-smoke.test.ts×1 | Writes to properties with ZERO observers still batch (no effects anywhere; pre-write value visible between setState and flush).** | +| CS-R26 | live | `docs/rules-mining/core-store.md:117` | — | — | Setting a key to undefined is not deletion: key stays present (`in` true, no key-set notify); only delete / storePath.DELETE removes.** | +| CS-R27 | live | `docs/rules-mining/core-store.md:121` | store.ts×1 | — | The setter may return a replacement value that swaps the root wholesale; symbol keys on the replacement preserved.** | +| CS-R28 | live | `docs/rules-mining/core-store.md:125` | — | — | `storePath` addressing: string keys, numeric indices, index arrays, predicate filters ((value, index)), ranges, trailing functional setters address and update intended paths + trigger per-path subscri… | +| CS-R29 | live | `docs/rules-mining/core-store.md:129` | store.ts×2 | overlay.test.ts×1 | Merge/replacement preserve accessor descriptors and keep getters LIVE (re-evaluated per read, reactive reads track), for pre-existing and new keys.** | +| CS-R30 | live | `docs/rules-mining/core-store.md:134` | store.ts×2 | — | Prototype pollution fully guarded: `__proto__` assignment inert; reading `constructor` on the draft returns undefined; storePath refuses `__proto__`/`constructor`/`prototype` segments; skips unsafe ow… | +| CS-R31 | live | `docs/rules-mining/core-store.md:138` | projection.ts×1 | reconcile-resend-identity.test.ts×1 | Derived-store manual writes win over the recompute for the tick: manual setStore beats a queued recompute in the same flush; a SAME-VALUE manual write still holds against the recompute for that tick; … | +| CS-R32 | live | `docs/rules-mining/core-store.md:143` | store.ts×1 | visibility-oracle-store.test.ts×1 | A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.** Aligned: O7's resolution (a test already exists). | +| CS-R33 | live | `docs/rules-mining/core-store.md:147` | — | visibility-oracle-store.test.ts×2 | Action/async lane semantics on store properties: a write held by an action makes isPending true for that property (per-property, not whole-store) while showing the committed value; applies on settle.*… | +| CS-R34 | live | `docs/rules-mining/core-store.md:151` | store.ts×1 | visibility-oracle-store.test.ts×1 | ~~Optimistic writes visible immediately at write time (before flush)~~, never touch base raw; ambient (non-action) optimistic writes auto-revert at flush end.** _Visibility superseded 2026-09-10 by A2… | +| CS-R35 | live | `docs/rules-mining/core-store.md:156` | — | — | Mid-refetch optimistic overlays are consumed when data lands — identical via direct reads, mapArray, wrapper views, Object.keys, snapshot.** | +| CS-R36 | live | `docs/rules-mining/core-store.md:160` | — | — | An active optimistic hold on a wrapper view masks inner-store changes for the view's subscribers: mid-hold inner refresh landing causes ZERO re-runs of the view's structural subscribers; the reveal re… | +| CS-R37 | live | `docs/rules-mining/core-store.md:165` | — | visibility-oracle-store.test.ts×1 | Setting store state from effect callbacks and promise resolutions works, applying next flush.** | +| CS-R38 | live | `docs/rules-mining/core-store.md:171` | — | — | Shallow stores: root keys reactive (per-key nodes, membership, length), values served raw by identity at every depth, arrays and objects.** | +| CS-R39 | live | `docs/rules-mining/core-store.md:175` | — | visibility-oracle-store.test.ts×1 | Shallow setter-scope reads serve raws; in-place mutation of a served raw is reactively inert — records replaced, never edited.** | +| CS-R40 | live | `docs/rules-mining/core-store.md:179` | — | — | Shallow reconcile is positional: per-index effects only where the reference changed; reference-identical rows skip entirely; length propagates; `key` option moot.** Aligned: unowned-reference skip rul… | +| CS-R41 | live | `docs/rules-mining/core-store.md:183` | reconcile.ts×1 store.ts×2 | — | A plain record replaced into a shallow store is STICKY raw-marked: presents raw in this store AND in any deep store that later ingests it.** | +| CS-R42 | live | `docs/rules-mining/core-store.md:188` | reconcile.ts×1 store.ts×1 | — | markRaw values never wrap through ANY store (deep included); leaves for reconcile (reference replacement, no recursion).** | +| CS-R43 | live | `docs/rules-mining/core-store.md:192` | — | — | Store proxies are exempt from shallow raw treatment: shallow store ingesting another store's proxy passes it through unmarked and serves a live wrapped view (upstream visible, downstream isolated), se… | +| CS-R44 | live | `docs/rules-mining/core-store.md:196` | store.ts×1 | — | Ingesting an already-deep-tracked raw into a shallow store throws in dev.** | +| CS-R45 | live | `docs/rules-mining/core-store.md:201` | — | — | A shallow store nested in a deep store participates in the parent's reconcile (raw replacement, per-index notify).** | +| CS-R46 | live | `docs/rules-mining/core-store.md:205` | — | — | Shallow projections work end-to-end (derive re-runs, output reconciles at boundary, rows stay raw).** | +| CS-R47 | live | `docs/rules-mining/core-store.md:211` | — | — | Platform objects (Map, Set, Date, Node instances, subclasses) are structurally non-wrappable: served raw by identity; internal-slot methods work on read and draft paths; draft mutations land on the ra… | +| CS-R48 | live | `docs/rules-mining/core-store.md:216` | — | — | User class instances (custom prototypes) DO wrap: prototype getters track; methods on the draft receive the proxy as `this` (reactive writes).** | +| CS-R49 | live | `docs/rules-mining/core-store.md:220` | — | — | Null-prototype objects wrap and track; function-valued props callable through the proxy.** | +| CS-R50 | live | `docs/rules-mining/core-store.md:224` | — | — | Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).** | +| CS-R51 | live | `docs/rules-mining/core-store.md:229` | store.ts×3 | overlay.test.ts×1 write-floor.test.ts×2 | Proxy-invariant compliance via target indirection: keys/spread/descriptor reads never throw regardless of source rigidity; source-non-configurable prop readable, writable through the store, reported `… | +| CS-R52 | live | `docs/rules-mining/core-store.md:233` | — | — | Symbol-keyed properties first-class: read/write/descriptors/preserved through root replacement + storePath root merge; on arrays symbol writes are metadata (never affect length).** | +| CS-R53 | live | `docs/rules-mining/core-store.md:237` | — | — | Array key hygiene: non-index string keys never affect length; `s[len] = undefined` grows length AND creates a present key.** | +| CS-R54 | live | `docs/rules-mining/core-store.md:241` | — | — | Array natives work through the proxy on read (filter/reduce/map/iterate) and draft (push/pop/shift) paths.** | +| CS-R55 | live | `docs/rules-mining/core-store.md:243` | — | — | Functions stored as values served raw, replaceable, slot-tracked.** | +| CS-R56 | live | `docs/rules-mining/core-store.md:247` | — | — | Multiple setter calls before one flush coalesce: even a deep-reading (structural clone) effect re-runs exactly once per flush.** | +| CS-R57 | live | `docs/rules-mining/core-store.md:251` | — | — | Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.** | +| CS-R58 | live | `docs/rules-mining/core-store.md:255` | — | — | Mid-flush read coherence: untracked store reads inside internal machinery running WITHIN a flush (mapArray keyed:false under a Root owner) must observe the value being written in that flush, not stale… | ## R — optimistic-lanes (`OL-R`) -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ---------- | ------------------------------------------- | ------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OL-R1 | live | `docs/rules-mining/optimistic-lanes.md:11` | — | — | `createOptimistic(value \| fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. | -| OL-R2 | superseded | `docs/rules-mining/optimistic-lanes.md:15` | — | — | ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYN… | -| OL-R3 | live | `docs/rules-mining/optimistic-lanes.md:19` | — | — | The setter's updater receives the current _visible_ (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition'… | -| OL-R4 | live | `docs/rules-mining/optimistic-lanes.md:23` | — | — | Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). | -| OL-R5 | live | `docs/rules-mining/optimistic-lanes.md:27` | — | — | An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `f… | -| OL-R6 | live | `docs/rules-mining/optimistic-lanes.md:32` | — | — | An optimistic write inside an `action` holds for the entire action window and reverts when the action's transition completes; each intermediate write during a multi-yield action is observable in order… | -| OL-R7 | live | `docs/rules-mining/optimistic-lanes.md:36` | — | — | Computed-form `createOptimistic(fn)` with no overrides is a transparent passthrough of its (possibly async) source: promise resolutions, re-fired promises, and async-iterable yields all propagate; ove… | -| OL-R8 | live | `docs/rules-mining/optimistic-lanes.md:40` | — | — | Reset-on-settle targets the source's **newly computed value at settle time**, not the pre-write value: a wrong optimistic guess is auto-corrected to the real result; a correct guess settles silently (… | -| OL-R9 | live | `docs/rules-mining/optimistic-lanes.md:44` | — | — | Regular signals written in the same action are held (transition semantics) while optimistic writes display immediately; downstream memos and chained optimistic computeds see optimistic values and reve… | -| OL-R10 | live | `docs/rules-mining/optimistic-lanes.md:48` | — | — | `refresh()` of an optimistic accessor inside an action clears the override when the refetch settles; calling `refresh()` while the upstream source is still pending must not throw. | -| OL-R11 | live | `docs/rules-mining/optimistic-lanes.md:52` | — | — | Verdict channels: an optimistic override **is the value** on every channel — plain read and `latest()` both return it (including literal `undefined`); the override itself is **verdict-inert** — it nev… | -| OL-R12 | live | `docs/rules-mining/optimistic-lanes.md:56` | — | — | A bare `refresh()` is a quiet re-ask — never pending; a **declared** reload (`affects(x)` + `refresh(x)` inside an action) pends the slot for the whole reload window, even when the sole consumer is a … | -| OL-R13 | live | `docs/rules-mining/optimistic-lanes.md:60` | — | — | During the pending window, a source recompute that reveals a value **different** from the current override corrects the override in place (before the action settles), triggering downstream refetch; a … | -| OL-R14 | live | `docs/rules-mining/optimistic-lanes.md:67` | — | — | Independent optimistic writes to unrelated signals form independent lanes: notifications scoped to each signal's own subscribers; each action's overrides revert when _that_ action settles, regardless … | -| OL-R15 | live | `docs/rules-mining/optimistic-lanes.md:71` | — | — | A shared subscriber reading multiple optimistic sources merges lanes **for scheduling only**; it must not transfer transaction ownership of overrides. Disjoint-key work settles with its owning action … | -| OL-R16 | live | `docs/rules-mining/optimistic-lanes.md:76` | — | — | Same-key writes from multiple actions **entangle** those actions: the override (and transitively every override of the entangled actions) reverts only when the **last** entangled action settles. | -| OL-R17 | live | `docs/rules-mining/optimistic-lanes.md:81` | — | — | An equal-value write still registers ownership and still performs lane bookkeeping: a second action writing the same value keeps the override alive after the first settles; an override write whose val… | -| OL-R18 | live | `docs/rules-mining/optimistic-lanes.md:86` | — | — | All optimistic writes in one action share one transaction and revert together atomically; lanes/transactions clean up fully between cycles — the Nth cycle behaves exactly like the first, including aft… | -| OL-R19 | live | `docs/rules-mining/optimistic-lanes.md:90` | — | — | A shared **upstream** async resolving must not merge distinct downstream optimistic lanes — independent paths keep updating independently; genuine merge happens only at convergence points (a memo read… | -| OL-R20 | live | `docs/rules-mining/optimistic-lanes.md:94` | — | — | A later action's override wins over an earlier action's background settle: when action 1's refresh resolves _under_ action 2's live override, the visible value is unchanged, downstream must not recomp… | -| OL-R21 | live | `docs/rules-mining/optimistic-lanes.md:100` | — | — | An optimistic write of literal `undefined` is a full-fledged override: visible on plain read and `latest()`, verdict-inert on `isPending`, and it reverts at settle exactly like any other value. | -| OL-R22 | live | `docs/rules-mining/optimistic-lanes.md:105` | — | — | A follow-up optimistic write after an `undefined` override still rides the optimistic path and reverts at settle — `undefined` in the slot must never erase the node's optimistic identity or route late… | -| OL-R23 | live | `docs/rules-mining/optimistic-lanes.md:109` | — | — | Store form distinguishes "override to undefined" from "delete": optimistic set-to-undefined reads `undefined` with the key still present; optimistic `delete` reads `undefined` **and** `"key" in store … | -| OL-R24 | live | `docs/rules-mining/optimistic-lanes.md:116` | — | — | A transition completes only when **all** reachable asyncs (upstream source and downstream lane asyncs) resolve; held source values must never leak to subscribers before completion, even when the upstr… | -| OL-R25 | live | `docs/rules-mining/optimistic-lanes.md:120` | — | — | Lane readiness gating: subscribers reached _through a downstream async memo_ fire with optimistic values only once that async resolves; direct reads show the override immediately. The lane may flush \*… | -| OL-R26 | live | `docs/rules-mining/optimistic-lanes.md:124` | — | — | At settle, the commit of held transition writes and the revert of optimistic overrides are delivered **atomically**: one subscriber run observing both, never a torn intermediate. | -| OL-R27 | live | `docs/rules-mining/optimistic-lanes.md:128` | — | — | Rapid successive user writes replay correctly: the latest override wins; earlier lane flushes deliver the values current at their readiness time; final settled state reflects the last action's confirm… | -| OL-R28 | live | `docs/rules-mining/optimistic-lanes.md:134` | — | — | No-op settles are silent: if the optimistic write equals the current value, neither the write nor the revert notifies; if the settle-time computed value equals the override, no extra notification fire… | -| OL-R29 | live | `docs/rules-mining/optimistic-lanes.md:139` | — | — | Pre-flush writes coalesce: subscribers see only the latest override per flush (`[0, 2, 0]`, never intermediate `1`). | -| OL-R30 | live | `docs/rules-mining/optimistic-lanes.md:143` | — | — | Render-tier and user-tier effects must observe **identical value sequences** at every flush, including the mid-transition moment where an action finished but async reporters are still in flight. | -| OL-R31 | live | `docs/rules-mining/optimistic-lanes.md:147` | — | — | Optimistic lane notifications run even while an unrelated transition is stashed/pending; pending async in one lane never blocks another lane's write/revert notifications. | -| OL-R32 | live | `docs/rules-mining/optimistic-lanes.md:151` | — | — | `isPending` granularity: each async path's pending slot clears when its **own** async resolves; merged downstream nodes stay pending — emitting **no intermediate half-state values** — until all inputs… | -| OL-R33 | live | `docs/rules-mining/optimistic-lanes.md:155` | — | — | No pending flicker when the visible value is unchanged: background refresh phases with an unchanged visible override must not re-pend downstream; a genuinely new in-flight question must fire `isPendin… | -| OL-R34 | live | `docs/rules-mining/optimistic-lanes.md:159` | — | — | `latest()` readers opt into progressive per-path display while plain readers of merged memos wait for full resolution. | -| OL-R35 | live | `docs/rules-mining/optimistic-lanes.md:165` | — | — | `createOptimisticStore` returns `[proxy, setter]`; draft-style mutations inside an action are optimistic: immediately visible through the proxy, wholly reverted at settle. | -| OL-R36 | live | `docs/rules-mining/optimistic-lanes.md:169` | — | — | Array structural edits (e.g. filter-removal) are visible during the window through `length`, index reads, and iteration, and fully revert at settle. | -| OL-R37 | live | `docs/rules-mining/optimistic-lanes.md:174` | — | — | Store optimism is per-key: different keys written by different actions settle independently — same ownership rules as signals (R15/R16) at store-key granularity. | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| OL-R1 | live | `docs/rules-mining/optimistic-lanes.md:11` | — | — | `createOptimistic(value \| fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. | +| OL-R2 | superseded | `docs/rules-mining/optimistic-lanes.md:15` | — | — | ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYN… | +| OL-R3 | live | `docs/rules-mining/optimistic-lanes.md:19` | — | — | The setter's updater receives the current _visible_ (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition'… | +| OL-R4 | live | `docs/rules-mining/optimistic-lanes.md:23` | — | — | Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). | +| OL-R5 | live | `docs/rules-mining/optimistic-lanes.md:27` | — | — | An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `f… | +| OL-R6 | live | `docs/rules-mining/optimistic-lanes.md:32` | — | — | An optimistic write inside an `action` holds for the entire action window and reverts when the action's transition completes; each intermediate write during a multi-yield action is observable in order… | +| OL-R7 | live | `docs/rules-mining/optimistic-lanes.md:36` | — | — | Computed-form `createOptimistic(fn)` with no overrides is a transparent passthrough of its (possibly async) source: promise resolutions, re-fired promises, and async-iterable yields all propagate; ove… | +| OL-R8 | live | `docs/rules-mining/optimistic-lanes.md:40` | — | — | Reset-on-settle targets the source's **newly computed value at settle time**, not the pre-write value: a wrong optimistic guess is auto-corrected to the real result; a correct guess settles silently (… | +| OL-R9 | live | `docs/rules-mining/optimistic-lanes.md:44` | — | — | Regular signals written in the same action are held (transition semantics) while optimistic writes display immediately; downstream memos and chained optimistic computeds see optimistic values and reve… | +| OL-R10 | live | `docs/rules-mining/optimistic-lanes.md:48` | — | — | `refresh()` of an optimistic accessor inside an action clears the override when the refetch settles; calling `refresh()` while the upstream source is still pending must not throw. | +| OL-R11 | live | `docs/rules-mining/optimistic-lanes.md:52` | — | — | Verdict channels: an optimistic override **is the value** on every channel — plain read and `latest()` both return it (including literal `undefined`); the override itself is **verdict-inert** — it nev… | +| OL-R12 | live | `docs/rules-mining/optimistic-lanes.md:56` | — | — | A bare `refresh()` is a quiet re-ask — never pending; a **declared** reload (`affects(x)` + `refresh(x)` inside an action) pends the slot for the whole reload window, even when the sole consumer is a … | +| OL-R13 | live | `docs/rules-mining/optimistic-lanes.md:60` | — | — | During the pending window, a source recompute that reveals a value **different** from the current override corrects the override in place (before the action settles), triggering downstream refetch; a … | +| OL-R14 | live | `docs/rules-mining/optimistic-lanes.md:67` | — | — | Independent optimistic writes to unrelated signals form independent lanes: notifications scoped to each signal's own subscribers; each action's overrides revert when _that_ action settles, regardless … | +| OL-R15 | live | `docs/rules-mining/optimistic-lanes.md:71` | — | — | A shared subscriber reading multiple optimistic sources merges lanes **for scheduling only**; it must not transfer transaction ownership of overrides. Disjoint-key work settles with its owning action … | +| OL-R16 | live | `docs/rules-mining/optimistic-lanes.md:76` | — | — | Same-key writes from multiple actions **entangle** those actions: the override (and transitively every override of the entangled actions) reverts only when the **last** entangled action settles. | +| OL-R17 | live | `docs/rules-mining/optimistic-lanes.md:81` | — | — | An equal-value write still registers ownership and still performs lane bookkeeping: a second action writing the same value keeps the override alive after the first settles; an override write whose val… | +| OL-R18 | live | `docs/rules-mining/optimistic-lanes.md:86` | — | — | All optimistic writes in one action share one transaction and revert together atomically; lanes/transactions clean up fully between cycles — the Nth cycle behaves exactly like the first, including aft… | +| OL-R19 | live | `docs/rules-mining/optimistic-lanes.md:90` | — | — | A shared **upstream** async resolving must not merge distinct downstream optimistic lanes — independent paths keep updating independently; genuine merge happens only at convergence points (a memo read… | +| OL-R20 | live | `docs/rules-mining/optimistic-lanes.md:94` | — | — | A later action's override wins over an earlier action's background settle: when action 1's refresh resolves _under_ action 2's live override, the visible value is unchanged, downstream must not recomp… | +| OL-R21 | live | `docs/rules-mining/optimistic-lanes.md:100` | — | — | An optimistic write of literal `undefined` is a full-fledged override: visible on plain read and `latest()`, verdict-inert on `isPending`, and it reverts at settle exactly like any other value. | +| OL-R22 | live | `docs/rules-mining/optimistic-lanes.md:105` | — | — | A follow-up optimistic write after an `undefined` override still rides the optimistic path and reverts at settle — `undefined` in the slot must never erase the node's optimistic identity or route late… | +| OL-R23 | live | `docs/rules-mining/optimistic-lanes.md:109` | — | — | Store form distinguishes "override to undefined" from "delete": optimistic set-to-undefined reads `undefined` with the key still present; optimistic `delete` reads `undefined` **and** `"key" in store … | +| OL-R24 | live | `docs/rules-mining/optimistic-lanes.md:116` | — | — | A transition completes only when **all** reachable asyncs (upstream source and downstream lane asyncs) resolve; held source values must never leak to subscribers before completion, even when the upstr… | +| OL-R25 | live | `docs/rules-mining/optimistic-lanes.md:120` | — | — | Lane readiness gating: subscribers reached _through a downstream async memo_ fire with optimistic values only once that async resolves; direct reads show the override immediately. The lane may flush *… | +| OL-R26 | live | `docs/rules-mining/optimistic-lanes.md:124` | — | — | At settle, the commit of held transition writes and the revert of optimistic overrides are delivered **atomically**: one subscriber run observing both, never a torn intermediate. | +| OL-R27 | live | `docs/rules-mining/optimistic-lanes.md:128` | — | — | Rapid successive user writes replay correctly: the latest override wins; earlier lane flushes deliver the values current at their readiness time; final settled state reflects the last action's confirm… | +| OL-R28 | live | `docs/rules-mining/optimistic-lanes.md:134` | — | — | No-op settles are silent: if the optimistic write equals the current value, neither the write nor the revert notifies; if the settle-time computed value equals the override, no extra notification fire… | +| OL-R29 | live | `docs/rules-mining/optimistic-lanes.md:139` | — | — | Pre-flush writes coalesce: subscribers see only the latest override per flush (`[0, 2, 0]`, never intermediate `1`). | +| OL-R30 | live | `docs/rules-mining/optimistic-lanes.md:143` | — | — | Render-tier and user-tier effects must observe **identical value sequences** at every flush, including the mid-transition moment where an action finished but async reporters are still in flight. | +| OL-R31 | live | `docs/rules-mining/optimistic-lanes.md:147` | — | — | Optimistic lane notifications run even while an unrelated transition is stashed/pending; pending async in one lane never blocks another lane's write/revert notifications. | +| OL-R32 | live | `docs/rules-mining/optimistic-lanes.md:151` | — | — | `isPending` granularity: each async path's pending slot clears when its **own** async resolves; merged downstream nodes stay pending — emitting **no intermediate half-state values** — until all inputs… | +| OL-R33 | live | `docs/rules-mining/optimistic-lanes.md:155` | — | — | No pending flicker when the visible value is unchanged: background refresh phases with an unchanged visible override must not re-pend downstream; a genuinely new in-flight question must fire `isPendin… | +| OL-R34 | live | `docs/rules-mining/optimistic-lanes.md:159` | — | — | `latest()` readers opt into progressive per-path display while plain readers of merged memos wait for full resolution. | +| OL-R35 | live | `docs/rules-mining/optimistic-lanes.md:165` | — | — | `createOptimisticStore` returns `[proxy, setter]`; draft-style mutations inside an action are optimistic: immediately visible through the proxy, wholly reverted at settle. | +| OL-R36 | live | `docs/rules-mining/optimistic-lanes.md:169` | — | — | Array structural edits (e.g. filter-removal) are visible during the window through `length`, index reads, and iteration, and fully revert at settle. | +| OL-R37 | live | `docs/rules-mining/optimistic-lanes.md:174` | — | — | Store optimism is per-key: different keys written by different actions settle independently — same ownership rules as signals (R15/R16) at store-key granularity. | ## R — optimistic-store (`OS-R`) -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ---------- | ------------------------------------------- | --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OS-R1 | superseded | `docs/rules-mining/optimistic-store.md:9` | — | — | ~~Synchronous universal visibility.~~** ~~An optimistic write is visible to every reader immediately at write time, before any flush.~~ **Superseded 2026-09-10 by A28(5)\*\*: visible at the flush that c… | -| OS-R2 | live | `docs/rules-mining/optimistic-store.md:13` | — | — | Drafts compose on the live optimistic view.\*\* Each setter draft reads through all prior optimistic state (same tick, across ticks, across separate actions/refetches). | -| OS-R3 | live | `docs/rules-mining/optimistic-store.md:18` | — | — | Per-change notification.\*\* One notification per distinct optimistic value change; sequences like `[0, 1, 2, 0]` are contract. | -| OS-R4 | live | `docs/rules-mining/optimistic-store.md:22` | — | — | Equality cut.\*\* An optimistic write equal to current committed value: no notification on write or settle. | -| OS-R5 | live | `docs/rules-mining/optimistic-store.md:26` | — | — | Snapshot/deep read the optimistic view (resolves O1).\*\* snapshot()/deep() agree with every other reader: overlays, nested writes, optimistic deletes (key absent), array mutations; after settle show co… | -| OS-R6 | live | `docs/rules-mining/optimistic-store.md:31` | — | — | Snapshot allocates fresh objects while an overlay is live\*\* (not identity-stable across calls); settled returns raw identity. | -| OS-R7 | live | `docs/rules-mining/optimistic-store.md:36` | — | — | Propagation through derived graphs\*\* (memo chains, mapArray) like committed values. | -| OS-R8 | live | `docs/rules-mining/optimistic-store.md:38` | — | — | `latest()` returns the optimistic value\*\* during a pending refetch window. | -| OS-R9 | live | `docs/rules-mining/optimistic-store.md:40` | — | — | Cross-lane atomic flip.\*\* Regular store written in the same action holds old value while optimistic store shows overlay; at settle both land in ONE notification pass (mixed intermediates never observe… | -| OS-R10 | live | `docs/rules-mining/optimistic-store.md:46` | — | — | Settle reverts to base with one notification\*\* (`[0,1,0]`). | -| OS-R11 | live | `docs/rules-mining/optimistic-store.md:48` | — | — | Deep-state restoration.\*\* Revert restores complete pre-overlay state at every depth: nested writes, wholesale replacement, array length/indices/order, deletions (value + key membership). | -| OS-R12 | live | `docs/rules-mining/optimistic-store.md:50` | — | — | Revert target is the CURRENT derived base, not a stale snapshot\*\* (dependency changed mid-overlay → revert to recomputed value). | -| OS-R13 | live | `docs/rules-mining/optimistic-store.md:55` | — | — | Base data is not overlay data.\*\* Async-fetched/derived data commits to base and persists; only setter-originated optimistic state discards. | -| OS-R14 | live | `docs/rules-mining/optimistic-store.md:57` | — | — | No-flicker across the settle/refresh seam.\*\* From action-body return until refresh fetch lands, subscribers never observe the previously-committed value of an overridden property. | -| OS-R15 | live | `docs/rules-mining/optimistic-store.md:62` | — | — | Unaffected subscribers do not rerun on another action's settle.\*\* | -| OS-R16 | live | `docs/rules-mining/optimistic-store.md:64` | — | — | Cycles are independent\*\* (no residue between sequential write/settle cycles). | -| OS-R17 | live | `docs/rules-mining/optimistic-store.md:66` | — | — | Optimistic writes never pend.\*\* A plain optimistic store is never pending; an optimistic write alone never makes isPending true on any read (shallow, deep(), root or nested proxy, value or length, sam… | -| OS-R18 | live | `docs/rules-mining/optimistic-store.md:70` | — | — | Overlay lifetime is transaction-bound, per key\*\* (never a timer, never a mere flush boundary — under an action). | -| OS-R19 | live | `docs/rules-mining/optimistic-store.md:72` | — | — | Disjoint-key concurrent actions revert independently\*\* (incl. different rows, deletes) (#2899 ×3). | -| OS-R20 | live | `docs/rules-mining/optimistic-store.md:76` | — | — | Same-key writes entangle whole transactions:\*\* latest write displays; NOTHING in the merged transaction settles until the last member completes — including keys written by only one of them. | -| OS-R21 | live | `docs/rules-mining/optimistic-store.md:81` | — | — | Optimistic delete is per-transaction scoped\*\* (a concurrent action's settle must not resurrect another action's delete). | -| OS-R22 | live | `docs/rules-mining/optimistic-store.md:85` | — | — | Ambient (transaction-less) writes flash:\*\* visible until end of flush, then revert — without touching in-flight actions' keys. | -| OS-R23 | live | `docs/rules-mining/optimistic-store.md:89` | — | — | Actions scope globally (a transaction, not a store handle):\*\* writes made under action A belong to A regardless of which store; separate stores under separate actions settle independently. | -| OS-R24 | live | `docs/rules-mining/optimistic-store.md:91` | — | — | Re-override of a still-overridden key notifies and wins;\*\* the earlier action's completion never resurfaces its value. | -| OS-R25 | live | `docs/rules-mining/optimistic-store.md:95` | — | — | Array mutation overlays:\*\* push, splice, whole-array replacement, top-level array stores — length, index reads, holes, spread/iteration, .map all coherent mid-pending and restore exactly on revert. | -| OS-R26 | live | `docs/rules-mining/optimistic-store.md:97` | — | — | Length reactively consistent with contents;\*\* a consumer reading length then indices in one computation never observes a torn state. | -| OS-R27 | live | `docs/rules-mining/optimistic-store.md:101` | — | — | Key enumeration and `has` are lane-reactive\*\* (Object.keys / `in` reflect optimistic adds/deletes, notify, revert). | -| OS-R28 | live | `docs/rules-mining/optimistic-store.md:103` | — | — | Proxy identity survives truth adoption of optimistic rows:\*\* server data key-matching an optimistically pushed row recycles the proxy (identity preserved) and adopts server values. Single and multiple… | -| OS-R29 | live | `docs/rules-mining/optimistic-store.md:107` | — | — | Entity-swap key probes read committed base, not overlay\*\* (an optimistic `s.id = 99` must not confuse the swap); `key: null` → positional identity. | -| OS-R30 | live | `docs/rules-mining/optimistic-store.md:113` | store.ts×1 | — | Seed invisibility.\*\* Derived store's seed is a draft, never observable: before first resolution every read — get, `in`, keys, spread — throws NotReadyError untracked. Applies to createStore(fn, seed) … | -| OS-R31 | live | `docs/rules-mining/optimistic-store.md:117` | — | — | Dev strictRead scopes escalate:\*\* uninitialized read in a component body throws the `[PENDING_ASYNC_UNTRACKED_READ]` dev error (exact tag is contract), precedence over plain NotReadyError. | -| OS-R32 | live | `docs/rules-mining/optimistic-store.md:119` | — | — | Post-init untracked reads flow committed values,\*\* including during a later refetch window. | -| OS-R33 | live | `docs/rules-mining/optimistic-store.md:121` | — | — | Refetch window keeps the dev safeguard\*\* (committed value untracked; component-body read still dev-throws). | -| OS-R34 | live | `docs/rules-mining/optimistic-store.md:123` | — | — | isPending probes take the prod path in both builds:\*\* dev safeguard must not fire inside a probe; uninitialized + surrounding context ⇒ NotReadyError propagates out of isPending identically dev/prod; … | -| OS-R35 | live | `docs/rules-mining/optimistic-store.md:125` | — | — | Plain stores unaffected\*\* (read normally in every context incl. component bodies). | -| OS-R36 | live | `docs/rules-mining/optimistic-store.md:129` | — | — | Dependency-driven refetch pends the leaf and holds the committed view\*\* until the fetch lands. | -| OS-R37 | live | `docs/rules-mining/optimistic-store.md:131` | — | — | Optimistic writes are verdict-inert:\*\* a mid-refetch write displays but neither clears nor causes pending; the honest mixed state {value: 999, pending: true} is observable. (Re-ruled 2026-07-13, super… | -| OS-R38 | live | `docs/rules-mining/optimistic-store.md:133` | optimistic.ts×1 | — | No-op setters are fully inert:\*\* trap-firing no-ops (s => s, s => ({...s}), same-value write, delete of absent prop) mid-refetch display nothing, don't silence pending, don't entangle with the surroun… | -| OS-R39 | live | `docs/rules-mining/optimistic-store.md:137` | — | — | Landing truth wins over the override:\*\* fetch resolves → server/computed value displays, override consumed, pending clears — even if written mid-flight. | -| OS-R40 | live | `docs/rules-mining/optimistic-store.md:139` | — | — | Bare refresh is a quiet re-ask; affects + refresh is a declared reload.\*\* refresh(store) alone never pends reads; affects(store) + refresh pends them, clearing when data lands. Sync-back refresh insid… | -| OS-R41 | live | `docs/rules-mining/optimistic-store.md:141` | — | — | Streaming continuations are not pending windows.\*\* A generator-based derive (or wrapped createProjection) that yielded once reads settled while awaiting its next chunk, incl. with an override displaye… | -| OS-R42 | live | `docs/rules-mining/optimistic-store.md:143` | — | — | Bare writes ride an in-flight refetch (#2951).\*\* A transaction-less optimistic write while the store's own truth is in flight does NOT revert at flush end; holds until truth lands. Order-independent w… | -| OS-R43 | live | `docs/rules-mining/optimistic-store.md:147` | — | — | Refresh-in-action landings preserve still-pending overlays\*\* (same key ⇒ merged transaction: landing does not consume the pending action's optimistic value). | -| OS-R44 | live | `docs/rules-mining/optimistic-store.md:149` | — | — | Bare-refresh landings consume key-matched overlay content\*\* (optimistic "Optimistic" → server "Saved"); the action's later settle does not revert it. | -| OS-R45 | live | `docs/rules-mining/optimistic-store.md:151` | — | — | Separate-transition landings clear foreign optimistic rows (#2719):\*\* a different source transition resolving fresh data clears optimistic rows of a still-pending unrelated action immediately; later s… | -| OS-R46 | live | `docs/rules-mining/optimistic-store.md:155` | — | — | Refetch persistence across multi-action windows:\*\* overlay survives arbitrary interleaved refresh landings while any overlapping action is pending. | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| OS-R1 | superseded | `docs/rules-mining/optimistic-store.md:9` | — | — | ~~Synchronous universal visibility.~~** ~~An optimistic write is visible to every reader immediately at write time, before any flush.~~ **Superseded 2026-09-10 by A28(5)**: visible at the flush that c… | +| OS-R2 | live | `docs/rules-mining/optimistic-store.md:13` | — | — | Drafts compose on the live optimistic view.** Each setter draft reads through all prior optimistic state (same tick, across ticks, across separate actions/refetches). | +| OS-R3 | live | `docs/rules-mining/optimistic-store.md:18` | — | — | Per-change notification.** One notification per distinct optimistic value change; sequences like `[0, 1, 2, 0]` are contract. | +| OS-R4 | live | `docs/rules-mining/optimistic-store.md:22` | — | — | Equality cut.** An optimistic write equal to current committed value: no notification on write or settle. | +| OS-R5 | live | `docs/rules-mining/optimistic-store.md:26` | — | — | Snapshot/deep read the optimistic view (resolves O1).** snapshot()/deep() agree with every other reader: overlays, nested writes, optimistic deletes (key absent), array mutations; after settle show co… | +| OS-R6 | live | `docs/rules-mining/optimistic-store.md:31` | — | — | Snapshot allocates fresh objects while an overlay is live** (not identity-stable across calls); settled returns raw identity. | +| OS-R7 | live | `docs/rules-mining/optimistic-store.md:36` | — | — | Propagation through derived graphs** (memo chains, mapArray) like committed values. | +| OS-R8 | live | `docs/rules-mining/optimistic-store.md:38` | — | — | `latest()` returns the optimistic value** during a pending refetch window. | +| OS-R9 | live | `docs/rules-mining/optimistic-store.md:40` | — | — | Cross-lane atomic flip.** Regular store written in the same action holds old value while optimistic store shows overlay; at settle both land in ONE notification pass (mixed intermediates never observe… | +| OS-R10 | live | `docs/rules-mining/optimistic-store.md:46` | — | — | Settle reverts to base with one notification** (`[0,1,0]`). | +| OS-R11 | live | `docs/rules-mining/optimistic-store.md:48` | — | — | Deep-state restoration.** Revert restores complete pre-overlay state at every depth: nested writes, wholesale replacement, array length/indices/order, deletions (value + key membership). | +| OS-R12 | live | `docs/rules-mining/optimistic-store.md:50` | — | — | Revert target is the CURRENT derived base, not a stale snapshot** (dependency changed mid-overlay → revert to recomputed value). | +| OS-R13 | live | `docs/rules-mining/optimistic-store.md:55` | — | — | Base data is not overlay data.** Async-fetched/derived data commits to base and persists; only setter-originated optimistic state discards. | +| OS-R14 | live | `docs/rules-mining/optimistic-store.md:57` | — | — | No-flicker across the settle/refresh seam.** From action-body return until refresh fetch lands, subscribers never observe the previously-committed value of an overridden property. | +| OS-R15 | live | `docs/rules-mining/optimistic-store.md:62` | — | — | Unaffected subscribers do not rerun on another action's settle.** | +| OS-R16 | live | `docs/rules-mining/optimistic-store.md:64` | — | — | Cycles are independent** (no residue between sequential write/settle cycles). | +| OS-R17 | live | `docs/rules-mining/optimistic-store.md:66` | — | — | Optimistic writes never pend.** A plain optimistic store is never pending; an optimistic write alone never makes isPending true on any read (shallow, deep(), root or nested proxy, value or length, sam… | +| OS-R18 | live | `docs/rules-mining/optimistic-store.md:70` | — | — | Overlay lifetime is transaction-bound, per key** (never a timer, never a mere flush boundary — under an action). | +| OS-R19 | live | `docs/rules-mining/optimistic-store.md:72` | — | — | Disjoint-key concurrent actions revert independently** (incl. different rows, deletes) (#2899 ×3). | +| OS-R20 | live | `docs/rules-mining/optimistic-store.md:76` | — | — | Same-key writes entangle whole transactions:** latest write displays; NOTHING in the merged transaction settles until the last member completes — including keys written by only one of them. | +| OS-R21 | live | `docs/rules-mining/optimistic-store.md:81` | — | — | Optimistic delete is per-transaction scoped** (a concurrent action's settle must not resurrect another action's delete). | +| OS-R22 | live | `docs/rules-mining/optimistic-store.md:85` | — | — | Ambient (transaction-less) writes flash:** visible until end of flush, then revert — without touching in-flight actions' keys. | +| OS-R23 | live | `docs/rules-mining/optimistic-store.md:89` | — | — | Actions scope globally (a transaction, not a store handle):** writes made under action A belong to A regardless of which store; separate stores under separate actions settle independently. | +| OS-R24 | live | `docs/rules-mining/optimistic-store.md:91` | — | — | Re-override of a still-overridden key notifies and wins;** the earlier action's completion never resurfaces its value. | +| OS-R25 | live | `docs/rules-mining/optimistic-store.md:95` | — | — | Array mutation overlays:** push, splice, whole-array replacement, top-level array stores — length, index reads, holes, spread/iteration, .map all coherent mid-pending and restore exactly on revert. | +| OS-R26 | live | `docs/rules-mining/optimistic-store.md:97` | — | — | Length reactively consistent with contents;** a consumer reading length then indices in one computation never observes a torn state. | +| OS-R27 | live | `docs/rules-mining/optimistic-store.md:101` | — | — | Key enumeration and `has` are lane-reactive** (Object.keys / `in` reflect optimistic adds/deletes, notify, revert). | +| OS-R28 | live | `docs/rules-mining/optimistic-store.md:103` | — | — | Proxy identity survives truth adoption of optimistic rows:** server data key-matching an optimistically pushed row recycles the proxy (identity preserved) and adopts server values. Single and multiple… | +| OS-R29 | live | `docs/rules-mining/optimistic-store.md:107` | — | — | Entity-swap key probes read committed base, not overlay** (an optimistic `s.id = 99` must not confuse the swap); `key: null` → positional identity. | +| OS-R30 | live | `docs/rules-mining/optimistic-store.md:113` | store.ts×1 | — | Seed invisibility.** Derived store's seed is a draft, never observable: before first resolution every read — get, `in`, keys, spread — throws NotReadyError untracked. Applies to createStore(fn, seed) … | +| OS-R31 | live | `docs/rules-mining/optimistic-store.md:117` | — | — | Dev strictRead scopes escalate:** uninitialized read in a component body throws the `[PENDING_ASYNC_UNTRACKED_READ]` dev error (exact tag is contract), precedence over plain NotReadyError. | +| OS-R32 | live | `docs/rules-mining/optimistic-store.md:119` | — | — | Post-init untracked reads flow committed values,** including during a later refetch window. | +| OS-R33 | live | `docs/rules-mining/optimistic-store.md:121` | — | — | Refetch window keeps the dev safeguard** (committed value untracked; component-body read still dev-throws). | +| OS-R34 | live | `docs/rules-mining/optimistic-store.md:123` | — | — | isPending probes take the prod path in both builds:** dev safeguard must not fire inside a probe; uninitialized + surrounding context ⇒ NotReadyError propagates out of isPending identically dev/prod; … | +| OS-R35 | live | `docs/rules-mining/optimistic-store.md:125` | — | — | Plain stores unaffected** (read normally in every context incl. component bodies). | +| OS-R36 | live | `docs/rules-mining/optimistic-store.md:129` | — | — | Dependency-driven refetch pends the leaf and holds the committed view** until the fetch lands. | +| OS-R37 | live | `docs/rules-mining/optimistic-store.md:131` | — | — | Optimistic writes are verdict-inert:** a mid-refetch write displays but neither clears nor causes pending; the honest mixed state {value: 999, pending: true} is observable. (Re-ruled 2026-07-13, super… | +| OS-R38 | live | `docs/rules-mining/optimistic-store.md:133` | optimistic.ts×1 | — | No-op setters are fully inert:** trap-firing no-ops (s => s, s => ({...s}), same-value write, delete of absent prop) mid-refetch display nothing, don't silence pending, don't entangle with the surroun… | +| OS-R39 | live | `docs/rules-mining/optimistic-store.md:137` | — | — | Landing truth wins over the override:** fetch resolves → server/computed value displays, override consumed, pending clears — even if written mid-flight. | +| OS-R40 | live | `docs/rules-mining/optimistic-store.md:139` | — | — | Bare refresh is a quiet re-ask; affects + refresh is a declared reload.** refresh(store) alone never pends reads; affects(store) + refresh pends them, clearing when data lands. Sync-back refresh insid… | +| OS-R41 | live | `docs/rules-mining/optimistic-store.md:141` | — | — | Streaming continuations are not pending windows.** A generator-based derive (or wrapped createProjection) that yielded once reads settled while awaiting its next chunk, incl. with an override displaye… | +| OS-R42 | live | `docs/rules-mining/optimistic-store.md:143` | — | — | Bare writes ride an in-flight refetch (#2951).** A transaction-less optimistic write while the store's own truth is in flight does NOT revert at flush end; holds until truth lands. Order-independent w… | +| OS-R43 | live | `docs/rules-mining/optimistic-store.md:147` | — | — | Refresh-in-action landings preserve still-pending overlays** (same key ⇒ merged transaction: landing does not consume the pending action's optimistic value). | +| OS-R44 | live | `docs/rules-mining/optimistic-store.md:149` | — | — | Bare-refresh landings consume key-matched overlay content** (optimistic "Optimistic" → server "Saved"); the action's later settle does not revert it. | +| OS-R45 | live | `docs/rules-mining/optimistic-store.md:151` | — | — | Separate-transition landings clear foreign optimistic rows (#2719):** a different source transition resolving fresh data clears optimistic rows of a still-pending unrelated action immediately; later s… | +| OS-R46 | live | `docs/rules-mining/optimistic-store.md:155` | — | — | Refetch persistence across multi-action windows:** overlay survives arbitrary interleaved refresh landings while any overlapping action is pending. | ## R — projections (`PJ-R`) -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ------ | -------------------------------------- | -------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PJ-R1 | live | `docs/rules-mining/projections.md:9` | — | — | The derive receives the projection's current state as a mutable draft that persists across runs\*\*; prior runs' writes are visible and editable later. | -| PJ-R2 | live | `docs/rules-mining/projections.md:12` | store.ts×1 | projection-absent-key-tracking.test.ts×1 | Draft reads inside the derive never register dependencies (no self-tracking)\*\*, including `has`/index probes from array methods (`findIndex`, `splice`) and inspection traps from `console.log`. | -| PJ-R3 | live | `docs/rules-mining/projections.md:15` | — | — | The derive runs eagerly at creation (before any read/subscriber) and re-runs on flush when tracked sources change, even with zero subscribers.\*\* | -| PJ-R4 | live | `docs/rules-mining/projections.md:19` | — | — | A returned value merges reconcile-style\*\*: changed paths notify, absent keys delete, unchanged paths keep value and identity. | -| PJ-R5 | live | `docs/rules-mining/projections.md:22` | reconcile.ts×2 | — | The projection's root proxy identity is stable for its lifetime\*\*: across entity swaps, shape changes, and root key mismatches (root key change merges in place, no throw). | -| PJ-R6 | live | `docs/rules-mining/projections.md:25` | reconcile.ts×1 | — | Keyed diff (default key `"id"`)\*\*: key-matched subtrees merge in place preserving child proxy identity, skipping notification for unchanged slots; key mismatch replaces the subtree with a fresh proxy. | -| PJ-R7 | live | `docs/rules-mining/projections.md:28` | reconcile.ts×2 | — | Key matching is hierarchically scoped\*\*: when the root entity's key changes, children are NOT merged across the entity change even if their own keys match. | -| PJ-R8 | live | `docs/rules-mining/projections.md:32` | — | — | `{ key: null }` merges positionally\*\* (proxy identity preserved regardless of key-field changes). | -| PJ-R9 | live | `docs/rules-mining/projections.md:34` | — | — | A proxy detached by an entity swap remains a coherent read view of its own (old) data\*\* — never dead, never reflecting the new entity. | -| PJ-R10 | live | `docs/rules-mining/projections.md:37` | reconcile.ts×1 | — | After a root swap, the outgoing raw stops resolving to the projection root\*\*; re-handed as nested data it wraps as a distinct proxy with its own values. | -| PJ-R11 | live | `docs/rules-mining/projections.md:41` | — | — | `reconcile()` on a plain store still throws on root key mismatch\*\*; the projection root's merge-in-place (R5) is a projection-specific relaxation. | -| PJ-R12 | live | `docs/rules-mining/projections.md:46` | — | — | Only subscribers of actually-changed properties rerun\*\*; equal-value rewrites and writes to unobserved keys notify nobody. | -| PJ-R13 | live | `docs/rules-mining/projections.md:49` | — | — | Deleting a key notifies its subscribers; subscribers of absent keys track and are notified on later creation.\*\* | -| PJ-R14 | live | `docs/rules-mining/projections.md:53` | — | — | Every subscriber of a changed property is notified exactly once per change.\*\* | -| PJ-R15 | live | `docs/rules-mining/projections.md:55` | — | — | Projections compose\*\* (projection reading another projection; downstream effects run once per upstream change with correct previous values). | -| PJ-R16 | live | `docs/rules-mining/projections.md:57` | — | — | `Object.keys` of a projection is tracked and notifies on key-set changes, including through a chained store backing.\*\* | -| PJ-R17 | live | `docs/rules-mining/projections.md:62` | — | — | A derive returning a live store proxy adopts it live\*\*: subsequent source-store writes flow through the projection without re-running the derive. | -| PJ-R18 | live | `docs/rules-mining/projections.md:66` | — | — | Fine-grained isolation preserved through the chain\*\*: a nested source-store write notifies only the projection subscribers of that nested path. | -| PJ-R19 | live | `docs/rules-mining/projections.md:68` | — | — | When the derive's return switches (store → plain → other store), subscribers see each new value and the previous chain is fully severed.\*\* | -| PJ-R20 | live | `docs/rules-mining/projections.md:70` | — | — | Chained backing works for array roots\*\* (structural + row-level edits flow). | -| PJ-R21 | live | `docs/rules-mining/projections.md:72` | — | — | `createStore(fn, seed)` is the same projection mechanism and chains identically.\*\* | -| PJ-R22 | live | `docs/rules-mining/projections.md:74` | — | — | `snapshot()` of a chained projection returns plain data equal to the current view and detached from future source writes.\*\* | -| PJ-R23 | live | `docs/rules-mining/projections.md:80` | store.ts×2 | — | The seed is a draft for the derive, never observable (#2897)\*\*: until first settle/yield, every read — tracked, untracked, enumeration/spread — throws NotReadyError. | -| PJ-R24 | live | `docs/rules-mining/projections.md:83` | — | — | Draft writes during an in-flight async run are invisible until that run settles\*\* (per-run atomic visibility). | -| PJ-R25 | live | `docs/rules-mining/projections.md:85` | — | — | Async generators publish one snapshot per yield\*\*: bare `yield` publishes accumulated draft mutations; `yield value` replaces the entire state (no merge); each yield transforms again. | -| PJ-R26 | live | `docs/rules-mining/projections.md:87` | — | — | Latest-run-wins supersession\*\*: superseded runs' later yields and pending draft writes are discarded entirely; if no run ever landed, stays NotReady. | -| PJ-R27 | live | `docs/rules-mining/projections.md:89` | — | — | Async recompute does not coarsen granularity\*\*: after settle, only changed-path subscribers rerun. | -| PJ-R28 | live | `docs/rules-mining/projections.md:91` | — | — | `refresh(proj)` forces a new derive run; bare refresh is quiet\*\* (no pending published; silent reveal). | -| PJ-R29 | live | `docs/rules-mining/projections.md:93` | — | — | `affects(proj)` + `refresh(proj)` is a declared reload\*\*: subscribed effects see isPending true + stale value for the window, then settle. | -| PJ-R30 | live | `docs/rules-mining/projections.md:95` | — | — | With no effect subscribed, async work creates no transition\*\* (isPending false throughout initial load). | -| PJ-R31 | live | `docs/rules-mining/projections.md:97` | — | — | With a subscribed effect, source-triggered async reruns are transitions\*\* (pending true + stale during window); initial no-stale-data load is never pending. | -| PJ-R32 | live | `docs/rules-mining/projections.md:99` | — | — | Reading a pending async source inside the derive propagates NotReady to consumers\*\* (Loading boundaries fall back); settle fires downstream effects exactly once with the settled value, never the seed … | -| PJ-R33 | live | `docs/rules-mining/projections.md:101` | — | — | Settlement is a status change, not a value diff\*\*: boundaries and blocked effects release even when the settled value equals the seed. | -| PJ-R34 | live | `docs/rules-mining/projections.md:103` | — | — | Errored derives follow async memo rules\*\*: after rejection ALL readers (settle-time, late tracked, untracked) throw the error (StatusError-wrapped; boundaries unwrap). Seed never served uninitialized;… | -| PJ-R35 | live | `docs/rules-mining/projections.md:106` | — | — | A genuine tracked read on a later cycle retries an errored derive\*\* (memo parity: never untracked, never inside isPending probe, at most once per cycle); successful retry serves fresh value. | -| PJ-R36 | live | `docs/rules-mining/projections.md:110` | — | — | Disposing the owning root stops the projection\*\* (no recomputes, no notifications afterward). | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| PJ-R1 | live | `docs/rules-mining/projections.md:9` | — | — | The derive receives the projection's current state as a mutable draft that persists across runs**; prior runs' writes are visible and editable later. | +| PJ-R2 | live | `docs/rules-mining/projections.md:12` | store.ts×1 | projection-absent-key-tracking.test.ts×1 | Draft reads inside the derive never register dependencies (no self-tracking)**, including `has`/index probes from array methods (`findIndex`, `splice`) and inspection traps from `console.log`. | +| PJ-R3 | live | `docs/rules-mining/projections.md:15` | — | — | The derive runs eagerly at creation (before any read/subscriber) and re-runs on flush when tracked sources change, even with zero subscribers.** | +| PJ-R4 | live | `docs/rules-mining/projections.md:19` | — | — | A returned value merges reconcile-style**: changed paths notify, absent keys delete, unchanged paths keep value and identity. | +| PJ-R5 | live | `docs/rules-mining/projections.md:22` | reconcile.ts×2 | — | The projection's root proxy identity is stable for its lifetime**: across entity swaps, shape changes, and root key mismatches (root key change merges in place, no throw). | +| PJ-R6 | live | `docs/rules-mining/projections.md:25` | reconcile.ts×1 | — | Keyed diff (default key `"id"`)**: key-matched subtrees merge in place preserving child proxy identity, skipping notification for unchanged slots; key mismatch replaces the subtree with a fresh proxy. | +| PJ-R7 | live | `docs/rules-mining/projections.md:28` | reconcile.ts×2 | — | Key matching is hierarchically scoped**: when the root entity's key changes, children are NOT merged across the entity change even if their own keys match. | +| PJ-R8 | live | `docs/rules-mining/projections.md:32` | — | — | `{ key: null }` merges positionally** (proxy identity preserved regardless of key-field changes). | +| PJ-R9 | live | `docs/rules-mining/projections.md:34` | — | — | A proxy detached by an entity swap remains a coherent read view of its own (old) data** — never dead, never reflecting the new entity. | +| PJ-R10 | live | `docs/rules-mining/projections.md:37` | reconcile.ts×1 | — | After a root swap, the outgoing raw stops resolving to the projection root**; re-handed as nested data it wraps as a distinct proxy with its own values. | +| PJ-R11 | live | `docs/rules-mining/projections.md:41` | — | — | `reconcile()` on a plain store still throws on root key mismatch**; the projection root's merge-in-place (R5) is a projection-specific relaxation. | +| PJ-R12 | live | `docs/rules-mining/projections.md:46` | — | — | Only subscribers of actually-changed properties rerun**; equal-value rewrites and writes to unobserved keys notify nobody. | +| PJ-R13 | live | `docs/rules-mining/projections.md:49` | — | — | Deleting a key notifies its subscribers; subscribers of absent keys track and are notified on later creation.** | +| PJ-R14 | live | `docs/rules-mining/projections.md:53` | — | — | Every subscriber of a changed property is notified exactly once per change.** | +| PJ-R15 | live | `docs/rules-mining/projections.md:55` | — | — | Projections compose** (projection reading another projection; downstream effects run once per upstream change with correct previous values). | +| PJ-R16 | live | `docs/rules-mining/projections.md:57` | — | — | `Object.keys` of a projection is tracked and notifies on key-set changes, including through a chained store backing.** | +| PJ-R17 | live | `docs/rules-mining/projections.md:62` | — | — | A derive returning a live store proxy adopts it live**: subsequent source-store writes flow through the projection without re-running the derive. | +| PJ-R18 | live | `docs/rules-mining/projections.md:66` | — | — | Fine-grained isolation preserved through the chain**: a nested source-store write notifies only the projection subscribers of that nested path. | +| PJ-R19 | live | `docs/rules-mining/projections.md:68` | — | — | When the derive's return switches (store → plain → other store), subscribers see each new value and the previous chain is fully severed.** | +| PJ-R20 | live | `docs/rules-mining/projections.md:70` | — | — | Chained backing works for array roots** (structural + row-level edits flow). | +| PJ-R21 | live | `docs/rules-mining/projections.md:72` | — | — | `createStore(fn, seed)` is the same projection mechanism and chains identically.** | +| PJ-R22 | live | `docs/rules-mining/projections.md:74` | — | — | `snapshot()` of a chained projection returns plain data equal to the current view and detached from future source writes.** | +| PJ-R23 | live | `docs/rules-mining/projections.md:80` | store.ts×2 | — | The seed is a draft for the derive, never observable (#2897)**: until first settle/yield, every read — tracked, untracked, enumeration/spread — throws NotReadyError. | +| PJ-R24 | live | `docs/rules-mining/projections.md:83` | — | — | Draft writes during an in-flight async run are invisible until that run settles** (per-run atomic visibility). | +| PJ-R25 | live | `docs/rules-mining/projections.md:85` | — | — | Async generators publish one snapshot per yield**: bare `yield` publishes accumulated draft mutations; `yield value` replaces the entire state (no merge); each yield transforms again. | +| PJ-R26 | live | `docs/rules-mining/projections.md:87` | — | — | Latest-run-wins supersession**: superseded runs' later yields and pending draft writes are discarded entirely; if no run ever landed, stays NotReady. | +| PJ-R27 | live | `docs/rules-mining/projections.md:89` | — | — | Async recompute does not coarsen granularity**: after settle, only changed-path subscribers rerun. | +| PJ-R28 | live | `docs/rules-mining/projections.md:91` | — | — | `refresh(proj)` forces a new derive run; bare refresh is quiet** (no pending published; silent reveal). | +| PJ-R29 | live | `docs/rules-mining/projections.md:93` | — | — | `affects(proj)` + `refresh(proj)` is a declared reload**: subscribed effects see isPending true + stale value for the window, then settle. | +| PJ-R30 | live | `docs/rules-mining/projections.md:95` | — | — | With no effect subscribed, async work creates no transition** (isPending false throughout initial load). | +| PJ-R31 | live | `docs/rules-mining/projections.md:97` | — | — | With a subscribed effect, source-triggered async reruns are transitions** (pending true + stale during window); initial no-stale-data load is never pending. | +| PJ-R32 | live | `docs/rules-mining/projections.md:99` | — | — | Reading a pending async source inside the derive propagates NotReady to consumers** (Loading boundaries fall back); settle fires downstream effects exactly once with the settled value, never the seed … | +| PJ-R33 | live | `docs/rules-mining/projections.md:101` | — | — | Settlement is a status change, not a value diff**: boundaries and blocked effects release even when the settled value equals the seed. | +| PJ-R34 | live | `docs/rules-mining/projections.md:103` | — | — | Errored derives follow async memo rules**: after rejection ALL readers (settle-time, late tracked, untracked) throw the error (StatusError-wrapped; boundaries unwrap). Seed never served uninitialized;… | +| PJ-R35 | live | `docs/rules-mining/projections.md:106` | — | — | A genuine tracked read on a later cycle retries an errored derive** (memo parity: never untracked, never inside isPending probe, at most once per cycle); successful retry serves fresh value. | +| PJ-R36 | live | `docs/rules-mining/projections.md:110` | — | — | Disposing the owning root stops the projection** (no recomputes, no notifications afterward). | ## R — reconcile-snapshot (`RS-R`) -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ------ | --------------------------------------------- | -------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| RS-R1 | live | `docs/rules-mining/reconcile-snapshot.md:9` | — | — | Keyed object merge deletes absent keys.\*\* Properties present in `next` update; properties absent from `next` are deleted (read `undefined`, removed from `in`/keys). | -| RS-R2 | live | `docs/rules-mining/reconcile-snapshot.md:12` | — | — | Reconcile applies to any nested proxy, not just the root\*\*, with identical semantics. | -| RS-R3 | live | `docs/rules-mining/reconcile-snapshot.md:15` | — | — | Keyed identity mismatch at the target throws\*\* (key differs, or key present on target but missing from `next`). Post-throw state is deliberately unasserted (original expectations commented out) — the … | -| RS-R4 | live | `docs/rules-mining/reconcile-snapshot.md:18` | — | — | `key: null` / `key: ""` disables key matching\*\*: positional merge, no root identity check. | -| RS-R5 | live | `docs/rules-mining/reconcile-snapshot.md:21` | — | — | Key modes: string key, key function, none.\*\* KeyFn's call set is observable (see R17). | -| RS-R6 | live | `docs/rules-mining/reconcile-snapshot.md:24` | — | — | Key-matched items preserve logical (proxy) identity across reorder, insert, delete.\*\* | -| RS-R7 | live | `docs/rules-mining/reconcile-snapshot.md:27` | — | — | Re-sent identical objects preserve raw identity: `snapshot(state.arr[i])` is `Object.is`-equal to the original.\*\* CONFLICT (benign, verify): adoption satisfies this since raw becomes the incoming obje… | -| RS-R8 | live | `docs/rules-mining/reconcile-snapshot.md:30` | reconcile.ts×1 | — | Positional merge preserves slot proxy identity even when identifying fields change\*\* (fixed-shape dashboard pattern). | -| RS-R9 | live | `docs/rules-mining/reconcile-snapshot.md:33` | reconcile.ts×2 | — | Only changed leaves notify\*\* (changed `a` reruns its subscriber exactly once; `b` subscriber zero times). | -| RS-R10 | live | `docs/rules-mining/reconcile-snapshot.md:36` | reconcile.ts×2 | — | Kind changes (object↔array) at any position replace wholesale, never merge, and notify the property node.\*\* | -| RS-R11 | live | `docs/rules-mining/reconcile-snapshot.md:39` | reconcile.ts×2 | — | Null entries and primitives are legal keyed-array members\*\* (#2772). | -| RS-R12 | live | `docs/rules-mining/reconcile-snapshot.md:42` | — | — | Array resize notification matrix.\*\* Shrink: tracked removed indices notify `undefined`; tracked `in` flips false; untracked reads agree with new length (no stale node values). Growth: tracked missing … | -| RS-R13 | live | `docs/rules-mining/reconcile-snapshot.md:45` | — | — | Numeric-coercible non-index string props on arrays (`"1e3"`, `"1.5"`) survive resize\*\*; node sync must be membership-based, not length-range-based. | -| RS-R14 | live | `docs/rules-mining/reconcile-snapshot.md:48` | — | — | Symbol keys have full parity with string keys under reconcile\*\* (update/remove/add/nested/mixed). | -| RS-R15 | live | `docs/rules-mining/reconcile-snapshot.md:51` | — | — | Reconcile can assign, swap, and reorder values that are other stores' proxies.\*\* CONFLICT (attention): adoption must handle `next` values that are live proxies of other stores — `storeLookup` resoluti… | -| RS-R16 | live | `docs/rules-mining/reconcile-snapshot.md:54` | reconcile.ts×1 | — | Captured proxies with a live subscriber anywhere below are diffed in place through never-tracked intermediate levels.\*\* CONFLICT (design obligation): a node exists deep below an un-noded path; adoptio… | -| RS-R17 | live | `docs/rules-mining/reconcile-snapshot.md:57` | reconcile.ts×1 | — | Never-subscribed subtrees are pruned: the diff does not walk below their top-level pair\*\* (observable via keyFn call set). | -| RS-R18 | live | `docs/rules-mining/reconcile-snapshot.md:60` | reconcile.ts×3 | — | Captured-but-unobserved proxies may detach and go stale after reconcile\*\* (pinned pruning contract); a key mismatch detaches even an observed captured proxy. | -| RS-R19 | live | `docs/rules-mining/reconcile-snapshot.md:63` | — | — | `deep()` observes a reconcile as a single notification carrying the final plain data.\*\* | -| RS-R20 | live | `docs/rules-mining/reconcile-snapshot.md:66` | — | — | (type-level) — `reconcile(next)` requires the complete store type.\*\* | -| RS-R21 | live | `docs/rules-mining/reconcile-snapshot.md:70` | — | — | A reconcile in the same batch after an unflushed setter write behaves identically to a clean reconcile.\*\* CONFLICT (framing only): tests motivate via `STORE_OVERRIDE`/`applyStateSlow` routing (deleted… | -| RS-R22 | live | `docs/rules-mining/reconcile-snapshot.md:73` | — | adoption-lane-rollback.test.ts×1 | Reconcile inside an optimistic action window is tentatively visible; captured-proxy readers see exactly what tracked readers see, during and after settle.\*\* CONFLICT (load-bearing): adoption must ride… | -| RS-R23 | live | `docs/rules-mining/reconcile-snapshot.md:78` | — | — | `snapshot()`/`deep()` always return plain non-proxy data\*\* — including rows through derived stores, nested objects in them, chained views. | -| RS-R24 | live | `docs/rules-mining/reconcile-snapshot.md:81` | — | — | CoW identity preservation:\*\* never-written store snapshots as the original source object (`===`); after a write, changed object + ancestors are new copies, unchanged siblings keep prior snapshot ident… | -| RS-R25 | live | `docs/rules-mining/reconcile-snapshot.md:84` | — | — | Snapshot through a derived-store view returns the same raw object as through the base store\*\* when nothing overridden. CONFLICT (attention): requires unwrapping chained proxy backings to base raw; the… | -| RS-R26 | live | `docs/rules-mining/reconcile-snapshot.md:87` | — | — | Snapshot reflects in-flight optimistic overrides while the base stays untouched.\*\* Confirms O1's "snapshot = current view, lane values included". | -| RS-R27 | live | `docs/rules-mining/reconcile-snapshot.md:90` | — | — | Snapshot sees pending (unflushed) setter writes synchronously, while untracked proxy reads return the previous value until flush.\*\* CONFLICT (MAJOR): §3's "urgent writes are synchronous commits" would… | -| RS-R28 | live | `docs/rules-mining/reconcile-snapshot.md:93` | reconcile.ts×1 | — | Array holes and length survive snapshot/deep\*\* (trailing delete keeps length; holes stay holes; explicit length truncation round-trips; overridden length 0 snapshots as `[]`). | -| RS-R29 | live | `docs/rules-mining/reconcile-snapshot.md:96` | store.ts×1 | — | Symbol-keyed data round-trips through snapshot\*\*: enumerable symbols preserved in copies; writes inside symbol subtrees captured; added-after-snapshot appear; deleted dropped; NON-enumerable symbols e… | -| RS-R30 | live | `docs/rules-mining/reconcile-snapshot.md:99` | — | — | Snapshot-scope machinery (setSnapshotCapture / markSnapshotScope / releaseSnapshotScope / clearSnapshots):\*\* signals/memos created during capture freeze creation-time value for scoped readers; writes … | -| RS-R31 | live | `docs/rules-mining/reconcile-snapshot.md:102` | — | — | Store properties written during capture preserve pre-write value for scoped readers; unwritten use current.\*\* CONFLICT: current mechanism (`STORE_SNAPSHOT_PROPS` in set trap) is layer-adjacent; the wr… | -| RS-R32 | live | `docs/rules-mining/reconcile-snapshot.md:105` | — | — | A pending async projection suppresses snapshot capture\*\*; after resolve + release, readers see resolved value. CONFLICT (mild): guard must move to lane-scoped adoption writes. | -| RS-R33 | live | `docs/rules-mining/reconcile-snapshot.md:110` | — | — | `merge` core contract:\*\* lazy getters (`this` = source); later sources win incl. explicit `undefined`; key union via `in`/keys; value props copied by value; non-enumerable → enumerable on result; firs… | -| RS-R34 | live | `docs/rules-mining/reconcile-snapshot.md:113` | — | — | `merge` reference-return optimization:\*\* same reference for single arg, trailing falsy args, and when last source's own keys cover the union; new proxy otherwise; holds for store proxies. | -| RS-R35 | live | `docs/rules-mining/reconcile-snapshot.md:115` | — | — | `merge` over signal-of-object source is reactive with minimal notifications.\*\* | -| RS-R36 | live | `docs/rules-mining/reconcile-snapshot.md:117` | — | — | `omit` contract:\*\* removed keys disappear from get/`in`/keys incl. store-proxy sources; kept value props copied; descriptors cloned faithfully; pollution-safe; composes with merge. | -| RS-R37 | live | `docs/rules-mining/reconcile-snapshot.md:119` | — | shared-child-multiparent.test.ts×1 | `deep()` contract:\*\* plain data; tracks entire reachable tree (leaf writes, push, branch replacement, symbol subtree writes, symbol add/delete, shared-object writes through other paths); one notificat… | -| RS-R38 | live | `docs/rules-mining/reconcile-snapshot.md:121` | — | — | Untracked read-through (via merge clone) shows pre-write values until flush.\*\* CONFLICT (same as R27, MAJOR): contradicts write-through-immediately unless plain setter writes stay staged until flush. … | - +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| RS-R1 | live | `docs/rules-mining/reconcile-snapshot.md:9` | — | — | Keyed object merge deletes absent keys.** Properties present in `next` update; properties absent from `next` are deleted (read `undefined`, removed from `in`/keys). | +| RS-R2 | live | `docs/rules-mining/reconcile-snapshot.md:12` | — | — | Reconcile applies to any nested proxy, not just the root**, with identical semantics. | +| RS-R3 | live | `docs/rules-mining/reconcile-snapshot.md:15` | — | — | Keyed identity mismatch at the target throws** (key differs, or key present on target but missing from `next`). Post-throw state is deliberately unasserted (original expectations commented out) — the … | +| RS-R4 | live | `docs/rules-mining/reconcile-snapshot.md:18` | — | — | `key: null` / `key: ""` disables key matching**: positional merge, no root identity check. | +| RS-R5 | live | `docs/rules-mining/reconcile-snapshot.md:21` | — | — | Key modes: string key, key function, none.** KeyFn's call set is observable (see R17). | +| RS-R6 | live | `docs/rules-mining/reconcile-snapshot.md:24` | — | — | Key-matched items preserve logical (proxy) identity across reorder, insert, delete.** | +| RS-R7 | live | `docs/rules-mining/reconcile-snapshot.md:27` | — | — | Re-sent identical objects preserve raw identity: `snapshot(state.arr[i])` is `Object.is`-equal to the original.** CONFLICT (benign, verify): adoption satisfies this since raw becomes the incoming obje… | +| RS-R8 | live | `docs/rules-mining/reconcile-snapshot.md:30` | reconcile.ts×1 | — | Positional merge preserves slot proxy identity even when identifying fields change** (fixed-shape dashboard pattern). | +| RS-R9 | live | `docs/rules-mining/reconcile-snapshot.md:33` | reconcile.ts×2 | — | Only changed leaves notify** (changed `a` reruns its subscriber exactly once; `b` subscriber zero times). | +| RS-R10 | live | `docs/rules-mining/reconcile-snapshot.md:36` | reconcile.ts×2 | — | Kind changes (object↔array) at any position replace wholesale, never merge, and notify the property node.** | +| RS-R11 | live | `docs/rules-mining/reconcile-snapshot.md:39` | reconcile.ts×2 | — | Null entries and primitives are legal keyed-array members** (#2772). | +| RS-R12 | live | `docs/rules-mining/reconcile-snapshot.md:42` | — | — | Array resize notification matrix.** Shrink: tracked removed indices notify `undefined`; tracked `in` flips false; untracked reads agree with new length (no stale node values). Growth: tracked missing … | +| RS-R13 | live | `docs/rules-mining/reconcile-snapshot.md:45` | — | — | Numeric-coercible non-index string props on arrays (`"1e3"`, `"1.5"`) survive resize**; node sync must be membership-based, not length-range-based. | +| RS-R14 | live | `docs/rules-mining/reconcile-snapshot.md:48` | — | — | Symbol keys have full parity with string keys under reconcile** (update/remove/add/nested/mixed). | +| RS-R15 | live | `docs/rules-mining/reconcile-snapshot.md:51` | — | — | Reconcile can assign, swap, and reorder values that are other stores' proxies.** CONFLICT (attention): adoption must handle `next` values that are live proxies of other stores — `storeLookup` resoluti… | +| RS-R16 | live | `docs/rules-mining/reconcile-snapshot.md:54` | reconcile.ts×1 | — | Captured proxies with a live subscriber anywhere below are diffed in place through never-tracked intermediate levels.** CONFLICT (design obligation): a node exists deep below an un-noded path; adoptio… | +| RS-R17 | live | `docs/rules-mining/reconcile-snapshot.md:57` | reconcile.ts×1 | — | Never-subscribed subtrees are pruned: the diff does not walk below their top-level pair** (observable via keyFn call set). | +| RS-R18 | live | `docs/rules-mining/reconcile-snapshot.md:60` | reconcile.ts×3 | — | Captured-but-unobserved proxies may detach and go stale after reconcile** (pinned pruning contract); a key mismatch detaches even an observed captured proxy. | +| RS-R19 | live | `docs/rules-mining/reconcile-snapshot.md:63` | — | — | `deep()` observes a reconcile as a single notification carrying the final plain data.** | +| RS-R20 | live | `docs/rules-mining/reconcile-snapshot.md:66` | — | — | (type-level) — `reconcile(next)` requires the complete store type.** | +| RS-R21 | live | `docs/rules-mining/reconcile-snapshot.md:70` | — | — | A reconcile in the same batch after an unflushed setter write behaves identically to a clean reconcile.** CONFLICT (framing only): tests motivate via `STORE_OVERRIDE`/`applyStateSlow` routing (deleted… | +| RS-R22 | live | `docs/rules-mining/reconcile-snapshot.md:73` | — | adoption-lane-rollback.test.ts×1 | Reconcile inside an optimistic action window is tentatively visible; captured-proxy readers see exactly what tracked readers see, during and after settle.** CONFLICT (load-bearing): adoption must ride… | +| RS-R23 | live | `docs/rules-mining/reconcile-snapshot.md:78` | — | — | `snapshot()`/`deep()` always return plain non-proxy data** — including rows through derived stores, nested objects in them, chained views. | +| RS-R24 | live | `docs/rules-mining/reconcile-snapshot.md:81` | — | — | CoW identity preservation:** never-written store snapshots as the original source object (`===`); after a write, changed object + ancestors are new copies, unchanged siblings keep prior snapshot ident… | +| RS-R25 | live | `docs/rules-mining/reconcile-snapshot.md:84` | — | — | Snapshot through a derived-store view returns the same raw object as through the base store** when nothing overridden. CONFLICT (attention): requires unwrapping chained proxy backings to base raw; the… | +| RS-R26 | live | `docs/rules-mining/reconcile-snapshot.md:87` | — | — | Snapshot reflects in-flight optimistic overrides while the base stays untouched.** Confirms O1's "snapshot = current view, lane values included". | +| RS-R27 | live | `docs/rules-mining/reconcile-snapshot.md:90` | — | — | Snapshot sees pending (unflushed) setter writes synchronously, while untracked proxy reads return the previous value until flush.** CONFLICT (MAJOR): §3's "urgent writes are synchronous commits" would… | +| RS-R28 | live | `docs/rules-mining/reconcile-snapshot.md:93` | reconcile.ts×1 | — | Array holes and length survive snapshot/deep** (trailing delete keeps length; holes stay holes; explicit length truncation round-trips; overridden length 0 snapshots as `[]`). | +| RS-R29 | live | `docs/rules-mining/reconcile-snapshot.md:96` | store.ts×1 | — | Symbol-keyed data round-trips through snapshot**: enumerable symbols preserved in copies; writes inside symbol subtrees captured; added-after-snapshot appear; deleted dropped; NON-enumerable symbols e… | +| RS-R30 | live | `docs/rules-mining/reconcile-snapshot.md:99` | — | — | Snapshot-scope machinery (setSnapshotCapture / markSnapshotScope / releaseSnapshotScope / clearSnapshots):** signals/memos created during capture freeze creation-time value for scoped readers; writes … | +| RS-R31 | live | `docs/rules-mining/reconcile-snapshot.md:102` | — | — | Store properties written during capture preserve pre-write value for scoped readers; unwritten use current.** CONFLICT: current mechanism (`STORE_SNAPSHOT_PROPS` in set trap) is layer-adjacent; the wr… | +| RS-R32 | live | `docs/rules-mining/reconcile-snapshot.md:105` | — | — | A pending async projection suppresses snapshot capture**; after resolve + release, readers see resolved value. CONFLICT (mild): guard must move to lane-scoped adoption writes. | +| RS-R33 | live | `docs/rules-mining/reconcile-snapshot.md:110` | — | — | `merge` core contract:** lazy getters (`this` = source); later sources win incl. explicit `undefined`; key union via `in`/keys; value props copied by value; non-enumerable → enumerable on result; firs… | +| RS-R34 | live | `docs/rules-mining/reconcile-snapshot.md:113` | — | — | `merge` reference-return optimization:** same reference for single arg, trailing falsy args, and when last source's own keys cover the union; new proxy otherwise; holds for store proxies. | +| RS-R35 | live | `docs/rules-mining/reconcile-snapshot.md:115` | — | — | `merge` over signal-of-object source is reactive with minimal notifications.** | +| RS-R36 | live | `docs/rules-mining/reconcile-snapshot.md:117` | — | — | `omit` contract:** removed keys disappear from get/`in`/keys incl. store-proxy sources; kept value props copied; descriptors cloned faithfully; pollution-safe; composes with merge. | +| RS-R37 | live | `docs/rules-mining/reconcile-snapshot.md:119` | — | shared-child-multiparent.test.ts×1 | `deep()` contract:** plain data; tracks entire reachable tree (leaf writes, push, branch replacement, symbol subtree writes, symbol add/delete, shared-object writes through other paths); one notificat… | +| RS-R38 | live | `docs/rules-mining/reconcile-snapshot.md:121` | — | — | Untracked read-through (via merge clone) shows pre-write values until flush.** CONFLICT (same as R27, MAJOR): contradicts write-through-immediately unless plain setter writes stay staged until flush. … | ## § — design sections -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ---- | ------ | ----------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| §1 | live | `docs/INTERNALS-STORE-STATE.md:27` | target.ts×1 | reveal-gating-contract.test.ts×1 | Storage model (the single-home rule) | -| §2 | live | `docs/INTERNALS-STORE-STATE.md:81` | — | — | Read paths | -| §3 | live | `docs/INTERNALS-STORE-STATE.md:116` | scheduler.ts×1 optimistic.ts×1 reconcile.ts×1 store.ts×1 target.ts×1 | — | Write paths (all must stay equivalent) | -| §4 | live | `docs/INTERNALS-STORE-STATE.md:178` | — | — | Identity rules | -| §5 | live | `docs/INTERNALS-STORE-STATE.md:190` | — | — | Laziness invariants (candidates for `__TEST__` assertions) | -| §5b | live | `docs/INTERNALS-STORE-STATE.md:208` | target.ts×2 | — | Creation budget (phase-1 fitness) | -| §5c | live | `docs/INTERNALS-STORE-STATE.md:227` | — | — | Comparison method (shipped vs rewrite) | -| §6 | live | `docs/INTERNALS-STORE-STATE.md:278` | invariants.ts×1 optimistic.ts×1 store.ts×2 target.ts×1 | — | Structural edits — the key-set node (resolves O2, RUL-8) | -| §6b | live | `docs/INTERNALS-STORE-STATE.md:306` | reconcile.ts×2 | adoption-lane-rollback.test.ts×1 | Lane-aware adoption (RUL-5) | -| §6c | live | `docs/INTERNALS-STORE-STATE.md:325` | projection.ts×2 store.ts×1 | createProjection.async.test.ts×1 flight-owned-transaction.test.ts×1 | Store-wide status gating (RUL-7) | -| §6d | live | `docs/INTERNALS-STORE-STATE.md:338` | reconcile.ts×1 target.ts×2 | — | Diff reachability (RUL-11) | -| §7 | live | `docs/INTERNALS-STORE-STATE.md:350` | optimistic.ts×1 projection.ts×1 | — | Projections & optimism layering | -| §7b | live | `docs/INTERNALS-STORE-STATE.md:360` | projection.ts×1 reconcile.ts×1 store.ts×11 target.ts×3 store.ts×1 | — | Chained backing (cross-store) — spec | -| §8 | live | `docs/INTERNALS-STORE-STATE.md:431` | — | reconcile-resend-identity.test.ts×1 | Assumptions / open questions | -| §8b | live | `docs/INTERNALS-STORE-STATE.md:487` | — | — | Suite-mined rules (2026-08-16) — index & rulings needed | -| §9 | live | `docs/INTERNALS-STORE-STATE.md:722` | — | — | Decision log | -| §11 | live | `docs/NODE-SHAPE.md:29` | — | — | Stage 3 opener: the core tax map (2026-08-21) | -| §11b | live | `docs/NODE-SHAPE.md:53` | constants.ts×1 | rules-index.test.ts×1 treeshake.test.ts×1 | Presence bits — hot-path monomorphism | -| §11c | live | `docs/NODE-SHAPE.md:72` | — | — | Stage-3 increment log | -| §12 | live | `docs/NODE-SHAPE.md:84` | constants.ts×1 core.ts×1 types.ts×1 | dist-artifacts.test.ts×1 rules-index.test.ts×1 treeshake.test.ts×2 | Cold-field extension (`_x`, `ext()`) | -| §12b | live | `docs/NODE-SHAPE.md:107` | — | treeshake.test.ts×1 | Zombie pair in the extension; plain-commit fast drain | -| §12c | live | `docs/NODE-SHAPE.md:119` | types.ts×1 | — | What stays IN the core literal | -| §12d | live | `docs/NODE-SHAPE.md:129` | core.ts×2 graph.ts×1 scheduler.ts×2 types.ts×1 | — | Staged-rewrite fast path (notify epoch) — _reconstructed_ | -| §12e | live | `docs/NODE-SHAPE.md:149` | core.ts×2 optimistic.ts×1 | rules-index.test.ts×1 | Signal-literal diet: `_time`, `_fn`, `_statusFlags` are computed-only — _reconstructed_ | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +|---|---|---|---|---|---| +| §1 | live | `docs/INTERNALS-STORE-STATE.md:27` | target.ts×1 | reveal-gating-contract.test.ts×1 | Storage model (the single-home rule) | +| §2 | live | `docs/INTERNALS-STORE-STATE.md:81` | — | — | Read paths | +| §3 | live | `docs/INTERNALS-STORE-STATE.md:116` | scheduler.ts×1 optimistic.ts×1 reconcile.ts×1 store.ts×1 target.ts×1 | — | Write paths (all must stay equivalent) | +| §4 | live | `docs/INTERNALS-STORE-STATE.md:178` | — | — | Identity rules | +| §5 | live | `docs/INTERNALS-STORE-STATE.md:190` | — | — | Laziness invariants (candidates for `__TEST__` assertions) | +| §5b | live | `docs/INTERNALS-STORE-STATE.md:208` | target.ts×2 | — | Creation budget (phase-1 fitness) | +| §5c | live | `docs/INTERNALS-STORE-STATE.md:227` | — | — | Comparison method (shipped vs rewrite) | +| §6 | live | `docs/INTERNALS-STORE-STATE.md:278` | invariants.ts×1 optimistic.ts×1 store.ts×2 target.ts×1 | — | Structural edits — the key-set node (resolves O2, RUL-8) | +| §6b | live | `docs/INTERNALS-STORE-STATE.md:306` | reconcile.ts×2 | adoption-lane-rollback.test.ts×1 | Lane-aware adoption (RUL-5) | +| §6c | live | `docs/INTERNALS-STORE-STATE.md:325` | projection.ts×2 store.ts×1 | createProjection.async.test.ts×1 flight-owned-transaction.test.ts×1 | Store-wide status gating (RUL-7) | +| §6d | live | `docs/INTERNALS-STORE-STATE.md:338` | reconcile.ts×1 target.ts×2 | — | Diff reachability (RUL-11) | +| §7 | live | `docs/INTERNALS-STORE-STATE.md:350` | optimistic.ts×1 projection.ts×1 | — | Projections & optimism layering | +| §7b | live | `docs/INTERNALS-STORE-STATE.md:360` | projection.ts×1 reconcile.ts×1 store.ts×11 target.ts×3 store.ts×1 | — | Chained backing (cross-store) — spec | +| §8 | live | `docs/INTERNALS-STORE-STATE.md:431` | — | reconcile-resend-identity.test.ts×1 | Assumptions / open questions | +| §8b | live | `docs/INTERNALS-STORE-STATE.md:487` | — | — | Suite-mined rules (2026-08-16) — index & rulings needed | +| §9 | live | `docs/INTERNALS-STORE-STATE.md:722` | — | — | Decision log | +| §11 | live | `docs/NODE-SHAPE.md:29` | — | — | Stage 3 opener: the core tax map (2026-08-21) | +| §11b | live | `docs/NODE-SHAPE.md:53` | constants.ts×1 | rules-index.test.ts×1 treeshake.test.ts×1 | Presence bits — hot-path monomorphism | +| §11c | live | `docs/NODE-SHAPE.md:72` | — | — | Stage-3 increment log | +| §12 | live | `docs/NODE-SHAPE.md:84` | constants.ts×1 core.ts×1 types.ts×1 | dist-artifacts.test.ts×1 rules-index.test.ts×1 treeshake.test.ts×2 | Cold-field extension (`_x`, `ext()`) | +| §12b | live | `docs/NODE-SHAPE.md:107` | — | treeshake.test.ts×1 | Zombie pair in the extension; plain-commit fast drain | +| §12c | live | `docs/NODE-SHAPE.md:119` | types.ts×1 | — | What stays IN the core literal | +| §12d | live | `docs/NODE-SHAPE.md:129` | core.ts×2 graph.ts×1 scheduler.ts×2 types.ts×1 | — | Staged-rewrite fast path (notify epoch) — _reconstructed_ | +| §12e | live | `docs/NODE-SHAPE.md:149` | core.ts×2 optimistic.ts×1 | rules-index.test.ts×1 | Signal-literal diet: `_time`, `_fn`, `_statusFlags` are computed-only — _reconstructed_ | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 160fac3a4..8f38c4e38 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -30,11 +30,11 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t ### A17. An active override is the displayed value until its transaction commits, and the graph's value until its own source answers -**Status:** **ruled, amended in place** 2026-07-06 (promoted from C4) — maintainer ruling, 2026-07-06/07; amended 2026-09-09 (#3331: "knowing otherwise" splits display from tracked derivations — see A18 supersession); carve-out ruled 2026-09-14 (`until()` reads the landed world) -**Pinned by:** `tests/spec-async-semantics.test.ts`; downstream-async lane holding: `tests/createOptimistic.test.ts` (CategoryDisplay/News-Finance real-world sections) -**Mechanism (index, 2026-09-14):** `_overrideValue` + `hasActiveOverride`; value selection in `read` / `readNodeFast` / store `serveDataKey`; authoritative-view carve-out for `until()` (`CONFIG_AUTHORITATIVE_READ`); held-truth mask `CONFIG_HELD_TRUTH` (#3164); after the own-source landing `supersededRead` routes tracked readers to the arrived value while untracked reads keep `_overrideValue` (`CONFIG_OVERRIDE_SUPERSEDED`, #3331). +**Status:** **ruled, amended in place** 2026-07-06 (promoted from C4) — maintainer ruling, 2026-07-06/07; amended 2026-09-09 (#3331: "knowing otherwise" splits display from tracked derivations — see A18 supersession); carve-out ruled 2026-09-14 (`until()` reads the landed world); **lanes stage** ruled 2026-09-15 (#3479 review: an optimistic derivation is an override) +**Pinned by:** `tests/spec-async-semantics.test.ts`; downstream-async lane holding: `tests/createOptimistic.test.ts` (CategoryDisplay/News-Finance real-world sections); `tests/lane-outside-view.test.ts` (#3460: a reader mounted or re-run mid-hold, `latest()` and `createOptimistic` alike; #3479: the outsider's frame is whole, a never-ready guess never shows beside its old derivation) +**Mechanism (index, 2026-09-15):** `_overrideValue` + `hasActiveOverride`; value selection in `read` / `readNodeFast` / store `serveDataKey`; authoritative-view carve-out for `until()` (`CONFIG_AUTHORITATIVE_READ`); held-truth mask `CONFIG_HELD_TRUTH` (#3164); after the own-source landing `supersededRead` routes tracked readers to the arrived value while untracked reads keep `_overrideValue` (`CONFIG_OVERRIDE_SUPERSEDED`, #3331). **Lanes stage (#3479):** a lane pass on a memo — its sync recompute (`recompute`'s lane branch) or its async landing (`asyncWrite`'s lane branch) — publishes into the override slot as a _derived override_ (`laneOverride`, `CONFIG_DERIVED_OVERRIDE`); `_value` stays the committed truth. The derived override rides the written override's machinery unchanged: display and tracked selection (`read()`'s override arm, `overrideRead`), the A18 sync twin when a plain pass re-derives it, the revert with the lane's transaction (`resolveOptimisticNodes`, which _promotes_ it — the truth confirmed the guess — instead of dropping to a stale `_value`). Where a written override means intent, a derived one means "a pass's answer": it does not block lane merging through the node (`assignOrMergeLane`), does not stop pending propagation as an optimistic boundary (`notifyStatus`), suspends its lane readers while its re-ask is in flight (`laneSuspends`), is not superseded at body-end nor does its flight hold the settle (`endOptimism`, `transitionBlocked` — it re-derives when its source's is), carries no `_overrideTime` (not an unflushed write, A28), and is no acknowledgement to the hold census (attribution `isCompanion`). A lane pass over a WRITTEN guess (a `createOptimistic(fn)` corrected from fresh upstream data) marks it derived too — the guess is gone, the slot holds fn's answer — and the next user write re-arms it (`optimisticWrite` clears the bit). -**Statement (current).** (was C4) **Amended 2026-09-09 (#3331):** "until we know otherwise" is the landing of the node's own async, and knowing otherwise splits the readers: from that landing on, the override is the value for the _display_ — untracked/ambient reads and the applied frame — until the transaction commits, while tracked derivations (memos, async drivers, lane recomputes) see the arrived truth (A18 supersession). `latest(x)` returns the arrived value; `isPending(x)` is `true` iff it differs from the override. "It is the optimistic future value... it is both immediate and is the future until we know otherwise." "Knowing otherwise" is its own async source resolving (see A18); a transition whose optimistic node is still pending on its own fetch is not complete, so the override cannot be dropped early. **No-tearing is an effect-level concern, not a read-level one**: when async _derived from_ the optimistic value is in flight, the lane holds its render effects (the rendered view keeps the committed state as a unit) — but direct reads still return the override ("direct read shows optimistic, effect waits"). **Authoritative-reader carve-out (ruled 2026-09-14, visibility oracle):** `until()`'s predicate reads the _landed_ world — values a source has actually produced, staged or committed, before they have become visible — and never the caller's optimism. Maintainer: "it needs to work off landed values, but before they have become visible." So under a held write it sees the staged value; over an override it sees the truth beneath (staged if one arrived, else committed); over a pending or uninitialized node it suspends like any reader; over a loading-window node it sees the loading value. Mechanism: `CONFIG_AUTHORITATIVE_READ` on the predicate's computation, `authoritativeServe()` on the store side. +**Statement (current).** (was C4) **Amended 2026-09-09 (#3331):** "until we know otherwise" is the landing of the node's own async, and knowing otherwise splits the readers: from that landing on, the override is the value for the _display_ — untracked/ambient reads and the applied frame — until the transaction commits, while tracked derivations (memos, async drivers, lane recomputes) see the arrived truth (A18 supersession). `latest(x)` returns the arrived value; `isPending(x)` is `true` iff it differs from the override. "It is the optimistic future value... it is both immediate and is the future until we know otherwise." "Knowing otherwise" is its own async source resolving (see A18); a transition whose optimistic node is still pending on its own fetch is not complete, so the override cannot be dropped early. **No-tearing is an effect-level concern, not a read-level one**: when async _derived from_ the optimistic value is in flight, the lane holds its render effects (the rendered view keeps the committed state as a unit) — but direct reads still return the override ("direct read shows optimistic, effect waits"). Every render effect off the lane is held to the same view (#3460, A15 lanes corollary): one mounted or re-run by an unrelated sync write mid-hold is served the committed value and re-runs at the release — only direct reads return the override while the lane holds. **Lanes stage (ruled 2026-09-15, #3479 review):** that committed view is a _whole frame_. An optimistic derivation is an override: a memo the lane recomputes publishes its speculative result as a derived override, not into `_value`, so the source's shadow and every derivation of it are held in one place — the lane's readers and direct reads see the optimistic frame, a reader off the lane sees the committed one, and neither is torn. Before, a lane pass direct-committed `_value` (INV-11's "lane direct commit"), and the outsider saw the committed source beside a speculative derivation (`Late: 0 1` for `latest(count)` and a memo of it; gabbev's fuzzer, latest cohort: 57 → 6 published-derivation disagreements, 26 → 0 torn tuples delivered to an effect). Off the lane is provenance, not membership: a pass under the lane's own transaction — its async's landing re-entering it with no ambient lane — is the lane's work, not an outsider (`readsHeldCommitted`; the `1:0` frame of an optimistic value that never finished preparing, #3479 review). The revert promotes a derived override that was not superseded: a derivation nobody told otherwise is, by the graph's invariant, what the truth yields — re-deriving it re-asked an async member's flight and held the transaction on a frame already on screen. **Authoritative-reader carve-out (ruled 2026-09-14, visibility oracle):** `until()`'s predicate reads the _landed_ world — values a source has actually produced, staged or committed, before they have become visible — and never the caller's optimism. Maintainer: "it needs to work off landed values, but before they have become visible." So under a held write it sees the staged value; over an override it sees the truth beneath (staged if one arrived, else committed); over a pending or uninitialized node it suspends like any reader; over a loading-window node it sees the loading value. Mechanism: `CONFIG_AUTHORITATIVE_READ` on the predicate's computation, `authoritativeServe()` on the store side. **History (superseded formulation, kept verbatim).** The 2026-07-06 wording — its "tracked alike" and "any read path" clauses are replaced by the 2026-09-09 amendment the statement now leads with; the display half of both sentences still holds: An _active_ optimistic override is THE value for every **read** — ambient/untracked and tracked alike — regardless of transition entanglement. Do NOT mask the override from any read path to prevent tearing; that breaks the real-world optimistic-UI contract. @@ -198,17 +198,17 @@ A resting optimistic node reports pending via exactly the causes a plain async m ### A15. Transition entanglement is graph-driven; lanes settle as one reveal -**Status:** **ruled, amended in place** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06; amended 2026-09-14 (#3407: a shared render effect entangles nothing by itself — see the shared-hole corollary); mechanism completed 2026-09-14 (#3443: a held memo made pending by another flight entangles at the propagation, not at its next pass) -**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first); `tests/posture-born-held-and-observation.test.ts` (posture matrix, ruled 2026-09-15: `latest(x)` evaluated inside another live action ENTANGLES — "optimistic lanes are transition-bound, so it does need to entangle; that doesn't mean optimism for both can't poke through in the meanwhile" — the held value is served at once, the two transactions reveal as one) -**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407); `notifyStatus`'s pending propagation onto a memo another live transaction holds (stamped, and pending or staged) enters it (`initTransition(sub._transition)`, #3443). +**Status:** **ruled, amended in place** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06; amended 2026-09-14 (#3407: a shared render effect entangles nothing by itself — see the shared-hole corollary); mechanism completed 2026-09-14 (#3443: a held memo made pending by another flight entangles at the propagation, not at its next pass); lanes corollary extended 2026-09-15 (#3460, maintainer: "a held lane is basically a micro transition from the outside… we wouldn't hold a sync write on a transition. Lanes are the same"); reveal corollary's stale-reader term completed 2026-09-15 (#3458 first observer; #3463 a zombie reader is live for the hold) +**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first); `tests/lane-outside-view.test.ts` (#3460: a `latest()` / `createOptimistic` reader mounted or re-run by a sync write mid-hold shows the committed value and reveals with the lane; #3463: a reader whose removal is staged holds the lane while it is visible, a plain removal still releases at once); `tests/first-observer-stale-reader.test.ts` (#3458: a stale reader that is a flight's first observer holds the transaction on it); `tests/posture-born-held-and-observation.test.ts` (posture matrix, ruled 2026-09-15: `latest(x)` evaluated inside another live action ENTANGLES — "optimistic lanes are transition-bound, so it does need to entangle; that doesn't mean optimism for both can't poke through in the meanwhile" — the held value is served at once, the two transactions reveal as one) +**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407); `notifyStatus`'s pending propagation onto a memo another live transaction holds (stamped, and pending or staged) enters it (`initTransition(sub._transition)`, #3443). `readsHeldCommitted` (lanes.ts; from `overrideRead` — `read()`'s override arm's single engine hook `GlobalQueue._overrideRead`, which also carries the A18 supersession selection — and `latestRead`) serves a render effect off a held lane the committed value and queues its re-run on the lane's render queue (#3460); `heldFromStale` notifies a first observer's pending status up its queue chain under the transaction (#3458); `reporterBlocksSource` / `sourceObserved` take the judged transaction (`verdict`) and count a `REACTIVE_ZOMBIE` reporter as live unless the verdict is the commit that disposes it (#3463). -(was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands — and it stands from the moment the second flight reaches the memo (#3443): pending _propagates_ onto a held memo without recomputing it (its inputs' values are unchanged), so the propagation itself enters the memo's transaction — when the memo is genuinely _held_ (pending on that transaction's work, or staged by it); a stamp alone decides nothing (#3334), so a second write that supersedes the first through a shared output memo does not drag the superseded flight into the live reveal; waiting for the memo's next pass let the first flight land, reveal its inputs (`A: 1`) beside the memo's committed value (`Sum: 0`), and left `Sum: 2` to arrive with `B: 1`. Consequence: the write that started the second flight is held with the first when its async work flows into a memo the first holds (`page=1` waits with `count=1` while `details` re-asks), even where a plain binding of the same write would have passed through — the async work, not the binding, is what is shared. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`. +(was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands — and it stands from the moment the second flight reaches the memo (#3443): pending _propagates_ onto a held memo without recomputing it (its inputs' values are unchanged), so the propagation itself enters the memo's transaction — when the memo is genuinely _held_ (pending on that transaction's work, or staged by it); a stamp alone decides nothing (#3334), so a second write that supersedes the first through a shared output memo does not drag the superseded flight into the live reveal; waiting for the memo's next pass let the first flight land, reveal its inputs (`A: 1`) beside the memo's committed value (`Sum: 0`), and left `Sum: 2` to arrive with `B: 1`. Consequence: the write that started the second flight is held with the first when its async work flows into a memo the first holds (`page=1` waits with `count=1` while `details` re-asks), even where a plain binding of the same write would have passed through — the async work, not the binding, is what is shared. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`. **First observer (2026-09-15, #3458):** the stale reader's "joins the transaction's reporters when it has an entry" (#3374) has no gap: when the transaction has NO entry for the flight — it was in flight but nothing displayed it, and this reveal is its first observer — the observation registers it (the reader's status notification runs under the transaction), and the transaction waits on the flight it now shows a reader of. Before, `show → true` revealed `B: {b()}` as a stale reader of the `count=1` transaction (correctly served the committed `0`), the transaction was judged complete on `a` alone, and `Count: 1 | A: 1` published beside `B: 0`. Now one reveal, when both have landed. **Zombie readers (2026-09-15, #3463):** a reader whose removal is staged in a live transaction is still on screen and is live for every hold until the commit that disposes it — its say is moot only for the verdict of the transaction staging its removal (done, and the commit disposes it; not done, and it stays parked regardless). Before, it counted as dead everywhere, and a lane it alone held revealed `Value: 1` beside its `Details: 0`. A removal nothing else holds still commits at once and releases (the #3426 line). **Lanes mirror transitions (2026-09-15, #3460):** a held lane is a transaction seen from the outside. A render effect OFF the lane that reads what the lane is revealing — an override, a `latest()` shadow — is a stale reader of it: it shows the committed value (which is what is on screen: the lane defers its own readers' runs), publishes now, entangles nothing — "we wouldn't hold a sync write on a transition. Lanes are the same" — and re-derives at the release. Inside, a reader ON the lane computes the reveal and sees the lane's values and the parent transaction's landings, as before. `latest()` is not a special case: the same rule for a `createOptimistic` source. Before, only a reader under ANOTHER lane got the committed shadow; a mainline reader mounted mid-hold, or re-run by an unrelated sync write, showed the speculative `1` beside the lane's deferred `Value: 0`. Now `Late: 0` at the mount and `Details: 1 | Late: 1 | Value: 1` at the release; a sibling sync write re-runs `Both` to `0 y` at once and the release brings `1 y`. ### A30. A memo's dependencies are the committed frame's until the frame is replaced -**Status:** **ruled** 2026-09-13 (#3410) — maintainer ruling, Cluster 4 triage; the dependency twin of held children (#3404) -**Pinned by:** `tests/held-conditional-memo.test.ts` (#3410: a memo whose held pass stopped reading an input still follows a mainline write to it); `tests/held-conditional-effect.test.ts` (#3438: an effect whose held pass stopped reading an input, its run stashed, still follows a mainline write to it); `tests/async-landing-deps-3461.test.ts` (#3461: an async memo whose held flight stopped reading an input, its landing staged, still follows a mainline write to it) -**Mechanism (index, 2026-09-15):** `recompute` trims the previous pass's dependency tail (`trimStaleDeps`) only when the pass published or changed nothing (`_pendingValue === NOT_PENDING`, no `_error`) and, for an effect, owes no run (`_modified` clear); a pass that staged its value leaves the tail linked and `commitPendingNode` trims it after a clean pass (`_error == null`); an effect pass that direct-committed and owes a run leaves it for `runEffect` to trim once the run applies (again after a clean pass). `__OBSERVE__` fan-in counting and the attribution engine's subscription diff walk the validated prefix only. An async landing (`asyncWrite`) follows the same gate (#3461): it trims only when it published (`_pendingValue === NOT_PENDING` after the write, an equal-value or lane landing); a transition-held landing leaves the tail for `commitPendingNode`. +**Status:** **ruled** 2026-09-13 (#3410) — maintainer ruling, Cluster 4 triage; the dependency twin of held children (#3404); amended 2026-09-15 (#3469: an unchanged pass waits on the flush's verdict) +**Pinned by:** `tests/held-conditional-memo.test.ts` (#3410: a memo whose held pass stopped reading an input still follows a mainline write to it); `tests/held-conditional-effect.test.ts` (#3438: an effect whose held pass stopped reading an input, its run stashed, still follows a mainline write to it); `tests/async-landing-deps-3461.test.ts` (#3461: an async memo whose held flight stopped reading an input, its landing staged, still follows a mainline write to it); `tests/held-frame-dependencies.test.ts` (#3469: a memo whose held pass computed the same value still follows its committed inputs; the render-effect arm follows them at once) +**Mechanism (index, 2026-09-15):** `recompute` trims the previous pass's dependency tail (`trimStaleDeps`) only when the pass published or changed nothing (`_pendingValue === NOT_PENDING`, no `_error`) and, for an effect, owes no run (`_modified` clear); a pass that staged its value leaves the tail linked and `commitPendingNode` trims it after a clean pass (`_error == null`); an effect pass that direct-committed and owes a run leaves it for `runEffect` to trim once the run applies (again after a clean pass). `__OBSERVE__` fan-in counting and the attribution engine's subscription diff walk the validated prefix only. An async landing (`asyncWrite`) follows the same gate (#3461): it trims only when it published (`_pendingValue === NOT_PENDING` after the write, an equal-value or lane landing); a transition-held landing leaves the tail for `commitPendingNode`. An unchanged pass (#3469) trims at its tail only when created, OPT-dirty, or a tracked effect; otherwise its stale tail goes to `heldTrims`, trimmed by `commitPendingNodes` when the flush commits and dropped (tail kept) when it parks. A pass that _staged_ its value has not replaced the committed frame, so the committed value still derives from the previous pass's dependencies and a write to one of them must reach the node — and, through the node's `_transition` stamp, join its hold — exactly as an unconditional read would. Before: `selected = fixed() ? 2 : count()` held on `fixed → true` dropped `count` at its held pass, and a mainline `count` write then published `Count: 1` beside the committed `Selected: 0` / `Fixed: false`. Decided at commit rather than at the pass because a plain flush knows nothing at recompute time: the transaction that ends up holding the pass may open later in the same flush (an async memo downstream pends and the batch is adopted). An errored pass (a throw, NotReady included, a comparator throw) keeps its full list as before, and the commit skips the trim by the same `_error`. Cost on the plain path: none — a pass that publishes trims at its tail as before; only a staged pass moves the trim to the same flush's commit. @@ -216,6 +216,8 @@ An effect's frame is the run its value is applied by, not its value slot (#3438) An async memo's frame is replaced by its landing, not by the pass that registered the flight (#3461). The flight's pass throws `NotReady` and keeps its full list; the landing used to trim unconditionally, before the write, even when `setSignal` then staged the value under a live transaction. Before: `selected = async () => (b() ? b() : a())` held on `b → 1` dropped `a` at its landing, and a mainline `a` write then published `A: 1` beside `Selected: 0` while `B` still read 0. Now the landing trims only when it published; a held landing leaves the tail for its commit, so the `a` write reaches `selected` and joins its hold (its stamp), exactly as the sync memo's staged pass does. Unchanged by design: a landing equal to the committed value published nothing new and trims at once, like a sync pass that changed nothing, so `b() ? 0 : a()` still drops `a` in both shapes. +A pass that changed nothing replaced nothing either (#3469). "Published or changed nothing" is not one case: a pass that changed nothing under a hold still left the committed frame deriving from the previous pass's dependencies, and it cannot know at its own tail whether the flush that ran it will park with its inputs held. Before: `selected = b() ? b() : a()` held on `b → 1` computed `1`, equal to the `1` it had from `a`, and trimmed `a`; the flush parked (b's flight was observed), and the mainline `a=2` never reached it — `A: 2 | B: 0 | Selected: 1`. Now the trim waits on the flush's verdict: trimmed when it commits, kept when it parks (the tail stays linked until a committing pass trims it — one spurious recompute at most). The consequence is the memo rule's: the `a=2` pass re-derives `selected`, is served the staged `b` and enters the hold (A29), so `A: 2` reveals with `B: 1` — the same held outcome as `sum = a() + b()` has always had, and one of the two outcomes the report accepts. The render-effect arm follows its inputs at once instead (`Selected: 2` on the write, `1` at the commit): a stale reader is served the committed `b`, and a sync write is never held by a transaction. A creation pass, an OPT-dirty pass and a tracked effect trim at their tails as before — their frames are replaceable like a direct commit, and a tracked effect's spurious run would be user-visible. + ### A33. A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary **Status:** **ruled** 2026-09-12 (#3375) — maintainer ruling; extended 2026-09-15 (#3459, maintainer confirmed: the hold transfers to the boundary rather than ending) @@ -522,7 +524,7 @@ question. - Internals: `waitingTransition(node)` (replaces `asyncObserved`), `CONFIG_OVERRIDE_SUPERSEDED`, `_overrideTime`, `_overrideStamp` / scheduler `origin`, - `GlobalQueue._supersedeOverride` / `_supersededRead` hook slots (the + `GlobalQueue._supersedeOverride` / `_overrideRead` (was `_supersededRead`; #3479 folded it with the lane outside-view rule) hook slots (the authoritative-observer wake now lives inside the former), lane demotion on supersession, the `runEffect` owner-gate exception for lane-less lane runners. See INTERNALS-ASYNC-STATE.md §1–§3. diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index c9b2ded0b..1e1155af6 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -1,6 +1,7 @@ import { CONFIG_CHILD_COMPANIONS, CONFIG_AUTO_DISPOSE, + CONFIG_DERIVED_OVERRIDE, CONFIG_INPUTS_PUBLISHED, CONFIG_SYNC, EFFECT_TRACKED, @@ -12,7 +13,8 @@ import { REACTIVE_ZOMBIE, STATUS_ERROR, STATUS_PENDING, - STATUS_UNINITIALIZED + STATUS_UNINITIALIZED, + unwrapOverride } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; import { context, setSignal, untrack, ext, statusNotifierOf } from "./core.js"; @@ -500,7 +502,14 @@ export function handleAsync( return; } if (wasUninitialized) landStatus(el, true); - } else if (el._x?._overrideValue !== undefined) { + } else if ( + el._x?._overrideValue !== undefined && + !(lane && el._config & CONFIG_DERIVED_OVERRIDE) + ) { + // A derived override's landing UNDER its lane is the lane's own work + // (the branch below); demoted — its source superseded (A18) — the + // landing is the truth the correction asked for, and holds and + // supersedes here like the sync twin (recompute). Otherwise: // Optimistic node — resting OR covered by an active override — holds // through the shared pending-node path, exactly like a plain async memo, // so the commit clears STATUS_UNINITIALIZED (#2806) and elevation to @@ -539,11 +548,15 @@ export function handleAsync( } else if (lane) { // Route through lane's effect queue for independent flushing const isEffect = (el as any)._type; - const prevValue = el._value; + const prevValue = hasActiveOverride(el) ? unwrapOverride(el._x!._overrideValue) : el._value; const equals = el._equals; try { if ((!isEffect && wasUninitialized) || !equals || !equals(value, prevValue)) { - el._value = value; + // Lanes stage (#3479): a memo's landing under its lane is a derived + // override, as its sync pass's result is (recompute) — `_value` + // stays the committed truth for readers off the lane. + if (isEffect) el._value = value; + else GlobalQueue._laneOverride!(el, value, lane); el._time = clock; // The latest() shadow write gives latest() effects independent lanes; the // _pendingSignal update is a no-op repeat of the clearStatus() call above @@ -930,8 +943,15 @@ export function notifyStatus( const pendingSource = status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined; const isSource = pendingSource === el; + // An optimistic node (a WRITTEN override slot) pending derivatively is a + // boundary: its override is the answer, pending stops here (A17). A + // derived override (#3479) is a previous speculative answer on a plain + // member — pending flows through it as through any memo. const isOptimisticBoundary = - status === STATUS_PENDING && el._x?._overrideValue !== undefined && !isSource; + status === STATUS_PENDING && + el._x?._overrideValue !== undefined && + !(el._config & CONFIG_DERIVED_OVERRIDE) && + !isSource; const startsBlocking = isOptimisticBoundary && hasActiveOverride(el); if (!blockStatus) { diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index a5a7b11f5..1c6d8fc1a 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -4,7 +4,7 @@ import { type InteractionRef, type OriginRef } from "./attribution-hooks.js"; -import { $REFRESH, NOT_PENDING } from "./constants.js"; +import { $REFRESH, CONFIG_DERIVED_OVERRIDE, NOT_PENDING } from "./constants.js"; import { anyExcluded, emitDiagnostic, @@ -2101,9 +2101,14 @@ const holdStates = new WeakMap(); let activeHold: HoldState | null = null; let holdLog: HoldEvent[] = []; -/** Companions are optimistic nodes too; `_parentSource` marks them. */ +/** Companions are optimistic nodes too; `_parentSource` marks them. So is a + * memo carrying a DERIVED override (lanes stage, #3479) — a lane pass's + * result, not a write anyone made: neither is an acknowledgement. */ function isCompanion(node: Signal | Computed): boolean { - return !!node._x && node._x._parentSource !== undefined; + return ( + (!!node._x && node._x._parentSource !== undefined) || + (node._config & CONFIG_DERIVED_OVERRIDE) !== 0 + ); } const HOLD_CENSUS_CAP = 10_000; diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index eacb061cd..b874783c3 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -163,6 +163,14 @@ export const CONFIG_INPUTS_PUBLISHED = 1 << 21; * a write is promoted at that recompute's end: readers in the same block see * it. Cleared when the next flush begins; set only on that rare path. */ export const CONFIG_PROMOTED = 1 << 22; +/** The node's active override is a DERIVED one: a lane pass published its + * speculative result into the override slot instead of `_value` (lanes + * stage — an optimistic derivation is an override, #3479). Its truth is not + * `_value` but a recompute from its inputs' truth, so the body-end + * supersession (`endOptimism`) and the authoritative-flight blockage + * (`transitionBlocked`) skip it; the revert drops the override and re-derives + * it (`resolveOptimisticNodes`). Cleared with the override. */ +export const CONFIG_DERIVED_OVERRIDE = 1 << 23; export const STATUS_NONE = 0; export const STATUS_PENDING = 1 << 0; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index bd60d109e..ecc92d5ae 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -25,6 +25,7 @@ import { CONFIG_INPUTS_PUBLISHED, CONFIG_NO_SNAPSHOT, CONFIG_OPTIMISTIC, + CONFIG_DERIVED_OVERRIDE, CONFIG_OVERRIDE_SUPERSEDED, CONFIG_OWNED_WRITE, CONFIG_PROMOTED, @@ -97,6 +98,7 @@ import { insertSubs, projectionWriteActive, queuePendingNode, + heldTrims, runInTransition, schedule, wokenTransitions, @@ -268,8 +270,11 @@ export function recompute(el: Computed, create: boolean = false): void { } let isOptimisticDirty = !!(el._flags & REACTIVE_OPTIMISTIC_DIRTY); + // A derived override (lanes stage, #3479) is override-covered like a written + // one: a plain pass over it — its source superseded (A18) — stages the truth + // and supersedes the override through the sync twin below. const hasOverride = - (el._config & CONFIG_OPTIMISTIC) !== 0 && + (el._config & (CONFIG_OPTIMISTIC | CONFIG_DERIVED_OVERRIDE)) !== 0 && el._x?._overrideValue !== NOT_PENDING && el._x?._overrideValue !== undefined; const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED); @@ -346,6 +351,21 @@ export function recompute(el: Computed, create: boolean = false): void { // latest()/isPending() pull stages instead of direct-committing (#3009). // The predicate lives with the engine (recomputeLane). else if (lane === false) isOptimisticDirty = false; + } else if (el._config & CONFIG_DERIVED_OVERRIDE) { + // Lanes stage (#3479): a pass over a live lane member carrying a derived + // override is the lane's pass whatever channel dirtied it (a boundary + // reset, an unrelated sync write) — its inputs serve the lane's view, so + // its result is the lane's and belongs in the override slot. Run plain, + // A18's sync twin below read that re-derived lane view as a differing + // truth (a fresh array), superseded the override and demoted the lane; + // the lane's next pass then dropped the staged "truth" and left the node + // flagged superseded with nothing to serve (fuzzer latest-1 #2481). A + // demoted node resolves no lane and stays plain: its pass IS the truth. + const lane = GlobalQueue._recomputeLane!(el, true); + if (lane) { + isOptimisticDirty = true; + currentOptimisticLane = lane; + } } else if (activeTransition && !create && activeTransition._optimisticNodes.length) { // Lane adoption: parent-deeper-than-owned-child can run before its OPT-dirty // child propagates. Walk deps once and inherit the OPT lane so this node @@ -432,6 +452,18 @@ export function recompute(el: Computed, create: boolean = false): void { // The replacement source is fully propagated now. If no new flight // re-owned self, retire the superseded flight and its dependent copies. if (notReady && wasPendingSource && !el._x?._inFlight) settlePendingSource(el); + // A re-park drops what the earlier pass carried (#3456): a source this + // pass no longer reaches — its branch switched, or a fresh flight + // replaced the inputs' pending with its own — stays copied onto + // dependents that reached it only through here, and its landing walk + // stops at this node (nothing left to retire) before it finds them. A + // dependent then waits forever on a flight it has no path to. The + // re-park twin of the unchanged-value recovery sweep below; dependents + // with another path keep the source (retryReaches). + if (notReady && outgoingPendingSources) + for (const source of outgoingPendingSources) + if (source !== el && !el._x?._pendingSources?.has(source)) + settlePendingSource(el, source); if (reaskChanged) GlobalQueue._repollVerdicts!(el); } } finally { @@ -573,21 +605,24 @@ export function recompute(el: Computed, create: boolean = false): void { // round-trip is that separation; it cannot be skipped on any path a // pull can reach. ) { - el._value = value; - // Lane-propagated correction: upstream data is fresh, correct the - // override unconditionally. The direct _value commit is the lane's - // own reveal schedule; drop any superseded older hold so its queued - // commit can't clobber the fresh value. Override or not: a node that - // adopted the lane through its deps (a `latest()` read — the - // companion is an optimistic node) direct-commits the same way, and - // a hold it staged on an earlier, lane-free pass of the SAME - // transaction is just as superseded — left in place, the commit - // published the older frame over the fresh one (#3377). - if (isOptimisticDirty) { - if (hasOverride) - ext(el)._overrideValue = value === undefined ? OVERRIDE_UNDEFINED : value; - el._pendingValue = NOT_PENDING; - } + // Lanes stage (#3479): a lane pass on a memo publishes its speculative + // result as an OVERRIDE — `_value` stays the committed truth, so a + // reader off the lane (A17's committed view, #3460) sees a whole + // committed frame: the source's shadow and its derivations together, + // never a committed shadow beside a speculative memo. The lane's own + // readers and untracked reads see the override (A17); the revert + // drops it and re-derives (resolveOptimisticNodes). Effects keep the + // direct commit — their `_value` is a run result the lane's queues + // already sequence. Either way the lane pass drops any superseded + // older hold so its queued commit can't clobber the fresh frame: a + // hold staged on an earlier, lane-free pass of the SAME transaction + // is superseded — left in place, the commit published the older + // frame over the fresh one (#3377). A reversion pass (OPT-dirty with + // no lane — the override dropped) commits directly: it IS the truth. + if (isOptimisticDirty && !isEffect && currentOptimisticLane !== null) + GlobalQueue._laneOverride!(el, value, currentOptimisticLane); + else el._value = value; + if (isOptimisticDirty) el._pendingValue = NOT_PENDING; } else { el._pendingValue = value; if (__DEV__) devTrackHeldPending(el); @@ -702,18 +737,24 @@ export function recompute(el: Computed, create: boolean = false): void { // so a write to a dependency the committed value still derives from // reaches this node — and joins its hold if the stage is transaction-held // by then (a plain flush decides nothing here: the transaction that holds - // the pass may open later in the same flush). A pass that published, or - // changed nothing, trims now. An errored pass (a throw, NotReady included, - // or a comparator throw above) keeps its full list as before — `_depsTail` - // marks where it stopped — and the commit skips it by the same `_error`. - // An effect's frame is the run its value is applied by, not the value slot - // (#3438): a direct-committed pass that still owes a run (`_modified`) has - // not replaced what the last run published — the same flush may stash that - // run into a transaction it opens later — so its tail waits for `runEffect` - // to trim once the run applies. A pass that changed nothing owes no run - // and trims here. - if (!el._x?._error && el._pendingValue === NOT_PENDING && !(isEffect && (el as any)._modified)) - trimStaleDeps(el); + // the pass may open later in the same flush). A pass that published + // directly (a first pass, a lane's own reveal) trims now. An errored pass + // (a throw, NotReady included, or a comparator throw above) keeps its full + // list as before — `_depsTail` marks where it stopped — and the commit + // skips it by the same `_error`. An effect's frame is the run its value is + // applied by, not the value slot (#3438): a direct-committed pass that + // still owes a run (`_modified`) has not replaced what the last run + // published — the same flush may stash that run into a transaction it + // opens later — so its tail waits for `runEffect` to trim once the run + // applies. A pass that changed nothing replaced nothing either (#3469): the + // same flush may park with its inputs held, and the committed frame still + // derives from the tail — its trim waits on the flush's verdict (heldTrims). + // A tracked effect's pass IS its run (it bypasses the heap and runs after + // the commit): the frame is replaced, trim now. + if (!el._x?._error && el._pendingValue === NOT_PENDING && !(isEffect && (el as any)._modified)) { + if (create || isOptimisticDirty || isEffect === EFFECT_TRACKED) trimStaleDeps(el); + else if ((el._depsTail as Link | null)?._nextDep ?? el._deps) heldTrims.push(el); + } // Attribution hook: fired before the lane restore so `currentOptimisticLane` // still reflects THIS run's posture. The facts distinguish an overlay // recompute (optimistic lane, transition replay, transition-held commit) @@ -1483,10 +1524,16 @@ export function installAuthoritativeRead(): void { * reporter the transaction recorded when the flight started may be gone (a * keyed remount disposed it, #3374); a completion check that found no live * reporter committed the writes ahead of the answer, tearing the new - * reader's frame (`Count: 1` beside `Details: 0`). Joins only an entry the - * transaction already holds — a staged signal or a settled node has none; - * INV-3: entries open from queue notification alone, so a boundary-consumed - * flight stays consumed — and dies with the reader like every reporter + * reader's frame (`Count: 1` beside `Details: 0`). Joins an entry the + * transaction already holds; a flight nobody had observed yet has none (the + * reader is its first observer — a conditional that just revealed it, #3458) + * and is notified up the reader's own queue chain under that transaction, + * the one sanctioned registration site (INV-3): a collecting boundary above + * the reader consumes it as it would any pending, an unboundaried reader + * opens the entry — and the transaction, judged complete on its other + * flights, revealed the inputs beside the reader's pre-flight value + * otherwise (`Count: 1 | A: 1` beside `B: 0`). A staged signal or a settled + * node registers nothing. Every reporter dies with its reader * (reporterBlocksSource: the read linked it as a dep). The node's own entry * is the only one that can matter: a chain's intermediate memo is re-pulled * by the read (updateIfNecessary's retry) and enters the transaction, so the @@ -1500,7 +1547,10 @@ function heldFromStale(el: Signal | Computed, c: Computed): boole const txn = currentTransition(t); const vt: Transition | null | undefined = (c as any)._valueTransition; if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); - txn._asyncReporters.get(el as Computed)?.add(c); + const reporters = txn._asyncReporters.get(el as Computed); + if (reporters) reporters.add(c); + else if ((el as Computed)._statusFlags & STATUS_PENDING) + runInTransition(txn, () => c._queue.notify(c, STATUS_PENDING, STATUS_PENDING, el._x!._error)); return true; } @@ -1874,13 +1924,14 @@ export function read(el: Signal | Computed): T { // yet the active override — fall through to the normal selection (the // authoritative mark below still applies: until() must wake on landing). if (!(c && c._config & CONFIG_AUTHORITATIVE_READ) && !unflushedOverride(el)) { - // A18 supersession (#3331): the node's own source answered with a - // DIFFERENT value. The optimism is over for the graph — a tracked - // reader sees the staged truth — while the override remains the - // DISPLAYED value for untracked reads (and for a stale reader of some - // other transaction). The selection lives with the engine. - if (c && el._config & CONFIG_OVERRIDE_SUPERSEDED) - return GlobalQueue._supersededRead!(el) as T; + // A tracked read of an override is the engine's selection (a lane or a + // supersession implies the engine): a render effect OFF the override's + // held lane sees the committed value (#3460, lanes mirror transitions); + // a node whose own source answered with a DIFFERENT value hands a + // tracked reader the staged truth (A18 supersession, #3331). Untracked + // reads display the override. + if (c && el._config & (CONFIG_HAS_LANE | CONFIG_OVERRIDE_SUPERSEDED)) + return GlobalQueue._overrideRead!(el as Computed, c as Computed) as T; return unwrapOverride(el._x?._overrideValue); } el._config |= CONFIG_AUTHORITATIVE_OBSERVED; diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index e6eb00f89..3dd12e3c3 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -1,5 +1,11 @@ -import { CONFIG_HAS_LANE, NOT_PENDING } from "./constants.js"; -import { ext } from "./core.js"; +import { + CONFIG_DERIVED_OVERRIDE, + CONFIG_HAS_LANE, + NOT_PENDING, + REACTIVE_DISPOSED +} from "./constants.js"; +import { currentOptimisticLane, ext } from "./core.js"; +import { enqueueSub } from "./heap.js"; import { activeTransition, currentTransition, @@ -111,6 +117,42 @@ export function laneHeld(lane: OptimisticLane): boolean { return false; } +/** + * Lanes mirror transitions (#3460): a render effect OFF a HELD lane that reads + * a value the lane is revealing — an override, a `latest()` shadow — sees the + * committed value, exactly as a stale reader of a held transaction does + * (A15 reveal corollary): it publishes now, with the frame that is on screen + * (the lane defers its own readers' runs, so the committed value is what is + * visible), entangles nothing — a sync write is never held by a lane — and + * re-derives at the release. The release re-run rides the lane's own render + * queue, which runs when the lane reveals (runLaneEffects) or its transaction + * commits (cleanupCompletedLanes). A reader ON the lane computes the lane's + * reveal and takes the value as before. + * + * OFF the lane is provenance, not membership — the transaction mirror + * exactly: a stale reader of a transaction is a pass that runs outside it. A + * pass under the lane's own transaction is the lane's work — its write (lane + * posture), or the landing of its async, which re-enters the transaction + * (#3334) and runs a member with no ambient lane. Read as an outsider, that + * pass published the committed view and queued a replay that revealed the + * override beside its unready derivation at the release (`1:0` for an + * optimistic frame that never became ready; #3479 review). Membership is the + * wrong test the other way: a member re-run by a sibling's sync write is a + * mainline pass and shows the committed view. + */ +export function readsHeldCommitted(owner: Computed, c: Computed): boolean { + const lane = resolveLane(owner); + if (!lane || !laneHeld(lane)) return false; + const t = activeTransition && resolveTransition(owner); + if ( + (t && currentTransition(t) === currentTransition(activeTransition!)) || + (currentOptimisticLane !== null && findLane(currentOptimisticLane) === lane) + ) + return false; + lane._effectQueues[0].push(() => c._flags & REACTIVE_DISPOSED || enqueueSub(c)); + return true; +} + /** * Merge two lanes when their dependency graphs overlap. */ @@ -186,7 +228,13 @@ export function assignOrMergeLane( // held parent, where its verdict waited on the async it reports (#3409). const existingRoot = findLane(existing); if (activeLanes.has(existingRoot)) { - if (existingRoot !== sourceRoot && !hasActiveOverride(el)) { + // A WRITTEN override is its own lane's source and merges nothing + // through it; a derived one (lanes stage, #3479) is a plain member — + // the shared reader that merges two writers' lanes carries one. + if ( + existingRoot !== sourceRoot && + (!hasActiveOverride(el) || (el as any)._config & CONFIG_DERIVED_OVERRIDE) + ) { // Parent-child lanes stay independent so isPending resolves without // waiting for the parent's async. The child keeps ownership. if (sourceRoot._parentLane && findLane(sourceRoot._parentLane) === existingRoot) { diff --git a/packages/signals/src/core/optimistic.ts b/packages/signals/src/core/optimistic.ts index 07e147582..750bc91ee 100644 --- a/packages/signals/src/core/optimistic.ts +++ b/packages/signals/src/core/optimistic.ts @@ -25,7 +25,9 @@ import { STATUS_PENDING, STATUS_UNINITIALIZED, CONFIG_AUTHORITATIVE_OBSERVED, + CONFIG_DERIVED_OVERRIDE, CONFIG_HAS_LANE, + CONFIG_OPTIMISTIC, CONFIG_OVERRIDE_SUPERSEDED } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; @@ -39,6 +41,7 @@ import { getOrCreateLane, hasActiveOverride, laneHeld, + readsHeldCommitted, resolveLane, resolveTransition, signalLanes, @@ -47,6 +50,7 @@ import { import { activeTransition, clock, + currentTransition, GlobalQueue, globalQueue, insertSubs, @@ -113,7 +117,8 @@ function optimisticWrite(el: Signal | Computed, v: T | ((prev: T) => T) ext(el)._optimisticLane = lane; // A fresh override re-masks: whatever truth is staged, this write is the // value for the graph again until the source answers it (#3331). - el._config = (el._config | CONFIG_HAS_LANE) & ~CONFIG_OVERRIDE_SUPERSEDED; + el._config = + (el._config | CONFIG_HAS_LANE) & ~(CONFIG_OVERRIDE_SUPERSEDED | CONFIG_DERIVED_OVERRIDE); // Literal undefined must not land raw: the slot doubles as the optimistic // brand, and erasing it makes the write invisible and routes follow-up @@ -133,15 +138,67 @@ function optimisticWrite(el: Signal | Computed, v: T | ((prev: T) => T) return v; } +/** + * Lanes stage (#3479): a lane pass's publish for a memo. An optimistic + * derivation is an override — the speculative result lives in the override + * slot, `_value` stays the committed truth. The whole optimistic frame is then + * in one place: the lane's readers and untracked reads see it (A17), a render + * effect off the held lane sees the committed frame whole (readsHeldCommitted, + * #3460) — the source's shadow AND its derivations — where a speculative + * `_value` beside a committed shadow tore it. The node joins the + * transaction's optimistic nodes on its first speculative publish; the revert + * drops the override and re-derives it from the truth (a derived override has + * no truth of its own — see resolveOptimisticNodes, endOptimism). + */ +function laneOverride(el: Computed, value: unknown, lane: OptimisticLane): void { + // The wake-only channel (#3009, see recomputeLane): a plain write to a + // latest()-tracked source rides a companion-sourced lane with no + // transaction on either side only to wake the verdict companions. Nothing + // is speculative — the pass commits directly, as any plain write does. + lane = findLane(lane); + if (!lane._transition && !activeTransition && lane._source._x?._parentSource !== undefined) { + el._value = value; + return; + } + if (!hasActiveOverride(el)) { + // It reverts with the lane's transaction (a landing runs outside any + // flush, where the ambient batch would revert it at its own end); an + // orphan lane's falls to the batch, adopted with it (initTransition) as a + // write's is. No `_overrideOwner`: a derived override is a plain member, + // its transaction its lane's (resolveTransition), and it merges lanes + // through itself as any shared reader does (assignOrMergeLane). No + // provenance stamp either: not an intent, any truth supersedes it. + (lane._transition + ? currentTransition(lane._transition) + : globalQueue._batch + )._optimisticNodes.push(el); + } + // A lane pass's output is a derivation — also over a WRITTEN guess it + // corrects (a `createOptimistic(fn)` re-derived from fresh upstream data): + // the guess is gone, the slot holds fn's answer, and the revert promotes it + // rather than dropping to a stale `_value` and re-asking downstream (the + // next user write re-arms the guess: optimisticWrite clears the bit). No + // `_overrideTime` stamp: that marks a user WRITE unflushed until the flush + // that carries it (A28) and shields it from same-tick supersession — a pass's + // result is neither (a pulled ownerless memo publishes outside any flush). + // A fresh lane frame ends a supersession in force: the pass just dropped + // the staged truth it pointed at (recompute, INV-11 corollary) — left set, + // the flag served a `_value` never committed (fuzzer latest-1 #2481). + el._config = (el._config | CONFIG_DERIVED_OVERRIDE) & ~CONFIG_OVERRIDE_SUPERSEDED; + el._x!._overrideValue = value === undefined ? OVERRIDE_UNDEFINED : value; +} + /** * transitionComplete's override blockage: a settling transition stays open * while one of its optimistic nodes holds an active override that is still - * pending on real (non-affects-sentinel) async. + * pending on real (non-affects-sentinel) async. A derived override's flight is + * the lane's own work, never authoritative — it does not hold the settle. */ function transitionBlocked(transition: Transition): boolean { for (let i = 0; i < transition._optimisticNodes.length; i++) { const node = transition._optimisticNodes[i]; if ( + !(node._config & CONFIG_DERIVED_OVERRIDE) && hasActiveOverride(node) && "_statusFlags" in node && (node as Computed)._statusFlags & STATUS_PENDING && @@ -167,14 +224,31 @@ function resolveOptimisticNodes(nodes: OptimisticNode[]): void { if (!((node as any)._statusFlags & STATUS_PENDING)) (node as any)._statusFlags &= ~STATUS_UNINITIALIZED; const prevOverride = node._x?._overrideValue; - ext(node)._overrideValue = NOT_PENDING; + // A derived override (lanes stage, #3479) has no truth of its own: the + // slot disarms — the memo is plain again — and the override PROMOTES to + // `_value`. Not superseded, nothing it derives from told it otherwise + // (a source override that reverts to a differing truth dirties it just + // above — sources join this list before their derivations — and its + // recompute then replaces the promotion), so by the graph's invariant + // the override IS what a recompute from the truth yields. Re-deriving + // instead re-asked an async member's flight and held the transaction on + // it — a waterfall after the reveal. + const derived = node._config & CONFIG_DERIVED_OVERRIDE; + ext(node)._overrideValue = + derived && !(node._config & CONFIG_OPTIMISTIC) ? undefined : NOT_PENDING; // A superseded override's subscribers already re-derived from the truth // when it arrived (#3331) — the drop changes nothing they read. Everyone // else learns of the correction here: this drop IS their notification. const superseded = (node._config & CONFIG_OVERRIDE_SUPERSEDED) !== 0; - node._config &= ~CONFIG_OVERRIDE_SUPERSEDED; - if (!superseded && prevOverride !== NOT_PENDING && node._value !== unwrapOverride(prevOverride)) - insertSubs(node, true); + node._config &= ~(CONFIG_OVERRIDE_SUPERSEDED | CONFIG_DERIVED_OVERRIDE); + if ( + !superseded && + prevOverride !== NOT_PENDING && + node._value !== unwrapOverride(prevOverride) + ) { + if (derived) node._value = unwrapOverride(prevOverride); + else insertSubs(node, true); + } node._transition = null; if (node._x !== null) node._x._overrideOwner = null; } @@ -305,7 +379,7 @@ function endOptimism(transition: Transition): boolean { return false; for (const source of transition._asyncReporters.keys()) if ( - sourceObserved(transition, source) && + sourceObserved(transition, source, transition) && source._x?._pendingSources?.has(source) && !resolveLane(source) ) @@ -315,7 +389,8 @@ function endOptimism(transition: Transition): boolean { if ( !hasActiveOverride(node) || node._x!._parentSource || - node._config & CONFIG_OVERRIDE_SUPERSEDED || + // A derived override re-derives when its source's is superseded. + node._config & (CONFIG_OVERRIDE_SUPERSEDED | CONFIG_DERIVED_OVERRIDE) || (node as Computed)._statusFlags & STATUS_UNINITIALIZED ) continue; @@ -337,7 +412,23 @@ function endOptimism(transition: Transition): boolean { * has left) — or the displayed override for a stale (render) reader of some * OTHER transaction, the same visibility a foreign transaction's staged * write has. */ -function supersededRead(el: OptimisticNode): unknown { +/** + * A tracked read of an active override (read()'s override arm). Lanes mirror + * transitions (#3460): a render effect OFF the override's held lane — re-run + * by a sync write, or mounted mid-hold — sees the committed value, as a stale + * reader of a held transaction does, and publishes now; the lane's release + * re-runs it (readsHeldCommitted). The lane defers the override's own readers' + * runs, so the committed value is what is on screen — the override is the + * visible value only once the lane has revealed (or, demoted at body-end, + * A18). Otherwise the override displays, unless the node's own source + * answered with a DIFFERENT value (A18 supersession, #3331): the optimism is + * over for the graph — a tracked reader sees the staged truth — while the + * override remains the DISPLAYED value for untracked reads (and for a stale + * reader of some other transaction). + */ +function overrideRead(el: OptimisticNode, c: Computed): unknown { + if (stale && readsHeldCommitted(el as Computed, c)) return el._value; + if (!(el._config & CONFIG_OVERRIDE_SUPERSEDED)) return unwrapOverride(el._x?._overrideValue); // The owning transaction: `_overrideOwner` (#2912), not the stamp — an // override written directly inside an action never passes the adoption // loop that stamps `_transition`, and a body-end supersession (#3427) @@ -432,11 +523,16 @@ function laneSuspends(owner: OptimisticNode): boolean { // this is only reachable under a lane, which implies the engine. if ((owner as Computed)._statusFlags & STATUS_UNINITIALIZED) return true; // Per-lane suspension: only throw if in same lane as pending async - // AND the node doesn't have an active override (overrides are the visible value, - // downstream in the lane should read the override, not throw) + // AND the node doesn't have an active WRITTEN override (overrides are the + // visible value, downstream in the lane should read the override, not + // throw). A derived override (#3479) is a previous speculative answer, not + // an intent: the re-ask pending behind it suspends like any lane async. const pendingLane = (owner as any)._x?._optimisticLane as OptimisticLane | undefined; if (!pendingLane) return false; - return findLane(pendingLane) === findLane(currentOptimisticLane!) && !hasActiveOverride(owner); + return ( + findLane(pendingLane) === findLane(currentOptimisticLane!) && + (!hasActiveOverride(owner) || (owner._config & CONFIG_DERIVED_OVERRIDE) !== 0) + ); } /** @@ -602,7 +698,8 @@ export function installOptimisticEngine(): void { GlobalQueue._runLaneEffects = runLaneEffects; GlobalQueue._supersedeOverride = supersedeOverride; GlobalQueue._endOptimism = endOptimism; - GlobalQueue._supersededRead = supersededRead; + GlobalQueue._overrideRead = overrideRead; + GlobalQueue._laneOverride = laneOverride; GlobalQueue._landOnOverride = landOnOverride; GlobalQueue._gatedRead = gatedRead; GlobalQueue._laneSuspends = laneSuspends; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index ed04dc0d5..e37089c91 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -695,7 +695,14 @@ export class GlobalQueue extends Queue { * staged truth, unless the reader is a stale (render) reader of another * transaction — then the displayed override, as it keeps a foreign * transaction's committed value over its staged write. */ - static _supersededRead: ((el: Signal | Computed) => unknown) | null = null; + /** A tracked read of an active override: the lane outside-view rule + * (#3460) and the A18 supersession selection (#3331) — see optimistic.ts. */ + static _overrideRead: ((el: Computed, c: Computed) => unknown) | null = null; + /** A lane pass's publish for a memo (#3479, lanes stage): the speculative + * result becomes a DERIVED override, `_value` stays committed — see + * optimistic.ts laneOverride. Set with the engine, which a lane implies. */ + static _laneOverride: ((el: Computed, value: unknown, lane: OptimisticLane) => void) | null = + null; /** Verdict-layer recompute in progress (companion creation, latest()/ * isPending() pulls): never born held — see core.ts enterStagedRead. */ static _verdictPull = false; @@ -767,6 +774,8 @@ export class GlobalQueue extends Queue { const isComplete = transitionComplete(activeTransition); if (!isComplete) { const stashedTransition = activeTransition!; + // Parked: the unchanged passes' inputs are held; their tails stay (A30). + heldTrims.length = 0; // When the parking batch IS the transition, all of its writes commit // only with it — every zombie recompute they queued would run against // a world the zombie never displays (zombies render mainline until @@ -1179,7 +1188,18 @@ export function setPatchCommitHook(fn: (batch: Transition) => void): void { * frame no timeline contains. */ const heldRevealed: Signal[] = []; +/** Unchanged passes with a stale dependency tail, waiting on this flush's + * verdict (A30, #3469). A pass that changed nothing replaced nothing either — + * and cannot know at its own tail whether the flush that ran it will park: + * parked, its inputs are held and the committed frame still derives from the + * tail (`b() ? b() : a()` computed `1` from the held `b`, equal to the `1` it + * had from `a` — with `a` trimmed, the mainline `a = 2` never reached it). + * Trimmed when the flush commits; dropped with a park, the tail stays linked + * until a committing pass trims it (one spurious recompute at most). */ +export const heldTrims: Computed[] = []; + function commitPendingNodes() { + while (heldTrims.length) trimStaleDeps(heldTrims.pop()!); const pendingNodes = currentBatch._pendingNodes; for (let i = 0; i < pendingNodes.length; i++) { const node = pendingNodes[i]; @@ -1476,8 +1496,28 @@ function runQueue(queue: QueueCallback[], type: number): void { for (let i = 0; i < queue.length; i++) queue[i](type); } -function reporterBlocksSource(reporter: Computed, source: Computed): boolean { - if (reporter._flags & (REACTIVE_ZOMBIE | REACTIVE_DISPOSED)) return false; +function reporterBlocksSource( + reporter: Computed, + source: Computed, + verdict?: Transition +): boolean { + const flags = reporter._flags; + if (flags & REACTIVE_DISPOSED) return false; + // A zombie renders until the commit that disposes it (#3463): while its + // removal is staged in a live transaction it is still on screen, and what + // it displays must stay consistent with the frame — a held `Show`'s + // `Details: 0` beside the lane's `Value: 1` otherwise. Its say is moot for + // the verdict of the transaction that stages the removal (`verdict`): done, + // and the commit disposes it; not done, and it stays parked regardless. A + // zombie whose removal commits this flush (owner's pass not held) is dead. + // The owner is stamped when the flush parks; held in this flush, its + // staging transaction is the active one. + if (flags & REACTIVE_ZOMBIE) { + let p: Computed | null = reporter; + while (p && p._flags & REACTIVE_ZOMBIE) p = p._parent as Computed | null; + let t = p && (p._transition || (p._config & CONFIG_HELD_CHILDREN ? activeTransition : null)); + if (!t || (t = currentTransition(t))._done === true || t === verdict) return false; + } // Fallback-caught async holds nothing. A collecting loading boundary // consumes the notification, so a reader under a fallback never registers — // but a reader registered while its boundary showed content stays @@ -1511,13 +1551,23 @@ function reporterBlocksSource(reporter: Computed, source: Computed): b * gets — without it the lane held on the dead reporter's registration until * the flight it no longer observed landed (#3426). */ -export function sourceObserved(transition: Transition, source: Computed): boolean { +export function sourceObserved( + transition: Transition, + source: Computed, + verdict?: Transition +): boolean { const reporters = transition._asyncReporters.get(source); + let kept = false; for (const reporter of reporters ?? []) { - if (reporterBlocksSource(reporter, source)) return true; - reporters!.delete(reporter); + if (reporterBlocksSource(reporter, source, verdict)) return true; + // A zombie the verdict passes over is kept, not pruned (#3463): moot for + // this verdict, it still holds a lane's reveal while the transaction + // stays parked on something else. + if (verdict && reporter._flags & REACTIVE_ZOMBIE) kept = true; + else reporters!.delete(reporter); } - return transition._asyncReporters.delete(source) && false; + if (!kept) transition._asyncReporters.delete(source); + return false; } function transitionComplete(transition: Transition): boolean { @@ -1543,7 +1593,7 @@ function transitionComplete(transition: Transition): boolean { // (enterWaiting). Judged complete instead, a re-entry between the two (a // repeated write to a held signal) committed the held writes beside the // reader's stale frame. - if (sourceObserved(transition, source) && source._x?._pendingSources?.size) { + if (sourceObserved(transition, source, transition) && source._x?._pendingSources?.size) { done = false; break; } diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index f3a406cab..ea97cb6dd 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -48,7 +48,13 @@ import { NotReadyError } from "./error.js"; import { link } from "./graph.js"; import { enqueueSub, insertIntoHeap, markHeap, queueFor } from "./heap.js"; import { devTrackCompanionOwner, InvariantHooks } from "./invariants.js"; -import { assignOrMergeLane, findLane, hasActiveOverride, laneHeld } from "./lanes.js"; +import { + assignOrMergeLane, + findLane, + hasActiveOverride, + laneHeld, + readsHeldCommitted +} from "./lanes.js"; import { installOptimisticEngine } from "./optimistic.js"; import { activeAffectsMarks, @@ -525,13 +531,13 @@ function latestRead(el: Signal | Computed): T { if (uninitializedSource(el)) throw new NotReadyError(el); return visibleValue; } - if (stale && currentOptimisticLane && pendingComputed._x?._optimisticLane) { - const pcLane = findLane(pendingComputed._x?._optimisticLane); - const curLane = findLane(currentOptimisticLane); - if (pcLane !== curLane && laneHeld(pcLane)) { - return visibleValue; - } - } + // A render effect off the shadow's HELD lane sees the committed value and + // re-runs at the release (#3460; lanes mirror transitions — see + // readsHeldCommitted). Was: only a reader under ANOTHER lane; a mainline + // reader, mounted or re-run by a sync write mid-hold, showed the + // speculative value beside the lane's deferred readers. + if (stale && context !== null && readsHeldCommitted(pendingComputed, context as Computed)) + return el._value as T; // A shadow recomputed by the pull above (not at creation) holds its fresh // speculative value in _pendingValue; a contextless read() only surfaces // _value. Overrides stay authoritative (A17), and stale readers keep the diff --git a/packages/signals/tests/first-observer-stale-reader.test.ts b/packages/signals/tests/first-observer-stale-reader.test.ts new file mode 100644 index 000000000..33519d61a --- /dev/null +++ b/packages/signals/tests/first-observer-stale-reader.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { createMemo, createRenderEffect, createRoot, createSignal, flush } from "../src/index.js"; + +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + const due = timers.filter(x => x.at <= t).sort((a, b) => a.at - b.at); + if (!due.length) break; + const next = due[0]; + timers = timers.filter(x => x !== next); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} +function text(fn: () => string, log: string[], when: number[]) { + createRenderEffect(fn, v => { + log.push(v); + when.push(now); + }); +} +function frames(log: string[], when: number[]) { + const m = new Map(); + log.forEach((l, i) => { + (m.get(when[i]) ?? m.set(when[i], []).get(when[i])!).push(l); + }); + return [...m].map(([t, ls]) => `${t}: ${ls.sort().join(" | ")}`); +} + +// A15 reveal corollary, first-observer arm (#3458): `count=1` opens T1 holding +// on `a` (its reader observed it); `b`'s flight is up too but nobody reads it. +// `show` flips mainline and B reads `b` as a stale reader of T1 — served the +// committed `0`, coherent with the frame. It is the flight's FIRST observer: +// T1 held no entry for `b`, the join found nothing, and T1 — judged complete +// on `a` alone — revealed `Count: 1 | A: 1` beside `B: 0`. The observation is +// now notified up the reader's queue chain under T1 (INV-3: the one +// registration site), and T1 waits for `b`. +describe("a stale reader that is a flight's first observer registers it (#3458)", () => { + it("revealing a pending memo holds the transaction on it", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void; + let setShow!: (v: boolean) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + const [show, ss] = createSignal(false); + setCount = sc; + setShow = ss; + const a = createMemo(() => delay(1000, count())); + const b = createMemo(() => delay(2000, count())); + text(() => `Count: ${count()}`, log, when); + text(() => `A: ${a()}`, log, when); + text(() => `B: ${show() ? b() : "hidden"}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + setCount(1); + await settle(); + await advanceTo(3500); + setShow(true); + await settle(); + await advanceTo(9000); + expect(frames(log, when)).toEqual([ + "0: B: hidden | Count: 0", + "1000: A: 0", + "3500: B: 0", + "5000: A: 1 | B: 1 | Count: 1" + ]); + }); +}); diff --git a/packages/signals/tests/held-frame-dependencies.test.ts b/packages/signals/tests/held-frame-dependencies.test.ts new file mode 100644 index 000000000..3977fb6cb --- /dev/null +++ b/packages/signals/tests/held-frame-dependencies.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { createMemo, createRenderEffect, createRoot, createSignal, flush } from "../src/index.js"; + +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + const due = timers.filter(x => x.at <= t).sort((a, b) => a.at - b.at); + if (!due.length) break; + const next = due[0]; + timers = timers.filter(x => x !== next); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} +function text(fn: () => string, log: string[], when: number[]) { + createRenderEffect(fn, v => { + log.push(v); + when.push(now); + }); +} +function frames(log: string[], when: number[]) { + const m = new Map(); + log.forEach((l, i) => { + (m.get(when[i]) ?? m.set(when[i], []).get(when[i])!).push(l); + }); + return [...m].map(([t, ls]) => `${t}: ${ls.sort().join(" | ")}`); +} + +// A30: dependencies are the committed frame's until it is replaced. A pass +// that changed nothing replaced nothing either (#3469): `selected = b() ? b() +// : a()` computed `1` from the held `b=1`, equal to the `1` it had from `a`, +// and trimmed `a` at its tail — but the flush parked (b's flight is observed), +// the committed frame still derives from `a`, and the mainline `a=2` never +// reached it: `A: 2 | B: 0 | Selected: 1`. The trim now waits on the flush's +// verdict: trimmed when it commits, kept when it parks. +describe("an unchanged pass keeps the committed frame's dependencies (A30, #3469)", () => { + it("a memo whose held pass computed the same value still follows its committed inputs", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setA!: (v: number) => void; + let setB!: (v: number) => void; + createRoot(() => { + const [a, sa] = createSignal(1); + const [b, sb] = createSignal(0); + setA = sa; + setB = sb; + const slow = createMemo(() => delay(2000, b())); + const selected = createMemo(() => (b() ? b() : a())); + text(() => `A: ${a()}`, log, when); + text(() => `B: ${b()}`, log, when); + text(() => `Loaded B: ${slow()}`, log, when); + text(() => `Selected: ${selected()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + setB(1); + await settle(); + await advanceTo(3500); + setA(2); + await settle(); + await advanceTo(9000); + // `selected` re-derives on the write and is served b's staged value, so it + // enters the hold (A29) and `a=2` is held with `b` — the #3443 consequence: + // the write whose derivation flows into a held memo is held with it. + expect(frames(log, when)).toEqual([ + "0: A: 1 | B: 0 | Selected: 1", + "2000: Loaded B: 0", + "5000: A: 2 | B: 1 | Loaded B: 1" + ]); + }); + + // Effect arm: a render effect is a stale reader — served the committed `b`, + // it publishes `2` at once (a sync write is never held by a transaction) + // and re-derives at the commit. The memo above, served the staged `b`, + // enters the transaction instead (A29) and holds `a=2` with it. + it("a render effect whose held pass computed the same value follows its inputs at once", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setA!: (v: number) => void; + let setB!: (v: number) => void; + createRoot(() => { + const [a, sa] = createSignal(1); + const [b, sb] = createSignal(0); + setA = sa; + setB = sb; + const slow = createMemo(() => delay(2000, b())); + text(() => `Loaded B: ${slow()}`, log, when); + text(() => `Selected: ${b() ? b() : a()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + setB(1); + await settle(); + await advanceTo(3500); + setA(2); + await settle(); + await advanceTo(9000); + expect(frames(log, when)).toEqual([ + "0: Selected: 1", + "2000: Loaded B: 0", + "3500: Selected: 2", + "5000: Loaded B: 1 | Selected: 1" + ]); + }); +}); diff --git a/packages/signals/tests/lane-outside-view.test.ts b/packages/signals/tests/lane-outside-view.test.ts new file mode 100644 index 000000000..53cef756b --- /dev/null +++ b/packages/signals/tests/lane-outside-view.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createLoadingBoundary, + createSignal, + flush, + latest, + onCleanup +} from "../src/index.js"; + +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + const due = timers.filter(x => x.at <= t).sort((a, b) => a.at - b.at); + if (!due.length) break; + const next = due[0]; + timers = timers.filter(x => x !== next); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} +function text(fn: () => string, log: string[], when: number[]) { + createRenderEffect(fn, v => { + log.push(v); + when.push(now); + }); +} +function frames(log: string[], when: number[]) { + const m = new Map(); + log.forEach((l, i) => { + (m.get(when[i]) ?? m.set(when[i], []).get(when[i])!).push(l); + }); + return [...m].map(([t, ls]) => `${t}: ${ls.sort().join(" | ")}`); +} + +// A held lane is a transaction seen from the outside (A15 lanes corollary, +// #3460 / #3463): a render effect OFF the lane sees the committed value — the +// lane defers its own readers' runs, so that is what is on screen — publishes +// now (a sync write is never held by a lane), and re-derives at the release. +// Inside, a reader ON the lane computes the lane's reveal as before. +describe("a held lane from the outside", () => { + // `Value` and `Details` are dirtied by the action's write and ride the + // lane; `Details`' flight holds it. `Late` mounts a flush later, mainline, + // and used to read the shadow's speculative `1` beside the deferred + // `Value: 0`. Now it shows `0`, and the release re-runs it. + it("#3460 a latest() reader mounted mid-hold shows the visible value and reveals with the lane", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setShow!: (v: boolean) => void; + let start!: () => unknown; + createRoot(() => { + const [count, setCount] = createSignal(0); + const [show, ss] = createSignal(false); + setShow = ss; + const value = () => latest(count); + const details = createMemo(() => delay(2000, value())); + start = action(function* () { + setCount(1); + yield delay(3000); + setCount(0); + }); + text(() => `Value: ${value()}`, log, when); + text(() => `Details: ${details()}`, log, when); + text(() => `Late: ${show() ? value() : "hidden"}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + start(); + await settle(); + await advanceTo(3500); + setShow(true); + await settle(); + await advanceTo(12000); + expect(frames(log, when)).toEqual([ + "0: Late: hidden | Value: 0", + "2000: Details: 0", + "3500: Late: 0", + "5000: Details: 1 | Late: 1 | Value: 1", + "8000: Details: 0 | Late: 0 | Value: 0" + ]); + }); + + it("#3460 a createOptimistic reader mounted mid-hold behaves the same (latest() is not special)", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setShow!: (v: boolean) => void; + let start!: () => unknown; + createRoot(() => { + const [count, setCount] = createOptimistic(0); + const [show, ss] = createSignal(false); + setShow = ss; + const details = createMemo(() => delay(2000, count())); + start = action(function* () { + setCount(1); + yield delay(3000); + }); + text(() => `Value: ${count()}`, log, when); + text(() => `Details: ${details()}`, log, when); + text(() => `Late: ${show() ? count() : "hidden"}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + start(); + await settle(); + await advanceTo(3500); + setShow(true); + await settle(); + await advanceTo(12000); + expect(frames(log, when)).toEqual([ + "0: Late: hidden | Value: 0", + "2000: Details: 0", + "3500: Late: 0", + "5000: Details: 1 | Late: 1 | Value: 1", + "8000: Details: 0 | Late: 0 | Value: 0" + ]); + }); + + // Maintainer: "we wouldn't hold a sync write on a transition. Lanes are the + // same." The sibling write re-runs the effect, which publishes at once with + // the committed view of the held value, and again at the release. + it("#3460 a sync write re-running a reader mid-hold reveals at once with the committed view", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setOther!: (v: string) => void; + let start!: () => unknown; + createRoot(() => { + const [count, setCount] = createSignal(0); + const [other, so] = createSignal("x"); + setOther = so; + const value = () => latest(count); + const details = createMemo(() => delay(2000, value())); + start = action(function* () { + setCount(1); + yield delay(3000); + setCount(0); + }); + text(() => `Value: ${value()}`, log, when); + text(() => `Details: ${details()}`, log, when); + text(() => `Both: ${value()} ${other()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + start(); + await settle(); + await advanceTo(3500); + setOther("y"); + await settle(); + await advanceTo(12000); + const fs = frames(log, when); + expect(fs.slice(0, 4)).toEqual([ + "0: Both: 0 x | Value: 0", + "2000: Details: 0", + "3500: Both: 0 y", + "5000: Both: 1 y | Details: 1 | Value: 1" + ]); + // The revert at 8000 runs `Both` twice on `next` today (both `0 y`); + // pinned by value, not by count. + expect(fs.length).toBe(5); + expect([...new Set(fs[4].slice(6).split(" | "))]).toEqual([ + "Both: 0 y", + "Details: 0", + "Value: 0" + ]); + }); + + // Lanes stage (#3479 review, fuzzer latest-1 #1955): the committed view an + // outsider sees must be a WHOLE frame. A memo derived from the held value + // used to publish its speculative result straight into `_value` (the lane's + // direct commit), so a reader mounted mid-hold saw the source's committed + // `0` beside the derivation's speculative `1` — a torn tuple. A lane pass + // now publishes a derived override: `_value` stays committed for the + // outsider, the override is the lane's view, and the release re-runs the + // outsider with the revealed frame. + it("#3479 an outsider mounted mid-hold sees the held value and its derivation as one committed frame", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setShow!: (v: boolean) => void; + let start!: () => unknown; + createRoot(() => { + const [count, setCount] = createSignal(0); + const [show, ss] = createSignal(false); + setShow = ss; + const value = () => latest(count); + const derived = createMemo(() => value()); + const details = createMemo(() => delay(2000, value())); + start = action(function* () { + setCount(1); + yield delay(3000); + }); + text(() => `Value: ${value()}`, log, when); + text(() => `Derived: ${derived()}`, log, when); + text(() => `Details: ${details()}`, log, when); + text(() => `Late: ${show() ? `${value()} ${derived()}` : "hidden"}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + start(); + await settle(); + await advanceTo(3500); + setShow(true); + await settle(); + await advanceTo(12000); + // `Late: 0 0` at 3500 (was `0 1`); `1 1` with the lane's reveal at 5000. + // No frame after the reveal: the revert PROMOTES the derived overrides + // (the truth confirmed the guess) instead of re-deriving `details` and + // re-asking its flight — which held the transaction to 8000 for a frame + // identical to the one on screen. + expect(frames(log, when)).toEqual([ + "0: Derived: 0 | Late: hidden | Value: 0", + "2000: Details: 0", + "3500: Late: 0 0", + "5000: Derived: 1 | Details: 1 | Late: 1 1 | Value: 1" + ]); + }); + + // #3479 review (gabbev, differential fuzzing): the optimistic `1` never + // finishes preparing — `details` is still in flight when the body ends and + // the truth (`0`) supersedes. The landing of `fast` re-enters the lane's + // transaction with no ambient lane; read as an outsider, that pass published + // the committed view and queued a replay that later revealed `1` beside the + // stale `0` derivation (`1:0`). A pass under the lane's own transaction is + // the lane's work (provenance, not membership). + it("#3479 an optimistic value that never finished preparing does not show beside its old derivation", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let save!: () => unknown; + createRoot(() => { + const [source, setSource] = createSignal(0); + const [value, setValue] = createOptimistic(source); + const fast = createMemo(async () => value()); + const details = createMemo(() => delay(1500, fast())); + save = action(function* () { + setValue(1); + yield delay(500); + setSource(0); + }); + text(() => `${value()}:${details()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3000); + save(); + await settle(); + await advanceTo(9000); + // Never `1:0`, and never `1:1` (the guess was wrong). + expect(frames(log, when).map(f => f.split(": ")[1])).toEqual(["0:0", "0:0"]); + }); + + // #3479 review (fuzzer latest-1 #2481): a loading boundary mounted mid-hold + // holds a memo born under the lane — its first pass threw, `_value` never + // committed. Its landing dirtied the memo twice: the lane pass published + // the derived override, then the boundary reset re-ran it PLAIN. Still a + // lane member, that pass re-derived the lane's view (a fresh tuple) and + // A18's sync twin took it for a differing truth: superseded, lane demoted. + // The lane's next pass dropped the staged "truth" and left the flag — + // readers were served the never-committed `undefined`. A pass over a live + // lane member carrying a derived override is the lane's pass. + it("#3479 a boundary mounted mid-hold over a lane-born memo reveals a whole tuple, never undefined", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setMounted!: (v: boolean) => void; + let start!: () => unknown; + createRoot(() => { + const [source, setSource] = createSignal(0); + const [mounted, sm] = createSignal(false); + setMounted = sm; + const value = () => latest(source); + const node = createMemo(() => delay(1000, value())); + start = action(function* () { + setSource(1); + yield delay(1500); + setSource(0); + yield delay(1500); + setSource(1); + }); + text(() => `Node: ${node()}`, log, when); + createRenderEffect( + () => { + if (!mounted()) return; + createRoot(dispose => { + onCleanup(dispose); + const view = createLoadingBoundary( + () => `${value()} ${node()}`, + () => "loading" + ); + text(() => `Late: ${view()}`, log, when); + }); + }, + () => {} + ); + }); + flush(); + await settle(); + await advanceTo(1000); + start(); + await settle(); + await advanceTo(3000); + setMounted(true); + await settle(); + await advanceTo(9000); + // `Late` never publishes `undefined`; it reveals the whole tuple with the + // lane. `Node` is the lane's own reader and shows each landing. + expect(frames(log, when)).toEqual([ + "1000: Node: 0", + "2000: Node: 1", + "3000: Late: loading", + "3500: Node: 0", + "5000: Late: 1 1 | Node: 1" + ]); + }); + + // #3463: the action stages `show=false`; `Details` is a zombie — its removal + // cannot commit while the action runs, so it stays on screen. It was treated + // as dead for the hold and the lane revealed `Value: 1` beside its `Details: + // 0`. A zombie blocks unless the judgment IS the commit that disposes it. + it("#3463 a reader whose removal is staged keeps holding the lane while it is visible", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setShow!: (v: boolean) => void; + let start!: () => unknown; + createRoot(() => { + const [count, setCount] = createSignal(0); + const [show, ss] = createSignal(true); + setShow = ss; + const value = () => latest(count); + const details = createMemo(() => delay(1000, value())); + start = action(function* () { + setCount(1); + yield delay(2000); + setCount(0); + }); + text(() => `Value: ${value()}`, log, when); + text(() => `Show: ${show()}`, log, when); + createRenderEffect( + () => { + if (show()) { + text(() => `Details: ${details()}`, log, when); + onCleanup(() => { + log.push("Details gone"); + when.push(now); + }); + } + }, + () => {} + ); + }); + flush(); + await settle(); + await advanceTo(3000); + start(); + setShow(false); + await settle(); + await advanceTo(12000); + expect(frames(log, when)).toEqual([ + "0: Show: true | Value: 0", + "1000: Details: 0", + "4000: Details: 1 | Value: 1", + "6000: Details gone | Show: false | Value: 0" + ]); + }); + + // The verdict of the transaction that stages the removal is the commit that + // disposes the zombie: with nothing else holding, the removal commits at + // once and the zombie's say is moot (#3426 spirit: an unmounting reader + // releases). Unchanged behavior, pinned beside its counterpart. + it("#3463 a plain removal with nothing else holding still releases at once", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let run!: () => void; + createRoot(() => { + const [count, setCount] = createSignal(0); + const [show, setShow] = createSignal(true); + const value = () => latest(count); + const details = createMemo(() => delay(1000, value())); + run = () => { + setCount(1); + setShow(false); + }; + text(() => `Value: ${value()}`, log, when); + text(() => `Show: ${show()}`, log, when); + createRenderEffect( + () => { + if (show()) { + text(() => `Details: ${details()}`, log, when); + onCleanup(() => { + log.push("Details gone"); + when.push(now); + }); + } + }, + () => {} + ); + }); + flush(); + await settle(); + await advanceTo(3000); + run(); + await settle(); + await advanceTo(12000); + expect(frames(log, when)).toEqual([ + "0: Show: true | Value: 0", + "1000: Details: 0", + "3000: Details gone | Show: false | Value: 1" + ]); + }); +}); diff --git a/packages/signals/tests/pending-source-repark.test.ts b/packages/signals/tests/pending-source-repark.test.ts new file mode 100644 index 000000000..2f3d47983 --- /dev/null +++ b/packages/signals/tests/pending-source-repark.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { createMemo, createRenderEffect, createRoot, createSignal, flush } from "../src/index.js"; + +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + const due = timers.filter(x => x.at <= t).sort((a, b) => a.at - b.at); + if (!due.length) break; + const next = due[0]; + timers = timers.filter(x => x !== next); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} +function text(fn: () => string, log: string[], when: number[]) { + createRenderEffect(fn, v => { + log.push(v); + when.push(now); + }); +} +function frames(log: string[], when: number[]) { + const m = new Map(); + log.forEach((l, i) => { + (m.get(when[i]) ?? m.set(when[i], []).get(when[i])!).push(l); + }); + return [...m].map(([t, ls]) => `${t}: ${ls.sort().join(" | ")}`); +} + +// A pass that re-parks on a new pending source drops the sources it stopped +// carrying from its dependents (#3456). Panel read `selected`, pending through +// `details`, so Panel recorded `details` as its root pending source; `count=2` +// switched `selected`'s branch — its new flight is its own promise, no longer +// through `details` — and Panel's pass re-threw on `selected`, but the stale +// `details` entry stayed. `selected` landed `0` (equal to its committed `0`, +// no value change) and retired only its own entry; `details`' landing walk +// reached `selected`, found nothing left to retire there, and stopped before +// Panel. Panel stayed pending forever on a source it had no path to. +describe("re-park retires the sources a pass stopped carrying (#3456)", () => { + it("a conditional whose async branch was cancelled shows the settled world", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + setCount = sc; + const details = createMemo(() => delay(1000, count())); + const selected = createMemo(async () => (count() === 1 ? details() : 0)); + text(() => `Count: ${count()}`, log, when); + text(() => `Panel: ${count() ? selected() : "hidden"}`, log, when); + }); + flush(); + await settle(); + await advanceTo(2000); + setCount(1); + await settle(); + await advanceTo(2500); + setCount(2); + await settle(); + await advanceTo(8000); + // `selected(2)` lands 0 in a microtask; nobody displays `details`, so its + // superseding flight (lands 3500) holds nothing: one reveal at 2500. + expect(frames(log, when)).toEqual(["0: Count: 0 | Panel: hidden", "2500: Count: 2 | Panel: 0"]); + }); + + // The sync twin recovers on its own: an errored pass keeps its dropped dep + // linked, so the dropped source's landing still reaches the node. Pinned so + // the async arm above cannot regress it (the sweep skips sources the new + // pass still carries). + it("a branch switch between two pending sources settles on the live one", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + setCount = sc; + const x = createMemo(() => delay(1000, count())); + const y = createMemo(() => delay(3000, count() * 10)); + const selected = createMemo(() => (count() === 1 ? x() : y())); + text(() => `Count: ${count()}`, log, when); + text(() => `Panel: ${selected()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(4000); + setCount(1); + await settle(); + await advanceTo(4500); + setCount(2); + await settle(); + await advanceTo(12000); + expect(frames(log, when)).toEqual([ + "0: Count: 0", + "3000: Panel: 0", + "7500: Count: 2 | Panel: 20" + ]); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 3e408aaf5..797ca857b 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -321,11 +321,27 @@ describe("pay-for-use tree-shaking (#2883)", () => { // (`unflushedStaged`) instead of `_running`: inline, they cost ~140 B of // setSignal bytecode and 10–20% on the write-loop benches (+156 B here). // Measured at 24,478 rebased over #3464–#3471 (`next` 23,750 → 24,478). + // CONSCIOUS BUMP (2026-09-15): five hold-consistency seams (#3456 #3458 + // #3460 #3463 #3469), all core-retained: recompute's re-park sweep over + // the sources a pass stopped carrying; `heldFromStale` notifying a first + // observer's pending up its queue chain; `reporterBlocksSource` walking a + // zombie's owner chain to the transaction staging its removal (+ the + // `verdict` argument through `sourceObserved`); `heldTrims` deferring an + // unchanged pass's dep trim to the flush verdict; and the one + // read()'s override arm folded to one engine hook (`_overrideRead`, + // absorbing `_supersededRead` and carrying the lane outside-view rule, + // whose body lives in lanes.ts and sheds with the engine). Measured at + // 24,836 (24,478 → 24,836, +358; 24,873 before the fold). // A pending reporter recovering without its flight landing wakes its // parked transaction (fuzzer #3446 P1, spec O3, 2026-09-16): +100 B // (24,478 -> 24,578), `wasPending` and the wokenTransitions site at // recompute's tail. - expect(minifiedBytes).toBeLessThan(24_700); + // Lanes stage (#3479 review, 2026-09-16): +139 B core-retained — recompute's + // publish arm routing an optimistic-dirty memo through `_laneOverride`, its + // override test admitting a derived one, and the derived-override posture + // branch (every pass over a live lane member is the lane's pass, fuzzer + // latest-1 #2481). Measured at 25,075 over #3488's 24,936. + expect(minifiedBytes).toBeLessThan(25_200); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 347dfc691..918542352 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -258,7 +258,24 @@ module.exports = [ // companions join it), the override arm's flush gate; the write-path arms are cold // helpers and the read sites test one module flag, keeping the write loop at // parity. +728 B minified in the in-package floor (23,752 -> 24,480). - limit: "9.15 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15; #3456 #3458 #3460 #3463 #3469): + // measured at 9,266 B (+164 B on A28's 9,102). All core-retained: recompute's + // re-park sweep over the sources a pass stopped carrying; `heldFromStale` + // notifying a first observer's pending up its queue chain; `reporterBlocksSource` + // walking a zombie's owner chain to the transaction staging its removal (+ the + // `verdict` argument through `sourceObserved`); `heldTrims` deferring an unchanged + // pass's dep trim to the flush verdict; and read()'s override arm folded to one + // engine hook (`_overrideRead`, absorbing `_supersededRead` and carrying the lane + // outside-view rule, whose body sheds with the engine). +358 B minified in the + // in-package floor (24,478 -> 24,836). Golf measured: terser source compressed + // WORSE under brotli (9,253 -> 9,301); the hook fold is the one that held. + // Lanes stage (#3479 review, 2026-09-15): measured at 9,298 B (+32 B; 9,292 with + // recompute's derived-override posture branch, 2026-09-16 — noise). The + // recompute publish arm routes an optimistic-dirty memo through the + // `_laneOverride` engine hook and its override test admits a derived one; + // the rest (laneOverride, the derived arms in the verdict, lane and status + // modules) sheds with the engine. + limit: "9.35 KB", modifyEsbuildConfig }, { @@ -501,7 +518,9 @@ module.exports = [ // A pending reporter recovering without its flight landing wakes its parked // transaction (fuzzer #3446 P1, spec O3, 2026-09-16): measured at 16,201 B // (+1 over the cap); +100 B minified in the signals floor (24,478 -> 24,578). - limit: "16.25 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 16,343 B; the signals + // core delta, see the core floor note. + limit: "16.45 KB", modifyEsbuildConfig }, { @@ -629,7 +648,15 @@ module.exports = [ // (core floor 8820 -> 8832, +createStore 15743 -> 15780, both in cap). // A28 — writes visible at flush, read-side (2026-09-15): measured at 11,639 B // against `next` (+339 B); the signals core delta, see the core floor note. - limit: "11.70 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 11,793 B; the signals + // core delta, see the core floor note. + // Lanes stage (#3479 review, 2026-09-15): measured at 11,959 B (+166 B). A lane + // pass publishes a memo's speculative result into the override slot + // (`laneOverride`), and the override lifecycle learns the derived kind: + // promote-on-revert, skipped by the body-end supersession and the + // authoritative-blockage census, merged through and suspended on in lanes, + // pending flowing through it in status. Retained here by `latest()`. + limit: "12.05 KB", modifyEsbuildConfig }, { @@ -720,7 +747,13 @@ module.exports = [ // bytes from the core floor note, nothing app-side. // A28 — writes visible at flush, read-side (2026-09-15): measured at 11,829 B // against `next` (+179 B); the signals core delta, see the core floor note. - limit: "11.90 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 11,974 B; the signals + // core delta, see the core floor note. + // Lanes stage (#3479 review, 2026-09-16): 12.05 -> 12.15 KB, measured at + // 12,075 B rebased over #3488 (its +100 B minified reporter wake, in cap on + // `next` by 1 B, plus this PR's +139 B core-retained arms — see the + // treeshake ceiling note); the signals core delta, nothing app-side. + limit: "12.15 KB", modifyEsbuildConfig }, { @@ -835,7 +868,13 @@ module.exports = [ // bytes from the core floor note, nothing app-side. // A28 — writes visible at flush, read-side (2026-09-15): measured at 19,367 B // against `next` (+267 B); the signals core delta, see the core floor note. - limit: "19.40 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 19,534 B; the signals + // core delta, see the core floor note. + // Lanes stage (#3479 review, 2026-09-16): 19.60 -> 19.70 KB, measured at + // 19,606 B (+72 brotli on ~+40 B minified: recompute's derived-override + // posture branch, fuzzer latest-1 #2481). Brotli noise: the same bytes + // read -6 on the core floor and +16 on isPending/latest. + limit: "19.70 KB", modifyEsbuildConfig }, { @@ -1006,7 +1045,11 @@ module.exports = [ // consumed the room under. // A28 — writes visible at flush, read-side (2026-09-15): measured at 29,280 B // against `next` (+330 B); the signals core delta, see the core floor note. - limit: "29.35 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 29,501 B; the signals + // core delta, see the core floor note. + // Lanes stage (#3479 review, 2026-09-15): measured at 29,657 B (+156 B); the + // signals optimistic-engine delta, see the isPending/latest note. + limit: "29.75 KB", modifyEsbuildConfig }, { @@ -1095,7 +1138,9 @@ module.exports = [ // -62 B) — the new scheduler export shifts brotli layout, not a shrink. // A28 — writes visible at flush, read-side (2026-09-15): measured at 14,747 B // against `next` (+247 B); the signals core delta, see the core floor note. - limit: "14.80 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 14,917 B; the signals + // core delta, see the core floor note. + limit: "15.00 KB", modifyEsbuildConfig }, { @@ -1191,7 +1236,9 @@ module.exports = [ // did not move, and the frames prod scenario below folds its emitters. // A28 — writes visible at flush, read-side (2026-09-15): measured at 16311 B // on top of #3472 (+233 B); the signals core delta, see the core floor note. - limit: "16.40 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 16,453 B; the signals + // core delta, see the core floor note. + limit: "16.55 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -1302,7 +1349,9 @@ module.exports = [ // recompute's causes trace back to, else the ambient frame. // A28 — writes visible at flush, read-side (2026-09-15): measured at 27909 B // on top of #3472 (+286 B); the signals core delta, see the core floor note. - limit: "28.00 KB", + // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 28,098 B; the signals + // core delta, see the core floor note. + limit: "28.20 KB", modifyEsbuildConfig: observeEsbuildConfig }, {