From bfd6f6c4876e697a68e5eab7184db72bb6eccb4f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 01:42:30 -0700 Subject: [PATCH 01/10] refactor(signals): store node reads select by readerSeesCommitted (Rule 1, one implementation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of DESIGN-CONSOLIDATION move 3b. nodeValue — the store's untracked node-read selection — restated core read()'s committed-vs-staged arm by hand (inOwnerContext, a stale-of-foreign 'foreignHold' twin, the HELD-truth mask). It now calls readerSeesCommitted with core's context selection (readerContext: a root reads as its parent computed) behind the two store-only tunnels, latest() (#3075) and authoritativeServe (truth authors). Not zero-change — the twin was missing a precondition of the rule it copied: a stale reader (render effect) whose untracked read is of a store key held by a foreign action was served committed (correct, A15/A26) but never recorded for replay at the commit (heldFromStale's _gatedSubs contract, which the signal path performs), so the effect stayed on the pre-action value after the action settled. Pinned as S4 in posture-store-parity (signal vs store, both now [0, 0, 1]). The backing twin (no node: pendingBackingVisible / heldFromReader) has the same gap and no node to record the reader on — pinned it.fails as the step-3 item. The lane arm (laneReadsCommitted) now also applies to untracked store node reads as it does to tracked ones; no matrix cell or test distinguishes. Gate: suite green (+2 pins, 1 expected fail); visibility oracles and the 621-cell posture matrix byte-identical to next; minified store −5 B, full bundle brotli −44 B vs step 1. Co-authored-by: Claude via Cursor --- .changeset/store-node-rule1.md | 5 + packages/signals/docs/RULES-INDEX.md | 725 +++++++++--------- packages/signals/src/store/next/store.ts | 40 +- .../tests/posture-store-parity.test.ts | 111 ++- 4 files changed, 505 insertions(+), 376 deletions(-) create mode 100644 .changeset/store-node-rule1.md diff --git a/.changeset/store-node-rule1.md b/.changeset/store-node-rule1.md new file mode 100644 index 000000000..5548e411c --- /dev/null +++ b/.changeset/store-node-rule1.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Store node reads select committed-vs-staged by the core's `readerSeesCommitted` (one Rule 1 implementation for signals and store nodes). Fixes a render effect's untracked read of a store key held by a foreign action never replaying at that action's commit — the store's hand-restated stale-of-foreign clause served the committed value but skipped the replay registration the signal path performs, leaving the effect on the pre-action value permanently. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 40583b464..1f3e87e83 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 | 18 | 33 | 0 | -| V | 5 | 2 | 5 | 0 | -| B | 5 | 0 | 5 | 0 | -| C | 4 | 0 | 3 | 1 | -| INV | 11 | 11 | 7 | 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 | 18 | 33 | 0 | +| V | 5 | 2 | 5 | 0 | +| B | 5 | 0 | 5 | 0 | +| C | 4 | 0 | 3 | 1 | +| INV | 11 | 11 | 7 | 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,360 +43,371 @@ 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: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.states.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.states.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.states.ts×4 visibility-oracle-store.test.ts×1 | [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.states.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.states.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.states.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.states.ts×11 visibility-oracle-store.test.ts×1 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.states.ts×9 visibility-oracle-store.test.ts×1 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.states.ts×5 visibility-oracle-store.test.ts×1 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:340` | 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:347` | — | 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.states.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.states.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.states.ts×7 visibility-oracle-store.test.ts×1 | [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.states.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×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 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.states.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` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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` | core.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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… | +| 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.states.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.states.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.states.ts×4 visibility-oracle-store.test.ts×1 | [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.states.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 posture-store-parity.test.ts×2 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.states.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.states.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×2 | 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.states.ts×11 visibility-oracle-store.test.ts×1 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.states.ts×9 visibility-oracle-store.test.ts×1 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.states.ts×5 visibility-oracle-store.test.ts×1 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:340` | 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:347` | — | 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.states.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.states.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.states.ts×7 visibility-oracle-store.test.ts×1 | [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 posture-store-parity.test.ts×2 visibility-oracle-store.states.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×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 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.states.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` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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` | core.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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:410` | 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:420` | 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:426` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:433` | — | 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:445` | — | 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:410` | 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:420` | 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:426` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:433` | — | 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:445` | — | 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:376` | — | 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:386` | — | — | - [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:376` | — | 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:386` | — | — | - [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: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×2 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 verdict.ts×1 | inv4-projection-dispose-shadow.test.ts×3 posture-store-parity.test.ts×1 | - **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×2 | inv4-projection-dispose-shadow.test.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 | +| 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×2 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 verdict.ts×1 | inv4-projection-dispose-shadow.test.ts×3 posture-store-parity.test.ts×1 | - **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×2 | inv4-projection-dispose-shadow.test.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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.states.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/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index ef324fccc..e3e7786a1 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -38,6 +38,7 @@ import { stale, hasActiveOverride, visibleOverride, + readerSeesCommitted, prepareComputed, read as readNode, READ_SLOW, @@ -66,7 +67,7 @@ import { setProjectionWriteActive, setStoreCommitHook } from "../../core/scheduler.js"; -import type { Owner, Signal } from "../../core/types.js"; +import type { Computed, Owner, Signal } from "../../core/types.js"; import { pendingCheckActive, strictRead } from "../../core/core.js"; import { DEV, @@ -1613,11 +1614,17 @@ const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor * computed (#2687 — untracked reads inside mapArray Roots see in-flight * values mid-flush). CHILDREN_FORBIDDEN execution scopes (createTrackedEffect * / onSettled callbacks) get COMMITTED visibility (#3006), same as core. */ -function inOwnerContext(): boolean { +/** Core read()'s reader: the current computation, a root reading as its + * parent computed (`context` persists under untrack — an untracked read + * inside an effect is still that effect's read). */ +function readerContext(): Computed | null { const c: any = getOwner(); - if (c === null) return false; - const eff = c._root ? c._parentComputed : c; - return eff != null && !(eff._config & CONFIG_CHILDREN_FORBIDDEN); + return c === null ? null : c._root ? (c._parentComputed ?? null) : c; +} + +function inOwnerContext(): boolean { + const eff = readerContext(); + return eff !== null && !(eff._config & CONFIG_CHILDREN_FORBIDDEN); } /** CHILDREN_FORBIDDEN execution scope (createTrackedEffect / onSettled @@ -1799,20 +1806,17 @@ function nodeValue(node: Signal, backing: any): any { !authoritativeServe() && visibleOverride(node) ? unwrapOverride(node._x?._overrideValue) : node._pendingValue !== NOT_PENDING && + // Store-only tunnels first: latest() reaches this untracked path for + // store keys (#3075) and truth authors (authoritativeServe: the + // projection derive's draft, write-override) see staged truth + // unconditionally. Then Rule 1's committed-vs-staged arm — the + // same readerSeesCommitted core read() serves tracked reads by + // (owner context, children-forbidden, stale-of-foreign, HELD truth, + // lanes) — with core's context selection (a root reads as its + // parent computed). (latestReadActive || - // Owner-context pending visibility — except HELD truth (#3164, - // see CONFIG_HELD_TRUTH: fold-staged or entangle-stolen - // confirming truth), which only authoritative/latest readers - // see (core read()'s A17-for-held-truth twin; ordinary readers - // keep committed until the transaction's reveal — latest() is - // exempted by the leading arm above). - // — and core read()'s stale-reader clause: a render effect's - // untracked read of a FOREIGN transaction's write sees committed - // (#3336; the tracked read reaches core read() and already does). - (((inOwnerContext() && - !(stale && node._transition !== null && foreignHold(node._transition))) || - authoritativeServe()) && - !(node._config & CONFIG_HELD_TRUTH && !authoritativeServe()))) + authoritativeServe() || + !readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false)) ? node._pendingValue : backing; return v === (FORCE as any) ? backing : v; diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 7c488dbb2..98edb5172 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -37,7 +37,8 @@ import { createStore, flush, isPending, - latest + latest, + untrack } from "../src/index.js"; const never = () => new Promise(() => {}); @@ -121,3 +122,111 @@ describe("S2 — creation in boundary content over a held value publishes it (OB expect(s.n).toBe(0); }); }); + +/** A render effect (stale reader) whose UNTRACKED read is of a value held by + * a foreign action: it is served committed (A15 / A26) and — because the + * commit will change what it read — is recorded for replay at that commit + * (core heldFromStale, the `_gatedSubs` contract). The signal side always + * did this; the store's node path restated the stale-of-foreign clause + * without the registration (nodeValue's `foreignHold` twin), so the effect + * showed the committed value after the action settled, permanently. Both + * paths now go through one readerSeesCommitted. */ +function untrackedStaleReplay(read: () => number, hold: () => void, release: () => void) { + const [u, setU] = createSignal(0); + const log: number[] = []; + createRoot(() => { + createRenderEffect( + () => { + u(); + return untrack(read); + }, + v => { + log.push(v); + } + ); + }); + flush(); + hold(); + flush(); + setU(1); // the stale reader re-runs off the hold: committed + flush(); + const held = [...log]; + release(); + return { held, log }; +} +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; + +describe("S4 — a stale reader's untracked read of a foreign hold replays at the hold's commit (A15 / A26 replay contract) — signal vs store", () => { + it("signal: committed while held, the new value after the action settles", async () => { + const [x, setX] = createSignal(0); + let release!: () => void; + const r = untrackedStaleReplay( + x, + () => + action(function* () { + setX(1); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + expect(r.log).toEqual([0, 0, 1]); + }); + it("store leaf with a node: committed while held, the new value after the action settles", async () => { + const [s, setS] = createStore({ n: 0 }); + // A tracked reader materializes the node for `n`; the untracked read + // then serves through it (nodeValue). + createRoot(() => + createRenderEffect( + () => s.n, + () => {} + ) + ); + flush(); + let release!: () => void; + const r = untrackedStaleReplay( + () => s.n, + () => + action(function* () { + setS(d => { + d.n = 1; + }); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + expect(r.log).toEqual([0, 0, 1]); + }); + // The backing twin (pendingBackingVisible / heldFromReader) serves the key + // with no node the same committed value but has no node to record the + // reader on — the effect never replays. Rule 1's backing-level form is an + // open item of DESIGN-CONSOLIDATION move 3b (step 3). + it.fails("store leaf WITHOUT a node: the same replay (backing twin gap)", async () => { + const [s, setS] = createStore({ n: 0 }); + let release!: () => void; + const r = untrackedStaleReplay( + () => s.n, + () => + action(function* () { + setS(d => { + d.n = 1; + }); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + expect(r.log).toEqual([0, 0, 1]); + }); +}); From 0148d58a10531b5e32e6cf7c7092a36eecffab49 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 02:02:38 -0700 Subject: [PATCH 02/10] fix(signals): store backing paths record the stale reader's replay (Rule 1, backing-level form) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of DESIGN-CONSOLIDATION move 3b. The stale-of-foreign clause has two halves — serve committed, and record the reader for replay at the hold's commit (A15 / A26, _gatedSubs). Core heldFromStale does both; the store's backing-level twins (heldFromReader for a key with no node, readSource's adoption-hold view for a reconcile inside an action) did only the first. A render effect whose untracked read hit either stayed on the pre-action value after the action settled. The registration is now one exported helper, recordStaleReplay(txn, c), called by heldFromStale and by the store's two backing sites (staleReplay: reader in context, not children-forbidden — the same gate core applies before it reaches heldFromStale). Pins: posture-store-parity S4 grows to the four store shapes (fold hold × adoption hold × with/without a node) beside the signal; all four fail on next, the it.fails from step 2 flips to a pass. Gate: suite green; oracles and the 621-cell posture matrix byte-identical to next; +31 B minified core (the helper), +123 B minified store (the fix); three brotli caps ratcheted with notes. Co-authored-by: Claude via Cursor --- .changeset/store-backing-stale-replay.md | 5 +++ packages/signals/docs/RULES-INDEX.md | 6 +-- packages/signals/src/core/core.ts | 14 ++++++- packages/signals/src/store/next/store.ts | 23 ++++++++++- .../tests/posture-store-parity.test.ts | 38 +++++++++++++++++-- 5 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 .changeset/store-backing-stale-replay.md diff --git a/.changeset/store-backing-stale-replay.md b/.changeset/store-backing-stale-replay.md new file mode 100644 index 000000000..99bb66a66 --- /dev/null +++ b/.changeset/store-backing-stale-replay.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A render effect's untracked read of a store key held by a foreign action — a key with no node, or a `reconcile` adoption held by the action — is now recorded for replay at the action's commit, as the signal path always was. Previously the store's backing-level selection served the committed value but skipped the registration, so the effect stayed on the pre-action value after the action settled. One registration (`recordStaleReplay`) is shared by the node path and the store's backing paths. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 1f3e87e83..d9735bdd1 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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 posture-store-parity.test.ts×2 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.states.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 … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×5 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 posture-store-parity.test.ts×2 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.states.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.states.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×2 | 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.states.ts×11 visibility-oracle-store.test.ts×1 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.states.ts×9 visibility-oracle-store.test.ts×1 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… | @@ -70,13 +70,13 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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.states.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.states.ts×7 visibility-oracle-store.test.ts×1 | [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 posture-store-parity.test.ts×2 visibility-oracle-store.states.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… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 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.states.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` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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` | core.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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… | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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 diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index cdb6fbbb6..49a900b1b 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1562,12 +1562,22 @@ export function installAuthoritativeRead(): void { * also pending on an upstream re-ask blocks through that flight until it * lands, and its landing re-runs the reader into the normal path. */ +/** The replay half of the stale-of-foreign clause (A15 / A26): a stale reader + * served the committed value because `txn` holds what it read re-runs at + * txn's commit, when the value it was denied becomes the frame — unless its + * own last value already came from that transaction. One registration for + * the node path (heldFromStale) and the store's backing paths, which have + * no node to carry the hold (heldFromReader, the adoption hold view). */ +export function recordStaleReplay(txn: Transition, c: Computed): void { + const vt: Transition | null | undefined = (c as any)._valueTransition; + if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); +} + function heldFromStale(el: Signal | Computed, c: Computed): boolean { const t = el._transition; if (t === null || t === activeTransition) return false; const txn = currentTransition(t); - const vt: Transition | null | undefined = (c as any)._valueTransition; - if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); + recordStaleReplay(txn, c); const reporters = txn._asyncReporters.get(el as Computed); if (reporters) reporters.add(c); else if ((el as Computed)._statusFlags & STATUS_PENDING) diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index e3e7786a1..8d568ae5f 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -39,6 +39,7 @@ import { hasActiveOverride, visibleOverride, readerSeesCommitted, + recordStaleReplay, prepareComputed, read as readNode, READ_SLOW, @@ -408,7 +409,19 @@ function heldFoldTransition(target: StoreNextTarget): Transition | null { function heldFromReader(target: StoreNextTarget): boolean { if (!stale && inOwnerContext()) return false; const txn = liveFoldTransition(target); - return txn !== null && foreignHold(txn); + return txn !== null && foreignHold(txn) && (staleReplay(txn), true); +} + +/** The replay half of the clause (core recordStaleReplay): the stale reader + * just denied the held value re-runs at the hold's commit. Without it the + * node path replayed (heldFromStale) and the backing paths did not — an + * effect's untracked read of a key with no node stayed on the pre-action + * value after the action settled (posture-store-parity S4). Only a reader in + * context has a pass to replay; a children-forbidden reader sees the frame + * and never the graph (A32). */ +function staleReplay(txn: Transition): void { + const c = readerContext(); + if (c !== null && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) recordStaleReplay(txn, c); } /** Core read()'s `activeTransition !== el._transition`: a hold belongs to a @@ -1673,7 +1686,13 @@ function readSource(target: StoreNextTarget): Record { (stale && target.ht !== PLAIN_HOLD && foreignHold(currentTransition(target.ht)))) ) { const hv = heldMaskView(target); - if (hv !== null) return hv; + if (hv !== null) { + // The reader denied the adopted view replays at the adoption's commit + // (the replay half of the clause; a latest()-pull PLAIN_HOLD is not a + // transaction and has no commit). + if (target.ht !== PLAIN_HOLD) staleReplay(currentTransition(target.ht as Transition)); + return hv; + } } return pendingBackingVisible(target, false) ? target.pb! : target.v; } diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 98edb5172..bb5bf920b 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -38,6 +38,7 @@ import { flush, isPending, latest, + reconcile, untrack } from "../src/index.js"; @@ -208,10 +209,9 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th expect(r.log).toEqual([0, 0, 1]); }); // The backing twin (pendingBackingVisible / heldFromReader) serves the key - // with no node the same committed value but has no node to record the - // reader on — the effect never replays. Rule 1's backing-level form is an - // open item of DESIGN-CONSOLIDATION move 3b (step 3). - it.fails("store leaf WITHOUT a node: the same replay (backing twin gap)", async () => { + // with no node the same committed value; it records the reader on the + // holding transaction directly (staleReplay → core recordStaleReplay). + it("store leaf WITHOUT a node: the same replay (backing twin)", async () => { const [s, setS] = createStore({ n: 0 }); let release!: () => void; const r = untrackedStaleReplay( @@ -229,4 +229,34 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th await settle(); expect(r.log).toEqual([0, 0, 1]); }); + // The other hold kind: an adoption (reconcile inside an action) holds at + // the backing (`ht`, #3074) — the held view is served to the stale reader + // by readSource, and the same replay is recorded there. + for (const withNode of [false, true]) { + it(`store reconcile held by a live action, ${withNode ? "with" : "without"} a node: the same replay`, async () => { + const [s, setS] = createStore({ n: 0 }); + if (withNode) { + createRoot(() => + createRenderEffect( + () => s.n, + () => {} + ) + ); + flush(); + } + let release!: () => void; + const r = untrackedStaleReplay( + () => s.n, + () => + action(function* () { + setS(reconcile({ n: 1 })); + yield new Promise(res => (release = res)); + })(), + () => release() + ); + expect(r.held).toEqual([0, 0]); + await settle(); + expect(r.log).toEqual([0, 0, 1]); + }); + } }); From d8e35a3232435ae476d83ac9706ace1e3dad1ddf Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 08:28:21 -0700 Subject: [PATCH 03/10] fix(signals): a derivation's untracked read of a held store value is born held (A29 at the store's untracked paths) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of DESIGN-CONSOLIDATION move 3b. Core read() serves a staged value to a deriving reader and enters its transaction in the same arm — an untracked read inside a memo is still that memo's read (context persists under untrack), so a mainline memo over a held signal is born held. The store's untracked paths — nodeValue, pendingBackingVisible, readSource's adoption-hold view — restated the value half and dropped the entry: a mainline memo's or user effect's untrack(() => s.n) PUBLISHED the action's unrevealed write. They now call enterStagedRead on the arm that serves the staged value; enterStagedRead takes a null node for a backing (the fold's transaction is passed). The two store tunnels (latest() for store keys, authoritativeServe) keep serving without entering, as before. Discovery: the posture matrix gains memoUntracked / effectUntracked reader kinds (621 -> 851 cells) — the kind that sees S4 and S5, and that shows two more store/signal divergences recorded, not ruled, as S6 (staged-ambient write read by a derivation created inside a foreign action) and S7 (optimistic override with the only reader gated away). Commits docs/DESIGN-CONSOLIDATION.md (dropped from next by c7ec862's squash accident) with a ledger of moves 3a/3b so far; the code citations now point at it. Gate: suite green (+14 pins); oracles identical; the 5-reader subset of the matrix identical to next; +1 B minified core, +130 B minified store; two brotli caps ratcheted. Co-authored-by: Claude via Cursor --- .changeset/store-untracked-born-held.md | 5 + packages/signals/docs/DESIGN-CONSOLIDATION.md | 140 +++++++++++++ packages/signals/docs/RULES-INDEX.md | 6 +- packages/signals/src/core/core.ts | 14 +- packages/signals/src/store/next/store.ts | 28 ++- .../tests/posture-store-parity.test.ts | 189 ++++++++++++++++++ .../tests/visibility-oracle-posture.test.ts | 31 ++- 7 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 .changeset/store-untracked-born-held.md create mode 100644 packages/signals/docs/DESIGN-CONSOLIDATION.md diff --git a/.changeset/store-untracked-born-held.md b/.changeset/store-untracked-born-held.md new file mode 100644 index 000000000..417055821 --- /dev/null +++ b/.changeset/store-untracked-born-held.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A memo or user effect created on mainline whose untracked read (`untrack(() => s.n)`, `deep(s)`) is of a store key held by a live action is now born held (A29), as the same read of a signal is: the pass enters the action's transaction and publishes nothing until the action commits. Previously the store's untracked paths served the held value without entering, so a mainline memo published the action's unrevealed write to the screen. Covers keys with and without a node and `reconcile` adoptions held by an action. diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md new file mode 100644 index 000000000..f3afed9f7 --- /dev/null +++ b/packages/signals/docs/DESIGN-CONSOLIDATION.md @@ -0,0 +1,140 @@ +# Consolidation — one implementation per rule + +**Status:** design 2026-09-16 (read-only pass over `next` at `5fa224a4a`, #3479 in), approved to proceed; **in progress** — see §0. Written for a decision; §1–§6 are the design as reviewed, §0 is the ledger. + +## 0. Ledger (2026-09-17) + +| Move | Status | Where | What it found | +| -------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 3a — `retireReporter`, deps scan at `_depsTail` | **merged** | #3496 and follow-ups (O3, fuzzer P1) | Fuzzer 984 / 4 / 12 → 994 / 0 / 6. | +| 3b step 1 — one `unflushed` | **merged** | #3510 (`CONFIG_ADOPTED_UNFLUSHED`) | Closed S1 / O4 for the verdict channels. | +| 3b step 1 — shared predicates | PR #3515 | `readerSeesCommitted` (read's slow tail), `visibleOverride` / one `hasActiveOverride` (core, lanes, verdict ×3, store ×2) | Pure refactor. Matrix, oracles, fuzzer identical; full bundle −72 B minified. | +| 3b step 2 — store node reads select by Rule 1 | PR #3516 | `nodeValue` → `readerSeesCommitted` behind the two store tunnels (`latest()`, `authoritativeServe`) | **S4:** the store's stale-of-foreign twin served committed but skipped the replay registration — a render effect's untracked read of a held key stayed on the pre-action value forever. Invisible to the suite, both oracles, the 621-cell matrix and the fuzzer. | +| 3b step 3 — backing-level replay | PR #3517 | `recordStaleReplay` (lifted from `heldFromStale`) called by `heldFromReader` and the adoption-hold view | S4's no-node and `reconcile`-inside-an-action forms (four store shapes, all failing on `next`). | +| 3b step 4 — A29 at the store's untracked paths | this PR | `nodeValue`, `pendingBackingVisible`, the adoption-hold view call `enterStagedRead` on the arm that serves the staged value; `enterStagedRead(null, txn)` for a backing | **S5:** a mainline memo's / user effect's untracked read of a held store key **published the unrevealed write** while the same read of a signal was born held. Matrix gained `memoUntracked` / `effectUntracked` reader kinds (621 → 851 cells) — the kind that sees it. | +| 3b — recorded, not ruled | open | `posture-store-parity` S6, S7 | **S6:** staged-ambient write, derivation created inside a foreign action: signal publishes committed (A28 / #3510), store publishes pending. **S7:** optimistic store override, only reader gated away: signal still reads the override, store reads committed. | +| 3b step 5 — `pendingBackingVisible`'s own arms, the store tunnels, `overrideRead` / `latestRead` / lane arms → one `serve` | not started | | Needs the lane ruling (§6). | +| 3c — cargo lifecycle | not started | | Wait for the first new "decided at the pass" fix, or for 3b to settle. | + +**Method that is working:** the pure-refactor step finds nothing; handing a twin the shared predicate and asking what changed finds the precondition the twin dropped. Every 3b finding so far (S4, S5) was a store site restating a core rule minus one half of it (the replay registration, the transaction entry) — the pattern §1 predicted, in store code the matrix could not see until it had a reader kind for "an untracked read inside a derivation." + +**Bytes so far (minified):** step 1 −72 B full bundle; step 3 +31 B core / +123 B store; step 4 +1 B core / +130 B store. The fixes cost bytes; the refactors do not. + +## 1. Why + +The last two months' async fixes are ~four rules, each fixed several times at different sites: + +| Rule (stated per outcome) | Sites that each decide it (enforced per site) | Fixes to the same rule | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| Which value does a reader off the hold see | `readNodeFast`, `read` fast block, `read` slow tail, `overrideRead`, `latestRead`, `gatedRead`, `laneReadsCommitted`, `readsHeldCommitted`, store `nodeValue` / `serveDataKey` / `pendingBackingVisible` / `heldFromReader` / `visibleOverride` / `optimisticView` | #3330 #3334 #3460 (two places) A29 (three sites) A28 (four sites) | +| Is this reporter live | `reporterBlocksSource` (one predicate, five stand-ins) + three independent _wake_ sites: `disposeChildren`, `recompute` tail, boundary reset | #3372 #3375 #3426 #3458 #3463 #3488 | +| Dependencies are the committed frame's (A30) | `commitPendingNode` trim, `runEffect` trim, `heldTrims` (unchanged pass), `trimStaleDeps` at pass end | #3410 #3438 #3461 #3469 | +| Decided at the pass, known at the verdict | `CONFIG_HELD_CHILDREN`+`_pendingFirstChild`/`_pendingDisposal`, `_modified`+`_queueStash`, `heldRevealed`, `_gatedSubs`, `heldTrims`, `_contested`, `_flushedStaged` | one bespoke mechanism per fix | + +Two instruments now exist that did not when those fixes were written: the posture matrix (621 enumerated cells: state × posture × reader → served value, entanglement) and the semantic fuzzer (#3446, 20 laws over generated graphs; baseline on this `next`: 984 pass / 4 fail / 12 policy). Both say "zero semantic change" is now a checkable claim rather than a hope, which is the precondition for any of what follows. + +Three open violations are the concrete targets; each is a consequence of the site count: + +- **O3, same-flush form** (fuzzer P1, cases 21/79; pinned `it.fails`): gate closes and source is written in one flush → the reporter's pass runs under the transaction and _stages_ its value, so A30 keeps its previous dep on the memo linked past `_depsTail` until commit; `reporterBlocksSource`'s deps scan walks the whole list, finds the kept dep, and calls the reporter live. The hold keeps the dep that keeps the hold. Rule 2's predicate reading Rule 3's deferral — verified by probe 2026-09-16 (pass ran once; not pending; `_pendingValue = "hidden"`; deps `[show, memo]`, tail after `show`). +- **O4 / S1** (pinned `it.fails`): after same-tick adoption, the signal's `unflushedValue` reads a stamped node with no stash as "flushed, held" and `latest`/`isPending` see a write no flush carried; the store's `flushedStaged` path does not. Two definitions of "unflushed". Rule 1. +- **O2** (recorded, not ruled): creation under a transaction / in boundary content escapes the hold while mainline creation is born held. One rule (A29) implemented at one of its sites. Rule 1 / Rule 4. A ruling question first — the consolidation makes whichever answer is chosen hold everywhere. + +## 2. Inventory (as of `5fa224a4a`) + +Condensed from a read-only walk; line numbers are approximate to ±5 and will drift. + +### Rule 1 — value selection + +Core, in evaluation order per site: + +- `readNodeFast` (`core.ts` ~1699–1737): bail gate → `READ_SLOW` on any special mode (`latestReadActive`, `pendingCheckActive`, `_fn`, `_firewall`, override, snapshot, `activeTransition`, lane, `unflushedStaged && pending`, strict); else link; then **T1**: `!c || pending === NOT_PENDING || CHILDREN_FORBIDDEN || (stale && heldFromStale)` → `_value`, else `enterStagedRead; _pendingValue`. +- `read` fast block (~1739–1783): same eligibility, same **T1** verbatim. +- `read` slow tail (~1966–1995): `noCommitted && !c` → throw; `unflushedValue` arm (A28) → committed / stash + `markLateLinker`; then **T1 extended**: `+ laneReadsCommitted`, `+ (CONFIG_HELD_TRUTH && !latest && !AUTHORITATIVE)`, `+ !noCommitted` guard on the stale arm. +- `read` override arm (~1912–1938): active override, not authoritative, `!unflushedOverride` → tracked with lane/superseded → `overrideRead`, else `unwrapOverride`. +- `read` pending arm (~1821–1878): stale carve-out (`!UNINITIALIZED && !INPUTS_PUBLISHED && !laneLive && heldFromStale`) → committed; else throw / `laneSuspends`. +- `heldFromStale` (~1544–1555): foreign transaction → true, with side registration into `_gatedSubs` / `_asyncReporters`. +- `enterStagedRead` (~1578–1611): A29 entry; companion/verdict exemptions; born-held record for mainline creation. +- `unflushedValue` / `unflushedOverride` (~1640–1663): A28. +- `overrideRead` (`optimistic.ts` ~429–445): `stale && readsHeldCommitted` → `_value`; not superseded → override; stale foreign owner → override; else enter + pending/committed. +- `gatedRead` (~556–567), `laneReadsCommitted` (~573–614), `readsHeldCommitted` (`lanes.ts` ~143–154): lane-side "prefer committed" with `_gatedSubs` registration. +- `latestRead` (`verdict.ts` ~479–551), `flushedStaged` (~170–176), `computePendingState` (~259–314): verdict channels; re-derive visible override, unflushed, stale-foreign, shadow pending. + +Store twins (`store/next/store.ts`, `optimistic.ts`): `heldFoldTransition` / `foreignHold` / `heldFromReader` (≡ `heldFromStale` for backings), `readSource` + `pendingBackingVisible` (≡ T1 extended for backings, plus draft / write-override / opt-family arms), `heldTruthMasked` (≡ HELD_TRUTH arm), `visibleOverride` (≡ override arm's `unflushedOverride` gate), `nodeValue` (untracked view: override → pending → backing), `serveDataKey` (per-key: length / opt / draft overlay, then `readNodeFast`/`readNode` tracked or `nodeValue` untracked), `optimisticView` (deep compose of flushed overrides). + +**Duplicated conditions (each is a place a rule change must be threaded by hand):** T1 ×2 verbatim, T1-extended ×1 + store backing twin; stale-foreign → committed ×5; CHILDREN*FORBIDDEN → committed ×3; A28 unflushed ×6 call sites over two helpers (signal) plus `flushedStaged` (verdict) — and the store gets a \_different* answer for adopted nodes (S1); override-vs-truth ×3; HELD_TRUTH mask ×2; `enterStagedRead` on staged serve ×4. + +### Rule 2 — reporter liveness + +- Predicate: `reporterBlocksSource` (`scheduler.ts` ~1499–1542): DISPOSED → dead; ZOMBIE → walk to non-zombie parent, judge by its transaction vs verdict; boundary walk (`_collectionType & PENDING && !_initialized`) → dead (A33); `_pendingSources.has(source)` → live; deps scan through `_parentSource`/`_firewall` → live; `pending && _error.source === source` → live. Callers: `sourceObserved` → `transitionComplete`, `waitingTransition`, `enterWaiting`, `_endOptimism`, `_transitionBlocked`. +- Registration: `notify` (~897–931, INV-3), `heldFromStale`, store optimistic path. +- **Events that retire a reporter, each pushing `wokenTransitions` independently:** `disposeChildren` (`owner.ts` ~86, #3372), `recompute` tail (`core.ts` ~727, #3488), boundary reset → `wakeParked` (`boundaries.ts` ~319). Consumed in flush's `finally` on an otherwise idle pass (~892). +- **Verdict placement:** `transitionComplete` at ~774, after `runHeap(dirtyQueue)` and **before** effects; on incomplete: `stashQueues` (~805) parks the _entire_ render/user queues, `finalizePureQueue(null, true)`, return. This ordering is O3's same-flush form. + +### Rule 3/4 — deferred decisions (one structure each today) + +| Decision recorded at the pass | Carrier | Applied at commit | Dropped at park | +| ---------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------- | +| Staged value | node `_pendingValue`, `t._pendingNodes` | `commitPendingNode(s)` | kept (re-stamped) | +| Children of a held pass | `CONFIG_HELD_CHILDREN`, `_x._pendingFirstChild/_pendingDisposal`, zombie heap | `_dispose(zombie=true)` in `commitPendingNode` | zombie heap cancelled if batch===txn; immediate on re-run | +| Effect run owed | `_modified`, `_queueStash` | `restoreQueues` → `runEffect` (re-enqueues if `_valueTransition` open) | stashed whole-queue | +| Deps trim (A30) | `_depsTail`, module `heldTrims[]` | `commitPendingNodes` drains; `runEffect` trims; per-node in `commitPendingNode` | `heldTrims.length = 0` | +| Gated / stale readers to replay | `t._gatedSubs` | `finalizePureQueue` enqueue+clear | kept; merged on merge; adopted from ambient | +| Held-truth reveal | node `CONFIG_HELD_TRUTH`, module `heldRevealed[]` | post-`_resolveOptimistic` `insertSubs` | never (park never commits) | +| Cross-txn effect write | `t._contested` | `finalizePureQueue` enqueue | merged | +| Held rewrite's flushed value (A28) | `_x._flushedStaged`, module `unflushedRewrites[]` | cleared at flush start | n/a (tick-local) | + +Ambient `_batch` and a live `Transition` already share the same field set — the "cargo" concept exists; it has no lifecycle API. + +## 3. Shape + +Three moves, ordered by blast radius. Each is zero-semantic-change by construction _except_ where it closes a pinned violation, and each is gated on the matrix, both oracles, and the fuzzer baseline before and after. + +### 3a. Rule 2 — one retirement event, one verdict placement + +**`retireReporter(reporter)`** — a single entry point that the three wake sites call instead of pushing `wokenTransitions` themselves. Body is today's dedup'd push + `schedule()`, keyed on `reporter._transition`. Pure refactor; the three call sites lose their inline copies. + +**Same-flush O3.** A first reading of this case blamed effect parking (the run stashed, never proving itself dead). The probe says otherwise: the compute ran, staged `"hidden"`, and the effect is judged live only because `reporterBlocksSource`'s deps scan reads the dep A30 deliberately keeps linked past `_depsTail` for the commit to trim. Two rules, one wrong answer; the fix is in the predicate, not the scheduler: + +_Liveness reads the pass's deps, not the committed frame's._ For a reporter with a staged pass (`_pendingValue !== NOT_PENDING`), the deps scan stops at `_depsTail` — the dependencies this pass actually read. A kept tail is the committed frame's business (A30: a write to a dep the committed value still derives from must reach the node) and says nothing about whether the reporter still observes the flight. One predicate, one line, and it is the `readerLive()` consolidation's first concrete content: the predicate must know which frame it is asking about. + +Case 21 then resolves without any parking change: the pass no longer reads the memo → not live → the transaction completes at the verdict already in place, the same flush. Stash-by-world (`#3407` applied to `stashQueues`) is _not_ needed for this and should not be done on its account; it remains a separate question (§6). + +### 3b. Rule 1 — shared predicates, then one `serve` + +Not a single `serve()` first. The perf constraint is hard and measured (2026-09-15): `readNodeFast` past ~460 B of bytecode, or a call on its staged branch, costs 10–15% on propagation; `setSignal` past the inline budget costs 10–20% on the write loop. The fast path must stay a tiny inlinable guard that handles the trivial case and bails. Value selection therefore has exactly **two** implementations by design — the fast ternary (T1) and one slow `serve` — and the target is to make the third-through-eleventh disappear, not the second. + +Step 1 — **shared predicates**, no behavior change: `readerSeesCommitted(el, c)` (= T1-extended's disjunction, including HELD*TRUTH and lane arms), `visibleOverride(el)` (already exists store-side; core's override arm inlines the same test), `unflushed(el)` with **one** definition used by `unflushedValue`, `flushedStaged`, `pendingBackingVisible` and `nodeValue`, and `readerClass(ctx)` — the three ways a reader relates to a hold, today spread over three unrelated flags: **derives** (a tracked pass: joins, or is born held), **displays** (a render/user effect's apply: sees the committed frame now, replays at the reveal — `_gatedSubs`), **observes** (verdict pulls `_verdictPull`, companions `_parentSource`: mirrors of the flushed world, never join). Direct-commit readers (`CONFIG_DIRECT_COMMIT` — `resolve()`/`until()`) are **not** observers: they are derivers with a tunnel \_inside their own transaction* (the arm that lets a hold not deadlock on its own acknowledgment), and from mainline over a **foreign** hold they wait for the commit like any deriver — #3492 pins that a mainline `resolve()` must never resolve with an unrelated action's unrevealed frame, which is exactly what exempting them from born-held (#3490) leaked. `enterStagedRead`, `heldFromStale` and `recompute`'s commit arm each test a different subset of these flags today. This step closes **S1**: "unflushed" = staged outside a flush and not yet carried by one, whatever the stamp — one predicate, so the signal and the store cannot disagree. Mechanism: `queuePendingNode` outside a flush already sets `unflushedStaged`; a per-node bit set there and cleared by the carrying flush (`resyncUnflushedCompanions` walks the batch's pending nodes — it is already the flush-start hook) makes adoption irrelevant to the test. + +Step 2 — **`serve(el, reader)`** as the slow tail: `read`'s slow arms, `overrideRead`, `latestRead`'s value selection and the store's `nodeValue`/`readSource` value decision call it; the store keeps its structural arms (draft overlay, length, chained, opt family) and delegates the _value_ decision. `gatedRead`/`laneReadsCommitted`/`readsHeldCommitted` fold into `readerSeesCommitted` with their `_gatedSubs` registration as a side effect of the predicate, as `heldFromStale` already does. + +Bytes: expect roughly neutral to slightly positive. Three mechanism-preserving consolidations this month came back +13…+85 B; the pitch is one site per rule, not size. + +### 3c. Rule 3/4 — cargo lifecycle + +Give the shared batch/transaction field set the two functions it lacks: `applyCargo(t)` (today's `commitPendingNodes` + `_gatedSubs` replay + `heldRevealed` wake + `heldTrims` drain + zombie dispose, in the order `finalizePureQueue` runs them) and `dropCargo(t)` (today's park path: `heldTrims.length = 0`, zombie cancel, `stashQueues`). `heldTrims` and `heldRevealed` move from module arrays onto the transaction they belong to (a module array is only correct while one transaction commits at a time, which `finalizePureQueue` guarantees today — by accident of sequencing, not by construction). New deferrals then have exactly one place to go. + +This is the largest move and the one with the least direct violation behind it; it can wait for the first new "decided at the pass" fix to motivate it, or be done when 3a/3b have settled. + +## 4. Verification protocol (per PR) + +1. `tests/visibility-oracle.test.ts`, `-store.test.ts`: every cell unchanged. +2. `tests/visibility-oracle-posture.test.ts`: 621-cell report diffed against the pre-change report; the only permitted diffs are the cells a pinned violation says should flip. +3. Fuzzer (#3446) campaign, same seed: baseline 984 / 4 / 12; permitted change is the pinned violation's cases. +4. CodSpeed on the PR; write-loop benches (`update1to1`, `update1to1000`, `diamond`, `avoidable`) alternating pairs; `--print-bytecode` for `readNodeFast`, `read`, `setSignal`, `recompute` before/after. +5. Size: floor and the nine brotli scenarios; report the delta, do not sell it. + +## 5. Sequencing + +1. **3a** — `retireReporter` + the deps-scan bounded by `_depsTail` for staged passes. Closes O3's same-flush form (fuzzer 4 → 0 expected). Smallest blast radius; touches `reporterBlocksSource` and three wake sites. +2. **3b step 1** — shared predicates incl. one `unflushed`. Closes S1. Touches `core.ts` read arms, `verdict.ts`, store `store.ts`; no fast-path change. +3. **O2 ruling**, then whichever answer, applied once via `enterStagedRead` (born held everywhere: the `creatingPass` prototype, +83 B) or via `recompute`'s create arm (escapes everywhere: retire the mainline born-held form). +4. **3b step 2** — `serve`. +5. **3c** — cargo lifecycle, when motivated. + +## 6. Open questions for the maintainer + +- **Stash-by-world (not required for O3):** `stashQueues` parks the whole render/user queue when a transaction parks, including effects dirtied only by a mainline write in that round. #3407 read literally says those belong to mainline and should run. Not a violation anyone has pinned; flagged as a candidate rule to make explicit, not a change to make now. +- **O2:** born held everywhere, or escapes everywhere. Either is consistent; the current state (mainline held, transaction/boundary creation escapes) is the only inconsistent option. **Insight from #3482 (2026-09-16):** born-held bundles two decisions that should be separate — _ownership_ (the created value belongs to the transaction it derived from) and _application_ (skip the effect's first run, replay at commit). The ownership half was right even in #3482's misuse: the post-`await` `until()` _was_ the action's reader, and born-held correctly made it the action's. What deadlocked was a reader created in the wrong posture (post-`await`, mainline by mechanism) over its _own_ action's hold — and from mainline, waiting for the commit is the correct behavior for that reader class (#3492: a mainline `resolve()` over a foreign hold must not see the held frame; the direct-commit tunnel is only for a reader inside its own transaction). So born-held was right on both halves there; the misuse is what put the reader in a posture where "right" deadlocks, and the docs/lint are the fix. "Born held everywhere" remains the recommendation for O2's actual question — creation _under_ a transaction / in boundary content — ownership and application both following derivation, as mainline creation already does (the `creatingPass` prototype, +83 B). The `CONFIG_DIRECT_COMMIT` exemption proposed in #3482 is declined on the evidence, not on taste. A further argument for posture-independence: the posture is exactly what users get wrong (`await` vs `yield`), so a rule that changes with the posture turns a documentation slip into a semantic one. +- **`readsHeldCommitted` and the lane arms:** folding them into `readerSeesCommitted` assumes lanes are "a transaction with an override"; if lanes are meant to diverge from transactions later, keep them as a separate predicate that `serve` consults. +- **Post-`await` posture:** pinned by #3492 for the direct-commit readers (three postures: own step, own `await` continuation, foreign mainline) as a standalone file. Worth folding into the matrix as a posture (`ownActionAfterAwait`) so the other reader kinds get the same rows; not urgent. +- **Loosening:** once `serve` exists, each of its arms is a constraint with a measurable blast radius (flip it, rerun the matrix). Candidates surfaced so far: O2 (two born-held forms → one), the `CONFIG_HELD_TRUTH` mask (one arm, two sites), and the stale-foreign carve-out in the pending arm (`INPUTS_PUBLISHED`), which exists to serve one shape (#3305). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index d9735bdd1..01cd0508c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -61,7 +61,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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×5 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 posture-store-parity.test.ts×2 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.states.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.states.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×2 | 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.states.ts×11 visibility-oracle-store.test.ts×1 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… | +| 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×2 | optimistic-undefined-override.test.ts×1 posture-store-parity.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.states.ts×11 visibility-oracle-store.test.ts×1 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.states.ts×9 visibility-oracle-store.test.ts×1 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.states.ts×5 visibility-oracle-store.test.ts×1 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:340` | 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… | @@ -72,8 +72,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:241` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [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` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×3 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.states.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` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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 … | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×4 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.states.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` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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… | diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 49a900b1b..7b2264cd5 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1607,16 +1607,18 @@ function heldFromStale(el: Signal | Computed, c: Computed): boole let stagedEntry: Transition | null = null; export function enterStagedRead( - el: Signal | Computed, - t: Transition | null | undefined = el._transition + el: Signal | Computed | null, + t: Transition | null | undefined = el!._transition ): void { if (!t || t === activeTransition || pendingCheckActive) return; // A companion (the latest() shadow, the isPending() verdict signal) is the // engine's mirror of the flushed world — reading it, or being it, is an // observation, not a derivation from the hold: latest(x) never enters x's // transaction, and the shadow's own pass never enters either (it would - // flip activeTransition under the reader that pulled it). - if (el._x?._parentSource || (context as Computed | null)?._x?._parentSource) return; + // flip activeTransition under the reader that pulled it). (`el` is null for + // a store backing served under a hold — no node, the transaction is the + // fold's.) + if (el?._x?._parentSource || (context as Computed | null)?._x?._parentSource) return; // Verdict machinery (GlobalQueue._verdictPull: companion creation and the // latest()/isPending() pulls — the latest() shadow is created before it is // marked optimistic, so the bit alone cannot tell) and optimistic nodes @@ -1646,7 +1648,7 @@ export function enterStagedRead( * node's COMMITTED value? One implementation of the rule the fast paths * (readNodeFast, read's fast block) carry as their trivial ternary and that * every slow site — read's tail, the store's backing selection, the lane and - * verdict arms — used to restate by hand (DESIGN-CONSOLIDATION, move 3b). In order: + * verdict arms — used to restate by hand (docs/DESIGN-CONSOLIDATION.md, move 3b). In order: * - no reader at all (an untracked read) — the committed frame; * - a reader under an optimistic lane the engine says reads committed * (laneReadsCommitted: another lane's hold, #3460); @@ -1751,7 +1753,7 @@ export function hasActiveOverride(el: Signal | Computed): boolean { * an optimistic write is a write; until its flush no reader sees it). One * implementation for read()'s override arm, the verdict channels * (latestRead, computePendingState) and the store's selection - * (DESIGN-CONSOLIDATION, move 3b). */ + * (docs/DESIGN-CONSOLIDATION.md, move 3b). */ export function visibleOverride(el: Signal | Computed): boolean { return hasActiveOverride(el) && !unflushedOverride(el); } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 8d568ae5f..fbbde0440 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -40,6 +40,7 @@ import { visibleOverride, readerSeesCommitted, recordStaleReplay, + enterStagedRead, prepareComputed, read as readNode, READ_SLOW, @@ -424,6 +425,17 @@ function staleReplay(txn: Transition): void { if (c !== null && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) recordStaleReplay(txn, c); } +/** The other half of serving a held backing: a deriving reader (a pass in + * owner context) served the pending backing a live transaction holds + * derives from that transaction's world and enters it (A29, core + * enterStagedRead on the same arm) — its result is held with the fold, not + * published into the mainline frame. Without it a mainline memo's untracked + * read of a held key (`untrack(() => s.n)`, `deep(s)`) published the + * unrevealed value while the same read of a signal was born held. */ +function enterHeldBacking(target: StoreNextTarget, txn = liveFoldTransition(target)): void { + if (txn !== null && readerContext() !== null) enterStagedRead(null, txn); +} + /** Core read()'s `activeTransition !== el._transition`: a hold belongs to a * FOREIGN transaction unless the flush running now is that transaction's — * its own stale readers (a render effect recomputing in it, whose run the @@ -1693,6 +1705,10 @@ function readSource(target: StoreNextTarget): Record { if (target.ht !== PLAIN_HOLD) staleReplay(currentTransition(target.ht as Transition)); return hv; } + } else if (target.ht !== null && !latestReadActive && !inDraft(target) && !getWriteOverride()) { + // An owner-context deriving reader served the ADOPTED view under a live + // adoption hold derives from the adoption's transaction (A29). + enterHeldBacking(target, heldAdoptionTransition(target)); } return pendingBackingVisible(target, false) ? target.pb! : target.v; } @@ -1726,7 +1742,10 @@ function pendingBackingVisible(target: StoreNextTarget, speculative: boolean): b // ordinary readers keep committed until the transaction's reveal). // Stale readers and owner-less peeks of a TRANSACTION-held backing see // committed, as core read() serves them (#3336, heldFromReader). - ((speculative || inOwnerContext()) && !heldTruthMasked(target) && !heldFromReader(target)) || + ((speculative || inOwnerContext()) && + !heldTruthMasked(target) && + !heldFromReader(target) && + (enterHeldBacking(target), true)) || // A projection's pending backing is authoritative-elect: serve it to // context-free readers too UNLESS a transition is holding the node // commits (downstream async hold — stale committed is the contract) @@ -1835,7 +1854,12 @@ function nodeValue(node: Signal, backing: any): any { // parent computed). (latestReadActive || authoritativeServe() || - !readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false)) + // A deriving reader served the staged value enters its + // transaction (A29) as core read() does on the same arm: the + // pass is the hold's, its result held with it — an untracked read + // inside a mainline memo must not publish the unrevealed frame. + (!readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false) && + (enterStagedRead(node), true))) ? node._pendingValue : backing; return v === (FORCE as any) ? backing : v; diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index bb5bf920b..88c08bdd2 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -19,6 +19,16 @@ * S3 — INV-4 after disposing a projection mid-refetch: its own file, * tests/inv4-projection-dispose-shadow.test.ts (the live actions S1/S2 * leave behind would mask the quiescence check here). Spec O5. + * S4 — (fixed, 3b steps 2–3) a stale reader's UNTRACKED read of a held store + * key replays at the commit as the signal's does — node, backing, and + * adoption-hold paths (recordStaleReplay). + * S5 — (fixed, 3b step 4) a mainline derivation's UNTRACKED read of a held + * store key is born held (A29) as the signal's is — the store's untracked + * paths served the pending value without entering the transaction. + * S6, S7 — DIVERGENCES recorded at their current values, not ruled (see the + * block comment above them). + * Discovery for S4–S7: the matrix's `memoUntracked` / `effectUntracked` + * reader kinds (an untracked read inside a derivation), added with S5. * * (A first cut also reported the projection's seed leaking as a value inside * boundary content, and `isPending` false / override invisible behind a @@ -29,8 +39,11 @@ import { describe, expect, it } from "vitest"; import { action, + createEffect, createLoadingBoundary, createMemo, + createOptimistic, + createOptimisticStore, createRenderEffect, createRoot, createSignal, @@ -260,3 +273,179 @@ describe("S4 — a stale reader's untracked read of a foreign hold replays at th }); } }); + +/** A deriving reader (memo, user effect) created MAINLINE whose UNTRACKED + * read is of a value held by a live action: the pass is served the staged + * value and enters the transaction — born held (A29) — so nothing is + * published until the action commits. The signal did this (core read() + * enters on the same arm that serves the staged value; `context` persists + * under untrack). The store's untracked paths (nodeValue, the backing's + * pendingBackingVisible, the adoption-hold view) served the pending value + * WITHOUT entering: a mainline memo published the action's unrevealed write + * to the screen while the same read of a signal was held. */ +type HeldShape = "signal" | "store+node" | "store" | "store reconcile" | "store reconcile+node"; +function heldShape(shape: HeldShape) { + if (shape === "signal") { + const [x, setX] = createSignal(0); + return { read: x, write: () => setX(1) }; + } + const [s, setS] = createStore({ n: 0 }); + if (shape.endsWith("+node")) { + createRoot(() => + createRenderEffect( + () => s.n, + () => {} + ) + ); + flush(); + } + return { + read: () => s.n, + write: () => + shape.startsWith("store reconcile") + ? setS(reconcile({ n: 1 })) + : setS(d => { + d.n = 1; + }) + }; +} +describe("S5 — a mainline derivation's UNTRACKED read of a held value is born held (A29) — signal vs store", () => { + for (const shape of [ + "signal", + "store+node", + "store", + "store reconcile", + "store reconcile+node" + ] as HeldShape[]) { + it(`${shape}: memo → render effect publishes nothing until the action commits`, async () => { + const { read, write } = heldShape(shape); + let release!: () => void; + action(function* () { + write(); + yield new Promise(res => (release = res)); + })(); + flush(); + const log: number[] = []; + const [u, setU] = createSignal(0); + createRoot(() => { + const m = createMemo(() => { + u(); + return untrack(read); + }); + createRenderEffect(m, v => { + log.push(v); + }); + }); + flush(); + setU(1); // a re-run off the hold is held too + flush(); + expect(log).toEqual([]); + release(); + await settle(); + expect(log).toEqual([1]); + }); + it(`${shape}: user effect runs once, after the commit`, async () => { + const { read, write } = heldShape(shape); + let release!: () => void; + action(function* () { + write(); + yield new Promise(res => (release = res)); + })(); + flush(); + const log: number[] = []; + createRoot(() => { + createEffect( + () => untrack(read), + v => { + log.push(v); + } + ); + }); + flush(); + expect(log).toEqual([]); + release(); + await settle(); + expect(log).toEqual([1]); + }); + } +}); + +/** DIVERGENCES the 7-reader matrix shows and this file only RECORDS (both + * sides pinned at their current value; not ruled — flip the store or the + * signal when the maintainer rules): + * + * S6 — staged, ambient (a write before any flush), reader created INSIDE a + * foreign action (which adopts the write, spec O1): the signal's memo → + * render effect publishes the committed 0 (A28 / #3510: adopted before + * any flush = unflushed, served committed); the store's publishes the + * pending 1 (pendingBackingVisible: owner context → pending backing). + * The verdict channels already agree (S1); the derivation reads do not. + * S7 — optimistic store, override active, the only reader gated away: the + * signal's x() still reads the override 5 while the action is live + * (A17); the store's s.n reads 0 — the override is invisible to an + * untracked read once no reader observes the key. + */ +describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivation created inside a foreign action", () => { + function publishedInsideForeignAction(read: () => number) { + const log: number[] = []; + action(function* () { + createRoot(() => { + const m = createMemo(read); + createRenderEffect(m, v => { + log.push(v); + }); + }); + yield never(); + })(); + flush(); + return log; + } + it("signal: publishes the committed 0", () => { + const [x, setX] = createSignal(0); + setX(1); + expect(publishedInsideForeignAction(x)).toEqual([0]); + }); + it("store: publishes the pending 1", () => { + const [s, setS] = createStore({ n: 0 }); + setS(d => { + d.n = 1; + }); + expect(publishedInsideForeignAction(() => s.n)).toEqual([1]); + }); +}); + +describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gated away", () => { + function gateAway(read: () => number) { + const [show, setShow] = createSignal(true); + createRoot(() => { + createRenderEffect( + () => (show() ? read() : "gated"), + () => {} + ); + }); + flush(); + setShow(false); + flush(); + return read(); + } + it("signal: x() still reads the override while the action is live", () => { + const [x, setX] = createOptimistic(0); + action(function* () { + setX(5); + yield never(); + })(); + flush(); + expect(gateAway(x)).toBe(5); + }); + it("store: s.n reads the committed 0 once nothing observes the key", () => { + const [s, setS] = createOptimisticStore({ n: 0 }); + action(function* () { + setS(d => { + d.n = 5; + }); + yield never(); + })(); + flush(); + expect(gateAway(() => s.n)).toBe(0); + }); +}); diff --git a/packages/signals/tests/visibility-oracle-posture.test.ts b/packages/signals/tests/visibility-oracle-posture.test.ts index 505da8d22..e7e5ef258 100644 --- a/packages/signals/tests/visibility-oracle-posture.test.ts +++ b/packages/signals/tests/visibility-oracle-posture.test.ts @@ -48,7 +48,8 @@ import { flush, isPending, latest, - NotReadyError + NotReadyError, + untrack } from "../src/index.js"; import { HELD, @@ -99,7 +100,20 @@ const POSTURES = [ "gatedAway" ] as const; type Posture = (typeof POSTURES)[number]; -const READERS = ["untracked", "memo", "effect", "latest", "isPending"] as const; +// memoUntracked / effectUntracked: the derivation reads x() under untrack() — +// still that pass's read (context persists under untrack), so the value +// selection is the reader's, without a subscription. The kind that found the +// store's untracked paths publishing a held frame a signal is born held on +// (posture-store-parity S5) and skipping the stale reader's replay (S4). +const READERS = [ + "untracked", + "memo", + "memoUntracked", + "effect", + "effectUntracked", + "latest", + "isPending" +] as const; type Reader = (typeof READERS)[number]; const classify = (fn: () => unknown): Cell => { @@ -211,6 +225,7 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise untrack(x) : x; const { y } = enter(posture, () => { switch (reader) { case "untracked": @@ -222,13 +237,14 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise isPending(x)); break; - case "memo": { + case "memo": + case "memoUntracked": { const d = createRoot(d => { const m = createMemo(() => { if (gated && !show()) return "gated"; let v: Cell; try { - v = x(); + v = rd(); } catch (e) { pass.push( e instanceof NotReadyError @@ -248,14 +264,15 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise { createRenderEffect( () => { if (gated && !show()) return "gated"; let v: Cell; try { - v = x(); + v = rd(); } catch (e) { pass.push( e instanceof NotReadyError @@ -282,7 +299,7 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise Date: Thu, 17 Sep 2026 09:27:44 -0700 Subject: [PATCH 04/10] fix(signals): an optimistic store override survives its key becoming unobserved (S7; store follows signal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling 2026-09-17: store rules follow signal rules. Of the two divergences the 7-reader posture matrix recorded in #3520, this ships S7 and defers S6. S7 — the slot hook released a property node the moment its last subscriber left, with the optimistic override on it (overrides live on nodes over a clone the setter discards): s.n read 0 while the action was live, where an optimistic signal keeps its override whether or not anything reads it. A node carrying an override or a staged write now defers its release to the flush that resolves it (deferSlotRelease feeds the scheduler's transientStoreNodes sweep — present since the monorepo absorb with exactly these checks, and unfed). +55 B minified store. Pinned: override visible after gating away; node released at settle. S6 — A28 at the backing (a mainline setStore invisible to a derivation created inside a foreign action until the carrying flush) is ruled the same way but DEFERRED: done at the store twin it cost +402 B minified (a node born in the unflushed window must stage the write, and every arm existed twice — core's and the twin's). Pinned at the store's current value with the ruling attached; it closes when the store's value selection shares core's serve (DESIGN-CONSOLIDATION move 3b step 6). Ledger updated. Gate: suite green; oracles identical; matrix: only the S7 cells moved, to the signal's values; solid/web unchanged from next; no size caps exceeded. Co-authored-by: Claude via Cursor --- .../store-override-survives-unobserved.md | 5 ++ packages/signals/docs/DESIGN-CONSOLIDATION.md | 25 +++---- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/scheduler.ts | 16 +++-- packages/signals/src/store/next/store.ts | 6 ++ .../tests/posture-store-parity.test.ts | 70 +++++++++++++------ 6 files changed, 85 insertions(+), 39 deletions(-) create mode 100644 .changeset/store-override-survives-unobserved.md diff --git a/.changeset/store-override-survives-unobserved.md b/.changeset/store-override-survives-unobserved.md new file mode 100644 index 000000000..5d7ebbd09 --- /dev/null +++ b/.changeset/store-override-survives-unobserved.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An optimistic store override now survives its key becoming unobserved. The property node was released with the override on it the moment its last reader left, so an untracked read of the key (`s.n`) returned the committed value while the action was still live; the release now waits for the flush that resolves the override, as an optimistic signal keeps its override whether or not anything reads it. diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md index f3afed9f7..f5c664f29 100644 --- a/packages/signals/docs/DESIGN-CONSOLIDATION.md +++ b/packages/signals/docs/DESIGN-CONSOLIDATION.md @@ -4,21 +4,22 @@ ## 0. Ledger (2026-09-17) -| Move | Status | Where | What it found | -| -------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 3a — `retireReporter`, deps scan at `_depsTail` | **merged** | #3496 and follow-ups (O3, fuzzer P1) | Fuzzer 984 / 4 / 12 → 994 / 0 / 6. | -| 3b step 1 — one `unflushed` | **merged** | #3510 (`CONFIG_ADOPTED_UNFLUSHED`) | Closed S1 / O4 for the verdict channels. | -| 3b step 1 — shared predicates | PR #3515 | `readerSeesCommitted` (read's slow tail), `visibleOverride` / one `hasActiveOverride` (core, lanes, verdict ×3, store ×2) | Pure refactor. Matrix, oracles, fuzzer identical; full bundle −72 B minified. | -| 3b step 2 — store node reads select by Rule 1 | PR #3516 | `nodeValue` → `readerSeesCommitted` behind the two store tunnels (`latest()`, `authoritativeServe`) | **S4:** the store's stale-of-foreign twin served committed but skipped the replay registration — a render effect's untracked read of a held key stayed on the pre-action value forever. Invisible to the suite, both oracles, the 621-cell matrix and the fuzzer. | -| 3b step 3 — backing-level replay | PR #3517 | `recordStaleReplay` (lifted from `heldFromStale`) called by `heldFromReader` and the adoption-hold view | S4's no-node and `reconcile`-inside-an-action forms (four store shapes, all failing on `next`). | -| 3b step 4 — A29 at the store's untracked paths | this PR | `nodeValue`, `pendingBackingVisible`, the adoption-hold view call `enterStagedRead` on the arm that serves the staged value; `enterStagedRead(null, txn)` for a backing | **S5:** a mainline memo's / user effect's untracked read of a held store key **published the unrevealed write** while the same read of a signal was born held. Matrix gained `memoUntracked` / `effectUntracked` reader kinds (621 → 851 cells) — the kind that sees it. | -| 3b — recorded, not ruled | open | `posture-store-parity` S6, S7 | **S6:** staged-ambient write, derivation created inside a foreign action: signal publishes committed (A28 / #3510), store publishes pending. **S7:** optimistic store override, only reader gated away: signal still reads the override, store reads committed. | -| 3b step 5 — `pendingBackingVisible`'s own arms, the store tunnels, `overrideRead` / `latestRead` / lane arms → one `serve` | not started | | Needs the lane ruling (§6). | -| 3c — cargo lifecycle | not started | | Wait for the first new "decided at the pass" fix, or for 3b to settle. | +| Move | Status | Where | What it found | +| -------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 3a — `retireReporter`, deps scan at `_depsTail` | **merged** | #3496 and follow-ups (O3, fuzzer P1) | Fuzzer 984 / 4 / 12 → 994 / 0 / 6. | +| 3b step 1 — one `unflushed` | **merged** | #3510 (`CONFIG_ADOPTED_UNFLUSHED`) | Closed S1 / O4 for the verdict channels. | +| 3b step 1 — shared predicates | PR #3515 | `readerSeesCommitted` (read's slow tail), `visibleOverride` / one `hasActiveOverride` (core, lanes, verdict ×3, store ×2) | Pure refactor. Matrix, oracles, fuzzer identical; full bundle −72 B minified. | +| 3b step 2 — store node reads select by Rule 1 | PR #3516 | `nodeValue` → `readerSeesCommitted` behind the two store tunnels (`latest()`, `authoritativeServe`) | **S4:** the store's stale-of-foreign twin served committed but skipped the replay registration — a render effect's untracked read of a held key stayed on the pre-action value forever. Invisible to the suite, both oracles, the 621-cell matrix and the fuzzer. | +| 3b step 3 — backing-level replay | PR #3517 | `recordStaleReplay` (lifted from `heldFromStale`) called by `heldFromReader` and the adoption-hold view | S4's no-node and `reconcile`-inside-an-action forms (four store shapes, all failing on `next`). | +| 3b step 4 — A29 at the store's untracked paths | this PR | `nodeValue`, `pendingBackingVisible`, the adoption-hold view call `enterStagedRead` on the arm that serves the staged value; `enterStagedRead(null, txn)` for a backing | **S5:** a mainline memo's / user effect's untracked read of a held store key **published the unrevealed write** while the same read of a signal was born held. Matrix gained `memoUntracked` / `effectUntracked` reader kinds (621 → 851 cells) — the kind that sees it. | +| 3b step 5 — store follows signal: S7 | PR #3521 (on #3520) | **Ruling 2026-09-17: "store rules should follow signal rules."** An optimistic override survives its key becoming unobserved (`deferSlotRelease` — the slot hook released the node with the override on it; the scheduler's transient-node sweep, unfed since the monorepo absorb, releases it when the override resolves). +55 B minified store. | **S7:** optimistic store key read 0 the moment its only reader gated away while the action was live. | +| S6 — ruled, fix deferred to step 6 | open | A28 at the backing: a `setStore` from imperative mainline is invisible to a derivation created inside a foreign action until the carrying flush; the signal already is. Ruled store-follows-signal. Done at the store twin (first cut of #3521) it cost **+402 B minified** — a node born in the unflushed window has to stage the write, and every arm existed twice (core's, the twin's). Pinned at the store's current value in `posture-store-parity` S6; closes when the store's value selection shares core's `serve`. | The byte cost of fixing a rule at its twin before the twin collapses: the argument for step 6 over more twin fixes. | +| 3b step 6 — `pendingBackingVisible`'s own arms, the store tunnels, `overrideRead` / `latestRead` / lane arms → one `serve` | not started | | Needs the lane ruling (§6). | +| 3c — cargo lifecycle | not started | | Wait for the first new "decided at the pass" fix, or for 3b to settle. | **Method that is working:** the pure-refactor step finds nothing; handing a twin the shared predicate and asking what changed finds the precondition the twin dropped. Every 3b finding so far (S4, S5) was a store site restating a core rule minus one half of it (the replay registration, the transaction entry) — the pattern §1 predicted, in store code the matrix could not see until it had a reader kind for "an untracked read inside a derivation." -**Bytes so far (minified):** step 1 −72 B full bundle; step 3 +31 B core / +123 B store; step 4 +1 B core / +130 B store. The fixes cost bytes; the refactors do not. +**Bytes so far (minified):** step 1 −72 B full bundle; step 3 +31 B core / +123 B store; step 4 +1 B core / +130 B store; step 5 +0 B core / +55 B store (S6 at the twin: +402 B, declined). The fixes cost bytes; the refactors do not — and a fix at a twin costs its rule twice, which is what step 6 exists to stop. ## 1. Why diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 01cd0508c..d99226c2c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -72,7 +72,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:241` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [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` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×4 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.states.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… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×5 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.states.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` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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 … | diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index b52cbbf39..1262b9572 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -122,11 +122,19 @@ export let _hitUnhandledAsync = false; // pending render effect — N async siblings at mount used to produce N copies. let _reportedUnhandledAsync = false; -// Store property nodes that were created solely to carry a pending write (no -// subscribers at write time). Swept after each flush that commits pending -// values — any still without subs get disposed via their `_unobserved` hook, -// releasing the slot in the parent store's node map. +// Store property nodes whose last subscriber left while they carried state +// the backing cannot reconstruct — an optimistic override (overrides live on +// nodes, over a clone the setter discards) or a staged write. Releasing the +// slot then would drop the override: an optimistic store key read `0` the +// moment its only reader gated away while the action was live (S7). Swept +// after each flush — a node still without subs whose override and staging +// have resolved is released through the slot hook; one that regained a +// subscriber leaves the set. const transientStoreNodes = new Set>(); +/** Slot hook's deferral: release this node when its carried state resolves. */ +export function deferSlotRelease(node: Signal): void { + transientStoreNodes.add(node); +} function canUseSimpleSyncFlush(queue: GlobalQueue): boolean { const batch = queue._batch; diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index fbbde0440..d16c8c802 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -57,6 +57,7 @@ import { import { activeTransition, currentTransition, + deferSlotRelease, globalQueue, insertSubs, type Transition @@ -252,6 +253,11 @@ const slotNodeEquals = function (this: any, a: any, b: any): boolean { setSlotUnobserved((node: any): void => { // A live affects() mark keeps the node addressable (sweep parity). if (node._x?._affectsCount) return; + // An active override or a staged write is state only the node holds (an + // optimistic signal keeps its override whether or not anything reads it — + // store parity, posture-store-parity S7): defer the release to the flush + // that resolves it (the scheduler's transient-node sweep). + if (hasActiveOverride(node) || node._pendingValue !== NOT_PENDING) return deferSlotRelease(node); const t: StoreNextTarget = node._host; const key: PropertyKey = node._key; if (t.n && t.n[key as any] === node) { diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 88c08bdd2..4b5b9df2c 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -25,8 +25,14 @@ * S5 — (fixed, 3b step 4) a mainline derivation's UNTRACKED read of a held * store key is born held (A29) as the signal's is — the store's untracked * paths served the pending value without entering the transaction. - * S6, S7 — DIVERGENCES recorded at their current values, not ruled (see the - * block comment above them). + * S6 — RULED (2026-09-17, "store rules follow signal rules"), fix DEFERRED: + * A28 at the backing. Pinned at the store's CURRENT value below so the + * divergence stays visible; the fix is the store half of `serve` + * (DESIGN-CONSOLIDATION move 3b step 6) — done at the twin it costs + * +400 B minified (a node born in the unflushed window must stage the + * write; #3521 first cut). + * S7 — (fixed) an optimistic override survives its key becoming unobserved + * (the slot release defers to the flush that resolves the override). * Discovery for S4–S7: the matrix's `memoUntracked` / `effectUntracked` * reader kinds (an untracked read inside a derivation), added with S5. * @@ -54,6 +60,7 @@ import { reconcile, untrack } from "../src/index.js"; +import { $TARGET } from "../src/store/store.js"; const never = () => new Promise(() => {}); @@ -370,22 +377,17 @@ describe("S5 — a mainline derivation's UNTRACKED read of a held value is born } }); -/** DIVERGENCES the 7-reader matrix shows and this file only RECORDS (both - * sides pinned at their current value; not ruled — flip the store or the - * signal when the maintainer rules): - * - * S6 — staged, ambient (a write before any flush), reader created INSIDE a - * foreign action (which adopts the write, spec O1): the signal's memo → - * render effect publishes the committed 0 (A28 / #3510: adopted before - * any flush = unflushed, served committed); the store's publishes the - * pending 1 (pendingBackingVisible: owner context → pending backing). - * The verdict channels already agree (S1); the derivation reads do not. - * S7 — optimistic store, override active, the only reader gated away: the - * signal's x() still reads the override 5 while the action is live - * (A17); the store's s.n reads 0 — the override is invisible to an - * untracked read once no reader observes the key. - */ -describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivation created inside a foreign action", () => { +/** S6 — DIVERGENCE, ruled, fix deferred. Staged, ambient (a write before + * any flush), reader created INSIDE a foreign action (which adopts the + * write, spec O1): the signal's memo → render effect publishes the committed + * 0 (A28 / #3510: adopted before any flush = unflushed, served committed); + * the store's publishes the pending 1 (pendingBackingVisible: owner context + * → pending backing). The verdict channels already agree (S1); the + * derivation reads do not. Ruling: the store follows the signal (0). The + * store side is pinned at its CURRENT value so the divergence stays visible + * until the store's value selection shares core's (`serve`, move 3b step 6); + * flip it to `[0]` then. */ +describe("S6 — DIVERGENCE (ruled: store follows signal; fix deferred to `serve`): staged-ambient write read by a derivation created inside a foreign action", () => { function publishedInsideForeignAction(read: () => number) { const log: number[] = []; action(function* () { @@ -405,7 +407,7 @@ describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivatio setX(1); expect(publishedInsideForeignAction(x)).toEqual([0]); }); - it("store: publishes the pending 1", () => { + it("store: publishes the pending 1 (CURRENT; rule says 0)", () => { const [s, setS] = createStore({ n: 0 }); setS(d => { d.n = 1; @@ -414,7 +416,14 @@ describe("S6 — DIVERGENCE (recorded): staged-ambient write read by a derivatio }); }); -describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gated away", () => { +/** S7 (fixed): the slot hook released a node the moment its last subscriber + * left — with the override on it (overrides live on nodes, over a clone the + * setter discards), so `s.n` read the committed 0 while the action was live. + * A node carrying an override or a staged write now defers its release to + * the flush that resolves it (deferSlotRelease / sweepTransientStoreNodes), + * as an optimistic signal keeps its override whether or not anything reads + * it. */ +describe("S7 — optimistic override, the only reader gated away: the override is still the value (A17) — signal vs store", () => { function gateAway(read: () => number) { const [show, setShow] = createSignal(true); createRoot(() => { @@ -437,7 +446,7 @@ describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gat flush(); expect(gateAway(x)).toBe(5); }); - it("store: s.n reads the committed 0 once nothing observes the key", () => { + it("store: s.n still reads the override once nothing observes the key", () => { const [s, setS] = createOptimisticStore({ n: 0 }); action(function* () { setS(d => { @@ -446,6 +455,23 @@ describe("S7 — DIVERGENCE (recorded): optimistic override, the only reader gat yield never(); })(); flush(); - expect(gateAway(() => s.n)).toBe(0); + expect(gateAway(() => s.n)).toBe(5); + }); + it("store: the node deferred for its override is released once the action settles", async () => { + const [s, setS] = createOptimisticStore({ n: 0 }); + let release!: () => void; + action(function* () { + setS(d => { + d.n = 5; + }); + yield new Promise(res => (release = res)); + })(); + flush(); + expect(gateAway(() => s.n)).toBe(5); + release(); + await settle(); + expect(s.n).toBe(0); + // the slot map no longer holds a node for `n` + expect(((s as any)[$TARGET].n ?? {}).n).toBeUndefined(); }); }); From f329a269c8cf4cb25a3972d94bee6ffaa8b8669b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 12:06:43 -0700 Subject: [PATCH 05/10] =?UTF-8?q?refactor(signals):=20one=20ownership=20re?= =?UTF-8?q?lation=20=E2=80=94=20ownsHold=20/=20ownsLane=20(move=203b=20ste?= =?UTF-8?q?p=206b);=20record=20the=20lane=20rulings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6b of DESIGN-CONSOLIDATION move 3b. Three tests of 'is this hold the running pass's world' existed: heldFromStale's t === activeTransition, the store's foreignHold (resolved-transition equality), and readsHeldCommitted's 'same resolved transition OR same lane'. One relation now: core ownsHold(hold) — the pass runs under the hold's transaction, through merges — and lanes.ts ownsLane(lane, owner) built on it, adding the lane clause. heldFromStale's identity test becomes the resolved form the other two already used; no cell of the 851-cell matrix, no oracle cell and no test moves. §6 of the design doc records the three rulings from 2026-09-17: store rules follow signal rules; a lane is a transaction with an override whose world includes the transition that owns it (an ownership relation, not a value arm — serve gets no lane arm); collapse in place on agreement, absorption into the open parent on disagreement (A18's mark; 3c's third cargo op). Gate: suite green; oracles and matrix byte-identical; solid/web unchanged from next; +44 B minified core (the function is not inlined; -4 B brotli on the pure-signals fixture), four brotli caps and the floor budget ratcheted. Co-authored-by: Claude via Cursor --- .changeset/owns-hold.md | 5 ++ packages/signals/docs/DESIGN-CONSOLIDATION.md | 5 +- packages/signals/docs/RULES-INDEX.md | 52 +++++++++---------- packages/signals/src/core/core.ts | 20 ++++++- packages/signals/src/core/lanes.ts | 21 +++++--- packages/signals/src/store/next/store.ts | 5 +- packages/signals/tests/treeshake.test.ts | 8 ++- 7 files changed, 77 insertions(+), 39 deletions(-) create mode 100644 .changeset/owns-hold.md diff --git a/.changeset/owns-hold.md b/.changeset/owns-hold.md new file mode 100644 index 000000000..958307837 --- /dev/null +++ b/.changeset/owns-hold.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +One ownership relation, `ownsHold`, answers "is this hold part of the running pass's world" for the stale-reader clause, the lane arm and the store's backing holds — a refactor with no behavior change, recording the ruling that a lane is a transaction with an override whose world includes the transition that owns it. diff --git a/packages/signals/docs/DESIGN-CONSOLIDATION.md b/packages/signals/docs/DESIGN-CONSOLIDATION.md index f5c664f29..4c3b889fc 100644 --- a/packages/signals/docs/DESIGN-CONSOLIDATION.md +++ b/packages/signals/docs/DESIGN-CONSOLIDATION.md @@ -136,6 +136,9 @@ This is the largest move and the one with the least direct violation behind it; - **Stash-by-world (not required for O3):** `stashQueues` parks the whole render/user queue when a transaction parks, including effects dirtied only by a mainline write in that round. #3407 read literally says those belong to mainline and should run. Not a violation anyone has pinned; flagged as a candidate rule to make explicit, not a change to make now. - **O2:** born held everywhere, or escapes everywhere. Either is consistent; the current state (mainline held, transaction/boundary creation escapes) is the only inconsistent option. **Insight from #3482 (2026-09-16):** born-held bundles two decisions that should be separate — _ownership_ (the created value belongs to the transaction it derived from) and _application_ (skip the effect's first run, replay at commit). The ownership half was right even in #3482's misuse: the post-`await` `until()` _was_ the action's reader, and born-held correctly made it the action's. What deadlocked was a reader created in the wrong posture (post-`await`, mainline by mechanism) over its _own_ action's hold — and from mainline, waiting for the commit is the correct behavior for that reader class (#3492: a mainline `resolve()` over a foreign hold must not see the held frame; the direct-commit tunnel is only for a reader inside its own transaction). So born-held was right on both halves there; the misuse is what put the reader in a posture where "right" deadlocks, and the docs/lint are the fix. "Born held everywhere" remains the recommendation for O2's actual question — creation _under_ a transaction / in boundary content — ownership and application both following derivation, as mainline creation already does (the `creatingPass` prototype, +83 B). The `CONFIG_DIRECT_COMMIT` exemption proposed in #3482 is declined on the evidence, not on taste. A further argument for posture-independence: the posture is exactly what users get wrong (`await` vs `yield`), so a rule that changes with the posture turns a documentation slip into a semantic one. -- **`readsHeldCommitted` and the lane arms:** folding them into `readerSeesCommitted` assumes lanes are "a transaction with an override"; if lanes are meant to diverge from transactions later, keep them as a separate predicate that `serve` consults. +- **`readsHeldCommitted` and the lane arms — RULED 2026-09-17.** Three rulings from the maintainer, recorded verbatim in substance: + 1. **Store rules follow signal rules.** Every store/signal divergence the matrix shows is a store bug unless a store-only contract says otherwise (the documented ones: `deep()`/`snapshot()` as a speculative peek, adoption's eager visibility). Applied: S4, S5, S7; S6 ruled and deferred to `serve`. + 2. **A lane is a transaction with an override, whose world includes the transition that owns it.** The one asymmetry between a lane and a separate transaction is visibility: a lane reader sees what lands from its parent transition as its own world; a separate transaction would treat those as foreign until the parent settled. `readsHeldCommitted` already says this ("same resolved transition OR same lane" = own). Consequence for the fold: this is an **ownership** relation, not a value-selection rule — one `ownsHold(reader, hold)` (plain reader: its transaction through merges; lane reader: its lane, its parent lanes, and the transition that owns it) replaces the three implementations (`heldFromStale`'s `t === activeTransition`, `readsHeldCommitted`'s disjunction, the store's `foreignHold`). `serve` gets no lane arm; the lane's extra visibility lives entirely in `ownsHold`, which is also where any future lane divergence goes. + 3. **Collapse vs absorption.** When a lane's source lands while the parent transition is still open: if truth **agrees** with the prediction, the lane collapses in place — the override was the value, nothing changes, its held effects run (`laneHeld` false). If truth **disagrees**, the override is superseded (`endOptimism` → `supersedeOverride`, `CONFIG_OVERRIDE_SUPERSEDED`) and the corrected frame is the parent transaction's cargo, revealed at its commit (A18: truth in the graph now, on screen at commit) — the lane is absorbed. If the parent has completed, the lane is cleaned up with it (`cleanupCompletedLanes`). For `serve` this is one arm keyed on the mark, unchanged; the agree/disagree fork is a lifecycle decision made once in `endOptimism`. For 3c it is the third cargo operation — `absorbCargo(lane → parent)` beside apply and drop — and the motivation the doc said to wait for. - **Post-`await` posture:** pinned by #3492 for the direct-commit readers (three postures: own step, own `await` continuation, foreign mainline) as a standalone file. Worth folding into the matrix as a posture (`ownActionAfterAwait`) so the other reader kinds get the same rows; not urgent. - **Loosening:** once `serve` exists, each of its arms is a constraint with a measurable blast radius (flip it, rerun the matrix). Candidates surfaced so far: O2 (two born-held forms → one), the `CONFIG_HELD_TRUTH` mask (one arm, two sites), and the stale-foreign carve-out in the pending arm (`INPUTS_PUBLISHED`), which exists to serve one shape (#3305). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index d99226c2c..9f6cd9bb8 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -385,29 +385,29 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## § — 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` | core.ts×1 invariants.ts×1 lanes.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/src/core/core.ts b/packages/signals/src/core/core.ts index 7b2264cd5..ff4e6f6d5 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1573,9 +1573,27 @@ export function recordStaleReplay(txn: Transition, c: Computed): void { if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); } +/** + * The ownership relation (DESIGN-CONSOLIDATION §6, ruled 2026-09-17): is + * `hold` part of the running pass's world? A plain reader's world is the + * transaction it runs under, through merges. A lane reader's world is its + * lane AND the transition that owns the lane — the one asymmetry between a + * lane and a separate transaction (a lane sees what lands from its parent as + * its own; a separate transaction would wait for the parent to settle) — + * see `ownsLane` in lanes.ts, built on this. One relation for the + * stale-of-foreign clause (heldFromStale), the lane arm (readsHeldCommitted) + * and the store's backing holds (foreignHold); `serve` has no lane arm of + * its own, the lane's extra visibility lives here. + */ +export function ownsHold(hold: Transition): boolean { + return ( + activeTransition !== null && currentTransition(hold) === currentTransition(activeTransition) + ); +} + function heldFromStale(el: Signal | Computed, c: Computed): boolean { const t = el._transition; - if (t === null || t === activeTransition) return false; + if (t === null || ownsHold(t)) return false; const txn = currentTransition(t); recordStaleReplay(txn, c); const reporters = txn._asyncReporters.get(el as Computed); diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index 75a723321..d6436d7fa 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -4,7 +4,7 @@ import { NOT_PENDING, REACTIVE_DISPOSED } from "./constants.js"; -import { currentOptimisticLane, ext, hasActiveOverride } from "./core.js"; +import { currentOptimisticLane, ext, hasActiveOverride, ownsHold } from "./core.js"; export { hasActiveOverride }; import { enqueueSub } from "./heap.js"; import { @@ -144,16 +144,23 @@ export function laneHeld(lane: OptimisticLane): boolean { 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; + if (ownsLane(lane, owner)) return false; lane._effectQueues[0].push(() => c._flags & REACTIVE_DISPOSED || enqueueSub(c)); return true; } +/** The ownership relation for a lane hold (core `ownsHold`, §6 ruling 2): the + * running pass owns `lane`'s hold if it runs under the transition that owns + * the lane (the node's, resolved through override ownership and merges) or + * inside the lane itself. */ +export function ownsLane(lane: OptimisticLane, owner: Computed): boolean { + if (activeTransition !== null) { + const t = resolveTransition(owner); + if (t && ownsHold(t)) return true; + } + return currentOptimisticLane !== null && findLane(currentOptimisticLane) === lane; +} + /** * Merge two lanes when their dependency graphs overlap. */ diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index d16c8c802..c55964c56 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -41,6 +41,7 @@ import { readerSeesCommitted, recordStaleReplay, enterStagedRead, + ownsHold, prepareComputed, read as readNode, READ_SLOW, @@ -447,9 +448,7 @@ function enterHeldBacking(target: StoreNextTarget, txn = liveFoldTransition(targ * its own stale readers (a render effect recomputing in it, whose run the * commit applies) see the staged world. */ function foreignHold(txn: Transition): boolean { - return ( - activeTransition === null || currentTransition(activeTransition) !== currentTransition(txn) - ); + return !ownsHold(txn); } function stageHeldKey(node: Signal, nv: any, txn: Transition): void { diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 86ec73a46..ebb1977c9 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -344,7 +344,13 @@ describe("pay-for-use tree-shaking (#2883)", () => { // One `unflushed` for signal and store (spec O4, 2026-09-16): a staging // adopted before any flush is marked CONFIG_ADOPTED_UNFLUSHED at adoption // and cleared by the carrying flush; +56 B (25,193 -> 25,249). - expect(minifiedBytes).toBeLessThan(25_300); + // Move 3b, 2026-09-17: shared read predicates (+13 B, readerSeesCommitted), + // the stale-reader replay helper recordStaleReplay (+31 B), enterStagedRead's + // null-node form (+1 B), and one ownership relation ownsHold for the + // stale-of-foreign clause, the lane arm and the store's backing holds + // (+44 B minified, -4 B brotli: the function is not inlined by esbuild). + // 25,249 -> 25,338. + expect(minifiedBytes).toBeLessThan(25_400); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { From 7a09cd9faa5c4fe17a153430092fecdca1f87d4f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 12:33:52 -0700 Subject: [PATCH 06/10] =?UTF-8?q?refactor(signals):=20serve()=20=E2=80=94?= =?UTF-8?q?=20Rule=201's=20one=20slow=20selection=20for=20signals=20and=20?= =?UTF-8?q?store=20nodes=20(move=203b=20step=206c);=20A18=20for=20untracke?= =?UTF-8?q?d=20store=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read()'s slow tail — the override arm (A17, engine-routed under a lane or a supersession), the lane entanglement gate, the born-held throw (A19 exc. 1), the A28 unflushed arm and readerSeesCommitted / A29 — is one function, serve(el, c, owner, committed). read() passes el._value; the store's untracked node path (nodeValue) passes its BACKING as the committed value (single-home rule, O6) behind its two tunnels (authoritativeServe for truth authors, latest() for store keys, #3075). unflushedValue takes the same committed parameter. read()'s auto-dispose sweep registration keeps its former early-return shape (an untracked read served a visible override returned before it) via a guard. Not zero-change — the fold carried A18 to the store: a derivation's UNTRACKED read of a derived optimistic store's key, after its own truth (2) landed differently from the action's edit (3), was served the override and published 3; the signal serves the landed truth and holds the memo with the action. nodeValue's own override arm lacked read()'s supersession routing (overrideRead). Pinned as S8; the matrix state 'derived optimistic store: own truth landed 2 ≠ override 3' now matches the signal's superseded state row for row (37/37) — the only cells that moved. Gate: suite green; oracles identical; solid/web unchanged from next; +88 B minified core (wrapper, parameter, guard), floor budget and two brotli caps ratcheted. The additive half of the consolidation; the deletions it enables (overrideRead's wrapper, nodeValue, the verdict re-derivations) are next. Co-authored-by: Claude via Cursor --- .changeset/serve-one-slow-selection.md | 5 + packages/signals/docs/RULES-INDEX.md | 72 +++++++-------- packages/signals/src/core/core.ts | 92 ++++++++++++------- packages/signals/src/store/next/store.ts | 40 +++----- .../tests/posture-store-parity.test.ts | 65 +++++++++++++ packages/signals/tests/treeshake.test.ts | 10 +- 6 files changed, 191 insertions(+), 93 deletions(-) create mode 100644 .changeset/serve-one-slow-selection.md diff --git a/.changeset/serve-one-slow-selection.md b/.changeset/serve-one-slow-selection.md new file mode 100644 index 000000000..8744be6bc --- /dev/null +++ b/.changeset/serve-one-slow-selection.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +One slow value selection, `serve`, for signal reads and store property nodes (the fast paths keep their inline ternary). Fixes a derivation's untracked read of a derived optimistic store's key after its own truth landed differently from the optimistic edit: the store served the memo the superseded override and let it publish, where a signal serves the landed truth and holds the memo with the action (A18). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 9f6cd9bb8..751336182 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -39,45 +39,45 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul **src/:** none — every citation resolves. -**tests/:** none. +**tests/:** §0 (test-only citations are informational; `--check` gates src/ only) ## A — spec propositions -| 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.states.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.states.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.states.ts×4 visibility-oracle-store.test.ts×1 | [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.states.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×5 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 posture-store-parity.test.ts×2 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.states.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.states.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×2 | optimistic-undefined-override.test.ts×1 posture-store-parity.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.states.ts×11 visibility-oracle-store.test.ts×1 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.states.ts×9 visibility-oracle-store.test.ts×1 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.states.ts×5 visibility-oracle-store.test.ts×1 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:340` | 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:347` | — | 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.states.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.states.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.states.ts×7 visibility-oracle-store.test.ts×1 | [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` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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×2 core.ts×19 optimistic.ts×1 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 posture-store-parity.test.ts×5 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.states.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` | action.ts×1 core.ts×6 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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… | +| 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.states.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.states.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.states.ts×4 visibility-oracle-store.test.ts×1 | [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.states.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×5 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 posture-store-parity.test.ts×2 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.states.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.states.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×10 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×2 | optimistic-undefined-override.test.ts×1 posture-store-parity.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.states.ts×11 visibility-oracle-store.test.ts×1 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×7 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 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.states.ts×9 visibility-oracle-store.test.ts×1 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×2 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.states.ts×5 visibility-oracle-store.test.ts×1 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:340` | 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:347` | — | 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.states.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.states.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.states.ts×7 visibility-oracle-store.test.ts×1 | [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` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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×2 core.ts×20 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 posture-store-parity.test.ts×5 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.states.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` | action.ts×1 core.ts×7 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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 diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index ff4e6f6d5..4a3cbd479 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1732,7 +1732,7 @@ export function unflushed(el: Signal | Computed): boolean { * in-computation writes) and engine companions (the isPending() verdict * signal, the latest() shadow: the system's own writes, made at the source's * write to mirror it, installing eagerly — A28, A8). */ -export function unflushedValue(el: Signal | Computed): unknown { +export function unflushedValue(el: Signal | Computed, committed = el._value): unknown { if ( globalQueue._running || el._pendingValue === NOT_PENDING || @@ -1742,9 +1742,9 @@ export function unflushedValue(el: Signal | Computed): unknown { return NOT_PENDING; // Ambient, or adopted by a transaction before any flush carried the staging // (CONFIG_ADOPTED_UNFLUSHED): nothing flushed is staged — the committed - // value answers. A held node: unflushed only if rewritten since the last - // flush (stash). - if (el._transition === null || el._config & CONFIG_ADOPTED_UNFLUSHED) return el._value; + // value answers (the caller's notion of committed: a store node's backing). + // A held node: unflushed only if rewritten since the last flush (stash). + if (el._transition === null || el._config & CONFIG_ADOPTED_UNFLUSHED) return committed; return el._x === null ? NOT_PENDING : el._x._flushedStaged; } /** Held nodes rewritten since the last flush (setSignal); the flush clears @@ -2023,6 +2023,53 @@ export function read(el: Signal | Computed): T { nodeName: (owner as any)?._name }); + const value = serve(el, c as Computed | null, owner, el._value) as T; + if ( + !c && + owner === el && + typeof computed._fn === "function" && + el._config & CONFIG_AUTO_DISPOSE && + !(owner._statusFlags & STATUS_PENDING) && + !el._subs && + // An untracked read served a visible override returned before this + // sweep registration when the arm was inline; keep that. + !visibleOverride(el) + ) { + // Deferred, not inline (#3078): an inline unobserved() here made untracked + // reads destructive — dispose on this read, full revival recompute on the + // next — so consecutive reads could answer differently with no write in + // between (the revival samples the ambient transition/lane context). + // The sweep at flush finalization re-validates and reclaims; schedule() + // guarantees that flush happens even if nothing else is queued. + dormantNodes.add(el as Computed); + schedule(); + } + return value; +} + +/** + * Rule 1, the one slow implementation (DESIGN-CONSOLIDATION move 3b, step + * 6c): the value a reader `c` (null = untracked, no pass) is served from + * `el`, whose committed value is `committed` — the node's own `_value` for + * a signal or memo, the BACKING for a store property node (single-home rule, + * O6: committed truth lives in the backing and a node's `_value` is never + * served for one). Called by read()'s slow tail and by the store's untracked + * node path (nodeValue); the fast paths (readNodeFast, read's fast block) + * keep their trivial ternary by design (perf, see the doc). Arms, in order: + * - the override (A17), routed through the engine for a tracked reader under + * a lane or a supersession (A18), an authoritative reader marked instead; + * - the lane entanglement gate (committed, recorded for replay); + * - a node born held has nothing for an untracked reader (A19 exception 1); + * - an unflushed write serves committed and re-runs the reader in the + * carrying flush (A28); + * - readerSeesCommitted, else the staged value and the transaction (A29). + */ +export function serve( + el: Signal | Computed, + c: Computed | null, + owner: Signal | Computed, + committed: unknown +): unknown { if (hasActiveOverride(el)) { // A17: the override IS the value for every reader — except an authoritative // reader (until()'s predicate carries CONFIG_AUTHORITATIVE_READ): it must @@ -2045,8 +2092,8 @@ export function read(el: Signal | Computed): T { // 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); + return GlobalQueue._overrideRead!(el as Computed, c); + return unwrapOverride(el._x?._overrideValue); } el._config |= CONFIG_AUTHORITATIVE_OBSERVED; } @@ -2062,9 +2109,9 @@ export function read(el: Signal | Computed): T { currentOptimisticLane !== null && activeTransition !== null && c !== null && - GlobalQueue._gatedRead!(el as Signal, owner, c as Computed) + GlobalQueue._gatedRead!(el as Signal, owner, c) ) { - return el._value as T; + return committed; } // In optimistic lane context, return _value for optimistic/lane-assigned signals @@ -2081,35 +2128,18 @@ export function read(el: Signal | Computed): T { el._pendingValue !== NOT_PENDING && ((el as Computed)._statusFlags & STATUS_UNINITIALIZED) !== 0; if (noCommitted && !c) throw new NotReadyError(null); - const u = c && unflushedStaged ? unflushedValue(el) : NOT_PENDING; + const u = c && unflushedStaged ? unflushedValue(el, committed) : NOT_PENDING; if (u !== NOT_PENDING) { - markLateLinker(c as Computed); + markLateLinker(c!); if (pendingCheckActive) GlobalQueue._recordFresh!(el, u); - return u as T; + return u; } - const value = readerSeesCommitted(el, c as Computed | null, owner, noCommitted) - ? el._value - : (enterStagedRead(el), el._pendingValue as T); + const value = readerSeesCommitted(el, c, owner, noCommitted) + ? committed + : (enterStagedRead(el), el._pendingValue); // Record that this isPending() probe observed the fresh pending value, so // the probe doesn't pair "pending" with the new value (#2831). if (pendingCheckActive) GlobalQueue._recordFresh!(el, value); - if ( - !c && - owner === el && - typeof computed._fn === "function" && - el._config & CONFIG_AUTO_DISPOSE && - !(owner._statusFlags & STATUS_PENDING) && - !el._subs - ) { - // Deferred, not inline (#3078): an inline unobserved() here made untracked - // reads destructive — dispose on this read, full revival recompute on the - // next — so consecutive reads could answer differently with no write in - // between (the revival samples the ambient transition/lane context). - // The sweep at flush finalization re-validates and reclaims; schedule() - // guarantees that flush happens even if nothing else is queued. - dormantNodes.add(el as Computed); - schedule(); - } return value; } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index c55964c56..d8855fc91 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -38,10 +38,10 @@ import { stale, hasActiveOverride, visibleOverride, - readerSeesCommitted, recordStaleReplay, enterStagedRead, ownsHold, + serve, prepareComputed, read as readNode, READ_SLOW, @@ -1840,33 +1840,23 @@ export function authoritativeServe(): boolean { * FORCE sentinels never surface (they only bump subscribers of accessor * keys, which are served by the trap, not the node). */ function nodeValue(node: Signal, backing: any): any { - // latest() sees the in-flight parked value like an owner-context reader - // does (#3075) — signal/memo parity for store-node-backed keys. - // Authoritative-view reads (until()'s predicate) skip the override arm - // only: staged pending values are authoritative, overrides are the - // caller's optimism. - const v = - !authoritativeServe() && visibleOverride(node) + // Store-only tunnels, ahead of Rule 1: truth authors (authoritativeServe — + // the projection derive's draft, the write-override continuation) see + // staged truth and never an override; latest() reaching this untracked + // path for a store key sees the in-flight parked value like an + // owner-context reader does (#3075), the visible override first. + let v: any; + if (authoritativeServe()) v = node._pendingValue !== NOT_PENDING ? node._pendingValue : backing; + else if (latestReadActive) + v = visibleOverride(node) ? unwrapOverride(node._x?._overrideValue) - : node._pendingValue !== NOT_PENDING && - // Store-only tunnels first: latest() reaches this untracked path for - // store keys (#3075) and truth authors (authoritativeServe: the - // projection derive's draft, write-override) see staged truth - // unconditionally. Then Rule 1's committed-vs-staged arm — the - // same readerSeesCommitted core read() serves tracked reads by - // (owner context, children-forbidden, stale-of-foreign, HELD truth, - // lanes) — with core's context selection (a root reads as its - // parent computed). - (latestReadActive || - authoritativeServe() || - // A deriving reader served the staged value enters its - // transaction (A29) as core read() does on the same arm: the - // pass is the hold's, its result held with it — an untracked read - // inside a mainline memo must not publish the unrevealed frame. - (!readerSeesCommitted(node, readerContext(), (node as any)._firewall || node, false) && - (enterStagedRead(node), true))) + : node._pendingValue !== NOT_PENDING ? node._pendingValue : backing; + // Otherwise the one slow selection core read() uses (serve): override, + // lane gate, A28, readerSeesCommitted / A29 — with the BACKING as the + // committed value (single-home rule, O6). + else v = serve(node, readerContext(), (node as any)._firewall || node, backing); return v === (FORCE as any) ? backing : v; } diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index 4b5b9df2c..af8ec080e 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -33,6 +33,9 @@ * write; #3521 first cut). * S7 — (fixed) an optimistic override survives its key becoming unobserved * (the slot release defers to the flush that resolves the override). + * S8 — (fixed, 3b step 6c) a derivation's untracked read of a SUPERSEDED + * store node derives from the landed truth (A18), not the override — + * nodeValue delegates to core's one slow selection, `serve`. * Discovery for S4–S7: the matrix's `memoUntracked` / `effectUntracked` * reader kinds (an untracked read inside a derivation), added with S5. * @@ -475,3 +478,65 @@ describe("S7 — optimistic override, the only reader gated away: the override i expect(((s as any)[$TARGET].n ?? {}).n).toBeUndefined(); }); }); + +/** S8 (fixed, 3b step 6c): A18 supersession for a store node read UNTRACKED + * inside a derivation. A derived optimistic store's own truth landed (2) + * while an action's edit (3) is displayed: the signal serves a deriving + * reader the staged truth and holds it (A18: truth in the graph now, on + * screen at commit); the store's untracked node path served the memo the + * OVERRIDE and let it publish 3 — nodeValue had its own override arm without + * the supersession routing core read() has (overrideRead). nodeValue now + * delegates to `serve`, the one slow selection, and inherits the arm. */ +describe("S8 — a derivation's UNTRACKED read of a superseded store node derives from the truth (A18) — signal vs store", () => { + it("store: memo → render effect holds, then publishes the landed truth at settle", async () => { + const [value, setValue] = createSignal(0); + const fetches: Array<() => void> = []; + let s!: { n: number }; + let set!: (fn: (d: { n: number }) => void) => void; + createRoot(() => { + [s, set] = createOptimisticStore<{ n: number }>( + () => { + const v = value(); + return new Promise<{ n: number }>(r => fetches.push(() => r({ n: v * 2 }))); + }, + { n: -1 } + ); + // an initialized downstream flight keeps the action live after its truth lands + const downstream = createMemo(() => { + const n = s.n; + return new Promise(r => flights.push(() => r(`${n}!`))); + }); + createRenderEffect(downstream, () => {}); + }); + const flights: Array<() => void> = []; + flush(); + fetches.shift()!(); // initial truth {n: 0} + await settle(); + flights.shift()!(); + await settle(); + action(function* () { + setValue(1); + set(d => { + d.n = 3; + }); + yield new Promise(res => flights.push(res as () => void)); + })(); + flush(); + fetches.shift()!(); // own truth lands {n: 2} ≠ 3 + await settle(); + expect(s.n).toBe(3); // A18 (c): the override stays displayed for an untracked mainline read + const log: number[] = []; + createRoot(() => { + const m = createMemo(() => untrack(() => s.n)); + createRenderEffect(m, v => { + log.push(v); + }); + }); + flush(); + expect(log).toEqual([]); // held with the action: derives from the truth, not the override + flights.splice(0).forEach(f => f()); + await settle(); + await settle(); + expect(log).toEqual([2]); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index ebb1977c9..29aaf6ecc 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -350,7 +350,15 @@ describe("pay-for-use tree-shaking (#2883)", () => { // stale-of-foreign clause, the lane arm and the store's backing holds // (+44 B minified, -4 B brotli: the function is not inlined by esbuild). // 25,249 -> 25,338. - expect(minifiedBytes).toBeLessThan(25_400); + // serve() — Rule 1's one slow implementation (move 3b step 6c, 2026-09-17): + // read()'s slow tail extracted with the committed value as a parameter so the + // store's untracked node path selects through the same function (its + // backing as committed, O6). +88 B: the wrapper, the parameter, and the + // auto-dispose sweep guard that preserves the inline arm's early return. + // 25,338 -> 25,426. The additive half; the twins it makes deletable + // (overrideRead's wrapper, nodeValue, the verdict re-derivations) are the + // deletion half — see docs/DESIGN-CONSOLIDATION.md §0. + expect(minifiedBytes).toBeLessThan(25_500); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { From e9c49a19e053f572a0727d86f1f2ea2e1a09dce1 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 12:39:16 -0700 Subject: [PATCH 07/10] chore(size): ratchet the move-3b caps over #3518/#3522 (one consolidated note per scenario) --- scripts/size/.size-limit.js | 102 +++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 9d1dc9be5..03b221f51 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -284,7 +284,17 @@ module.exports = [ // One `unflushed` for signal and store (spec O4, 2026-09-16): 9,418 B (+18 over // the cap); CONFIG_ADOPTED_UNFLUSHED set at adoption, cleared by the carrying // flush; +56 B minified in the signals floor (25,193 -> 25,249). - limit: "9.45 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 9,489 B (+39 over the rebased cap). + limit: "9.50 KB", modifyEsbuildConfig }, { @@ -544,7 +554,19 @@ module.exports = [ // signals: core +13 B, +createStore -38 B, full bundle -72 B; brotli on the // pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle // measured at 16,509 B rebased over #3507, against `next`'s 16,517 (-8 B). - limit: "16.55 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. +55 B store (S7, an optimistic override survives its key becoming + // unobserved), +123 / +130 B store (S4 / S5 fixes at the backing and the + // untracked node paths). + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 16,610 B (+60 over the rebased cap). + limit: "16.65 KB", modifyEsbuildConfig }, { @@ -686,7 +708,17 @@ module.exports = [ // One `unflushed` for signal and store (spec O4, 2026-09-16): 12,124 B (+24 over // the cap); CONFIG_ADOPTED_UNFLUSHED set at adoption, cleared by the carrying // flush; +56 B minified in the signals floor (25,193 -> 25,249). - limit: "12.15 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 12,188 B (+38 over the rebased cap). + limit: "12.20 KB", modifyEsbuildConfig }, { @@ -786,7 +818,17 @@ module.exports = [ // One `unflushed` for signal and store (spec O4, 2026-09-16): 12,200 B (+50 over // the cap); CONFIG_ADOPTED_UNFLUSHED set at adoption, cleared by the carrying // flush; +56 B minified in the signals floor (25,193 -> 25,249). - limit: "12.25 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 12,274 B (+24 over the rebased cap). + limit: "12.30 KB", modifyEsbuildConfig }, { @@ -940,7 +982,17 @@ module.exports = [ // `reportClientError` takes the thrower the status wrapper already names // and fills `boundaryPath` beside `ownerPath`; this scenario renders // , so it carries the hook module. - limit: "20.10 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 20,148 B (+48 over the rebased cap). + limit: "20.20 KB", modifyEsbuildConfig }, { @@ -1136,7 +1188,19 @@ module.exports = [ // signals floor. // Error hook thrower/boundary paths (2026-09-17): 30.15 -> 30.25 KB, // measured at 30,203 B rebased over #3515, against `next`'s 30,130 (+73 B). - limit: "30.25 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. +55 B store (S7, an optimistic override survives its key becoming + // unobserved), +123 / +130 B store (S4 / S5 fixes at the backing and the + // untracked node paths). + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 30,296 B (+46 over the rebased cap). + limit: "30.35 KB", modifyEsbuildConfig }, { @@ -1251,7 +1315,17 @@ module.exports = [ // signals: core +13 B, +createStore -38 B, full bundle -72 B; brotli on the // pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle // measured at 15,251 B rebased over #3507, against `next`'s 15,238 (+13 B). - limit: "15.30 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 15,342 B (+42 over the rebased cap). + limit: "15.40 KB", modifyEsbuildConfig }, { @@ -1500,7 +1574,19 @@ module.exports = [ // measured at 27,256 B rebased over #3507, against `next`'s 27,196 (+60 B). // The source change is in the signals core; this scenario's attribution // modules only alter the compressor layout. - limit: "27.30 KB", + // Move 3b, one implementation per rule (#3523, 2026-09-17), rebased over + // #3518/#3522: readerSeesCommitted / visibleOverride (step 1, merged as + // #3515), the store's node reads through it (step 2), recordStaleReplay + // (step 3), A29 at the store's untracked paths (step 4), S7, one ownership + // relation ownsHold (6b) and serve() — Rule 1's one slow selection (6c). + // Core minified: +31 (replay helper) +1 (enterStagedRead null node) +44 + // (ownsHold, not inlined) +88 (serve wrapper/parameter/guard) = +164 B. +55 B store (S7, an optimistic override survives its key becoming + // unobserved), +123 / +130 B store (S4 / S5 fixes at the backing and the + // untracked node paths). + // Five store/signal divergences fixed (posture-store-parity S4, S5, S7, S8; + // S6 ruled and deferred); every paired matrix state row-identical. + // Measured at 27,335 B (+35 over the rebased cap). + limit: "27.40 KB", modifyEsbuildConfig: observeEsbuildConfig }, { From 9db33cf3a47b1113442096fe355756eafaba0f70 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 13:32:01 -0700 Subject: [PATCH 08/10] =?UTF-8?q?refactor(signals):=20the=20store's=20back?= =?UTF-8?q?ing=20visibility=20on=20the=20shared=20predicates=20=E2=80=94?= =?UTF-8?q?=20one=20holdVisible=20for=20both=20hold=20kinds=20(move=203b,?= =?UTF-8?q?=20the=20deletion=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readSource (adoption hold) and pendingBackingVisible (fold) each restated Rule 1's hold arms for a CONTAINER — committed or staged — with their own helpers: heldFromReader, foreignHold, enterHeldBacking, staleReplay, inOwnerContext, inForbiddenScope. One holdVisible(txn, c) now, on core's ownsHold / recordStaleReplay / enterStagedRead: the pass owns the hold (or there is none) → staged; a foreign hold → a stale pass keeps committed and is recorded for replay, a deriving pass takes staged and enters (A29). Both callers serve context-free and children-forbidden readers the committed container themselves (A32), with the store-only contracts kept as written: the speculative peek, the projection's authoritative-elect backing, HELD truth on an optimistic family (heldTruthMasked), drafts and write-override. This is the twin serve() could not absorb: a container has no node to select on, so the arms stay at the store — once, on the shared predicates, instead of six times. Zero semantic change: the 851-cell posture matrix and both oracles are byte-identical; suite green. Store −313 B minified / −87 B brotli (the +createStore scenario −48 B, hydrating + stores −112 B). The size ceiling with the whole owner-context arm removed was −738 B; the remainder is the container choice itself. Co-authored-by: Claude via Cursor --- .changeset/store-hold-visible.md | 5 + packages/signals/docs/RULES-INDEX.md | 6 +- packages/signals/src/store/next/store.ts | 169 ++++++++--------------- 3 files changed, 63 insertions(+), 117 deletions(-) create mode 100644 .changeset/store-hold-visible.md diff --git a/.changeset/store-hold-visible.md b/.changeset/store-hold-visible.md new file mode 100644 index 000000000..a3bfb0e91 --- /dev/null +++ b/.changeset/store-hold-visible.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +The store's backing-level visibility (which container — committed or staged — a reader of a held store sees, for property reads, `in`, `Object.keys` and descriptors) is one `holdVisible` on the core's shared predicates for both hold kinds (a setter's fold, an adoption under a transaction), replacing six store-local helpers. No behavior change; −313 B minified in the store. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 751336182..72fb32be7 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,9 +59,9 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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×5 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 posture-store-parity.test.ts×2 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.states.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 … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×5 lanes.ts×2 scheduler.ts×2 store.ts×1 | 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 posture-store-parity.test.ts×2 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.states.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.states.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×10 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×2 | optimistic-undefined-override.test.ts×1 posture-store-parity.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.states.ts×11 visibility-oracle-store.test.ts×1 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… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×4 constants.ts×2 core.ts×10 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×1 | optimistic-undefined-override.test.ts×1 posture-store-parity.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.states.ts×11 visibility-oracle-store.test.ts×1 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×7 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 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.states.ts×9 visibility-oracle-store.test.ts×1 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×2 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.states.ts×5 visibility-oracle-store.test.ts×1 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:340` | 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… | @@ -70,7 +70,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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.states.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.states.ts×7 visibility-oracle-store.test.ts×1 | [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` | core.ts×1 scheduler.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 scheduler.ts×1 store.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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×2 core.ts×20 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 posture-store-parity.test.ts×5 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.states.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` | action.ts×1 core.ts×7 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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 … | diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index d8855fc91..22c7228bb 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -403,52 +403,26 @@ function heldFoldTransition(target: StoreNextTarget): Transition | null { } /** - * Core read()'s committed-visibility clause, at the backing (#3336): - * `(stale && el._transition !== null) ? _value : _pendingValue`. While a live - * transaction holds the pending backing, a stale (render) reader — and a - * reader with no owner at all — sees committed, through every channel: an - * untracked read in the effect, `in`, `Object.keys`, `deep()`/`snapshot()`. - * The node path already answers this way; without the backing twin the same - * effect read `0` through `a.count` and `1` through `untrack(() => a.count)` - * or `"added" in a`. Speculation stays visible to non-stale owner-context - * readers and to the peek from inside one; a pending backing with no - * transaction (a same-tick plain write) is unaffected. + * Rule 1 at the backing, the hold half (core serve()'s stale-of-foreign and + * A29 arms, for a container instead of a node): given the transaction + * holding what a reader `c` (a pass; callers serve context-free readers the + * committed container themselves) is about to be served, is the STAGED + * container its to see? Its own hold, or none — yes. A foreign hold — a + * stale pass (render effect) keeps the committed frame and is recorded for + * replay at the hold's commit (recordStaleReplay, A15 / A26); a deriving + * pass takes the staged world and enters the transaction (enterStagedRead, + * A29). Shared by both hold kinds: a setter's fold (committed `v`, staged + * `pb`, the write-time stamp) and an adoption under a transaction + * (committed = the held view `hv`, staged = the adopted `v`, #3074). */ -function heldFromReader(target: StoreNextTarget): boolean { - if (!stale && inOwnerContext()) return false; - const txn = liveFoldTransition(target); - return txn !== null && foreignHold(txn) && (staleReplay(txn), true); -} - -/** The replay half of the clause (core recordStaleReplay): the stale reader - * just denied the held value re-runs at the hold's commit. Without it the - * node path replayed (heldFromStale) and the backing paths did not — an - * effect's untracked read of a key with no node stayed on the pre-action - * value after the action settled (posture-store-parity S4). Only a reader in - * context has a pass to replay; a children-forbidden reader sees the frame - * and never the graph (A32). */ -function staleReplay(txn: Transition): void { - const c = readerContext(); - if (c !== null && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) recordStaleReplay(txn, c); -} - -/** The other half of serving a held backing: a deriving reader (a pass in - * owner context) served the pending backing a live transaction holds - * derives from that transaction's world and enters it (A29, core - * enterStagedRead on the same arm) — its result is held with the fold, not - * published into the mainline frame. Without it a mainline memo's untracked - * read of a held key (`untrack(() => s.n)`, `deep(s)`) published the - * unrevealed value while the same read of a signal was born held. */ -function enterHeldBacking(target: StoreNextTarget, txn = liveFoldTransition(target)): void { - if (txn !== null && readerContext() !== null) enterStagedRead(null, txn); -} - -/** Core read()'s `activeTransition !== el._transition`: a hold belongs to a - * FOREIGN transaction unless the flush running now is that transaction's — - * its own stale readers (a render effect recomputing in it, whose run the - * commit applies) see the staged world. */ -function foreignHold(txn: Transition): boolean { - return !ownsHold(txn); +function holdVisible(txn: Transition | null, c: Computed): boolean { + if (txn === null || ownsHold(txn)) return true; + if (stale) { + recordStaleReplay(txn, c); + return false; + } + enterStagedRead(null, txn); + return true; } function stageHeldKey(node: Signal, nv: any, txn: Transition): void { @@ -1652,23 +1626,6 @@ function readerContext(): Computed | null { return c === null ? null : c._root ? (c._parentComputed ?? null) : c; } -function inOwnerContext(): boolean { - const eff = readerContext(); - return eff !== null && !(eff._config & CONFIG_CHILDREN_FORBIDDEN); -} - -/** CHILDREN_FORBIDDEN execution scope (createTrackedEffect / onSettled - * callbacks). Distinct from context-free: these scopes get committed - * visibility even against a projection's authoritative-elect pending - * backing (#3082) — parity with signals, where core read() serves - * committed to them regardless of staged writes. */ -function inForbiddenScope(): boolean { - const c: any = getOwner(); - if (c === null) return false; - const eff = c._root ? c._parentComputed : c; - return eff != null && !!(eff._config & CONFIG_CHILDREN_FORBIDDEN); -} - /** A pending fold is transition-held when any written node's parked value is * stamped by a live transition (a plain batch parking — the lazy-recompute * read case — has no transition stamp and serves fresh). */ @@ -1688,32 +1645,23 @@ function foldHeld(target: StoreNextTarget): boolean { } function readSource(target: StoreNextTarget): Record { - // Held view first (#3074): an adoption staged under a live hold serves the - // pre-hold committed backing to committed-visibility readers. Speculative - // readers — drafts, write-override, owner-context computeds recomputing - // inside the transaction, and latest() reads — see the adopted backing. - if ( - target.ht !== null && - !latestReadActive && - !inDraft(target) && - !getWriteOverride() && - // A stale (render) reader of a FOREIGN transaction's hold is a - // committed-visibility reader whatever its owner context (#3336). - (!inOwnerContext() || - (stale && target.ht !== PLAIN_HOLD && foreignHold(currentTransition(target.ht)))) - ) { + // Adoption hold first (#3074): an adoption staged under a live transaction + // (or a latest()-pull, PLAIN_HOLD) serves the pre-hold committed view to + // committed-visibility readers — context-free and children-forbidden ones, + // and stale passes off a foreign hold. Drafts, write-override and latest() + // see the adopted backing. + const ht = target.ht; + if (ht !== null && !latestReadActive && !inDraft(target) && !getWriteOverride()) { const hv = heldMaskView(target); if (hv !== null) { - // The reader denied the adopted view replays at the adoption's commit - // (the replay half of the clause; a latest()-pull PLAIN_HOLD is not a - // transaction and has no commit). - if (target.ht !== PLAIN_HOLD) staleReplay(currentTransition(target.ht as Transition)); - return hv; + const c = readerContext(); + if ( + c === null || + c._config & CONFIG_CHILDREN_FORBIDDEN || + !holdVisible(ht === PLAIN_HOLD ? null : currentTransition(ht as Transition), c) + ) + return hv; } - } else if (target.ht !== null && !latestReadActive && !inDraft(target) && !getWriteOverride()) { - // An owner-context deriving reader served the ADOPTED view under a live - // adoption hold derives from the adoption's transaction (A29). - enterHeldBacking(target, heldAdoptionTransition(target)); } return pendingBackingVisible(target, false) ? target.pb! : target.v; } @@ -1734,35 +1682,28 @@ function readSource(target: StoreNextTarget): Record { * divergence from context-free per-key reads) — but never through a hold: * held truth stays masked exactly as it is for per-key readers. */ function pendingBackingVisible(target: StoreNextTarget, speculative: boolean): boolean { - return ( - target.pb !== null && - (inDraft(target) || - getWriteOverride() || - // Owner-context (and speculative-peek) readers see the pending - // backing — EXCEPT held truth on an optimistic family (#3164 fold): - // a live pb on an opt family outside the draft/write-override windows - // is a staged landing (tentative drafts never outlive their setter), - // and only the authoritative postures and latest() see it (the - // backing-level twin of core read()'s A17-for-held-truth arm; - // ordinary readers keep committed until the transaction's reveal). - // Stale readers and owner-less peeks of a TRANSACTION-held backing see - // committed, as core read() serves them (#3336, heldFromReader). - ((speculative || inOwnerContext()) && - !heldTruthMasked(target) && - !heldFromReader(target) && - (enterHeldBacking(target), true)) || - // A projection's pending backing is authoritative-elect: serve it to - // context-free readers too UNLESS a transition is holding the node - // commits (downstream async hold — stale committed is the contract) - // or the reader is a CHILDREN_FORBIDDEN scope, which never observes - // its own unsettled write (#3082, signal parity per #3006). - // (The write-time stamp covers keys with no node, #3336.) - (target.fam !== null && - !heldTruthMasked(target) && - !foldHeld(target) && - liveFoldTransition(target) === null && - !inForbiddenScope())) - ); + if (target.pb === null) return false; + // The writer's own channels compose on the pending backing regardless. + if (inDraft(target) || getWriteOverride()) return true; + // HELD truth on an optimistic family (#3164 fold) is masked from ordinary + // readers until the transaction's reveal (the backing-level twin of core + // serve()'s CONFIG_HELD_TRUTH arm; authoritative postures and latest() + // tunnel through inside heldTruthMasked). + if (heldTruthMasked(target)) return false; + const c = readerContext(); + if (c === null || c._config & CONFIG_CHILDREN_FORBIDDEN) { + // No pass, or a children-forbidden one: the committed frame (A32) — + // except the speculative peek (deep()/snapshot()), which sees ordinary + // pending staging but never through a live foreign hold, and a + // projection's pending backing, authoritative-elect for context-free + // readers UNLESS a transition holds the node commits (downstream async + // hold — stale committed is the contract; the write-time stamp covers + // keys with no node, #3336) or the scope is children-forbidden (#3082). + const txn = liveFoldTransition(target); + if (speculative) return txn === null || ownsHold(txn); + return target.fam !== null && c === null && !foldHeld(target) && txn === null; + } + return holdVisible(liveFoldTransition(target), c); } /** #3164 fold: HELD truth on an optimistic family — a pending backing From c410709fafa0e6fe7b1442d41081eb63a252091f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 14:08:21 -0700 Subject: [PATCH 09/10] fix(signals): an optimistic add/delete survives its only structural observer leaving (S7, structural half; review of #3523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optimistic add or delete lives on the key's PRESENCE node (target.h, getHasNode) as its membership override. Its unobserved callback released the node the moment the last structural observer left — with the override on it — so `in`, Object.keys and descriptors fell back to committed structure while the action was live: the structural twin of the value-slot bug S7 fixed. The presence node now defers its release through deferSlotRelease while it carries an override or a staged value; the transient-node sweep runs its callback once the state resolves. Pinned for optimistic add and delete: the only structural observer gates away, in / Object.keys / getOwnPropertyDescriptor keep the optimistic structure while the action is live, settlement restores committed structure and releases target.h[key]. Suite green; oracles and matrix identical; +35 B minified store. Co-authored-by: Claude via Cursor --- ...e-presence-override-survives-unobserved.md | 5 ++ packages/signals/src/store/next/store.ts | 7 +++ .../tests/posture-store-parity.test.ts | 47 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 .changeset/store-presence-override-survives-unobserved.md diff --git a/.changeset/store-presence-override-survives-unobserved.md b/.changeset/store-presence-override-survives-unobserved.md new file mode 100644 index 000000000..4de9fa6a9 --- /dev/null +++ b/.changeset/store-presence-override-survives-unobserved.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An optimistic add or delete on a store now survives its only structural observer leaving: the key's presence node was released with the membership override on it, so `in`, `Object.keys` and property descriptors fell back to the committed structure while the action was still live. The release now waits for the flush that resolves the override, as the value slot's does. diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 22c7228bb..43c0f8894 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -477,6 +477,13 @@ export function getHasNode( equals: isEqual, unobserved() { if ((created as any)._x?._affectsCount) return; + // The structural twin of the value slot's rule (setSlotUnobserved, + // S7): an optimistic add/delete lives on this presence node as its + // override — releasing it with the override on would let `in`, + // `Object.keys` and descriptors fall back to committed structure + // while the action is live. Defer to the flush that resolves it. + if (hasActiveOverride(created) || created._pendingValue !== NOT_PENDING) + return deferSlotRelease(created); if (target.h && target.h[key] === created) { delete target.h[key]; unlinkFirewallChild(created); diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index af8ec080e..82b486ade 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -479,6 +479,53 @@ describe("S7 — optimistic override, the only reader gated away: the override i }); }); +/** S7, the structural half (review of #3523): an optimistic add or delete + * lives on the key's PRESENCE node (`target.h`, getHasNode) as its override. + * Its unobserved callback released the node the moment the last structural + * observer left — with the override on it — so `in`, `Object.keys` and + * descriptors fell back to committed structure while the action was live. + * The presence node now defers its release like the value slot does. */ +describe("S7 (structural) — optimistic add/delete survives the only structural observer gating away", () => { + for (const kind of ["add", "delete"] as const) { + it(`optimistic ${kind}: in / Object.keys / descriptor keep the optimistic structure while live; settle restores committed and releases the presence node`, async () => { + const [s, setS] = createOptimisticStore>( + kind === "add" ? {} : { k: 1 } + ); + const [show, setShow] = createSignal(true); + createRoot(() => { + createRenderEffect( + () => (show() ? "k" in s : "gated"), + () => {} + ); + }); + flush(); + let release!: () => void; + action(function* () { + setS(d => { + if (kind === "add") d.k = 5; + else delete d.k; + }); + yield new Promise(res => (release = res)); + })(); + flush(); + const live = kind === "add"; + const structure = () => [ + "k" in s, + Object.keys(s).includes("k"), + Object.getOwnPropertyDescriptor(s, "k") !== undefined + ]; + expect(structure()).toEqual([live, live, live]); + setShow(false); // the only structural observer gates away + flush(); + expect(structure()).toEqual([live, live, live]); + release(); + await settle(); + expect(structure()).toEqual([!live, !live, !live]); + expect(((s as any)[$TARGET].h ?? {}).k).toBeUndefined(); + }); + } +}); + /** S8 (fixed, 3b step 6c): A18 supersession for a store node read UNTRACKED * inside a derivation. A derived optimistic store's own truth landed (2) * while an action's edit (3) is displayed: the signal serves a deriving From aec7ecdba1bf7c5bf92283b468b0b2ad3a282cda Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 14:12:41 -0700 Subject: [PATCH 10/10] =?UTF-8?q?test(signals):=20structural=20store=20sta?= =?UTF-8?q?tes=20in=20the=20visibility=20oracle=20=E2=80=94=20in=20/=20Obj?= =?UTF-8?q?ect.keys=20/=20descriptor=20under=20a=20held=20add=20and=20opti?= =?UTF-8?q?mistic=20add/delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oracle and the posture matrix only ever read a VALUE; structure — a key's presence through `in`, Object.keys, getOwnPropertyDescriptor — rides presence nodes and the key-set node and had no state, so the class 'structure under a hold or override, by posture' was invisible (the review of #3523 found an optimistic add lost to committed structure the moment its only structural observer left; no cell could have shown it). Nine states now: the plain store's held add and the optimistic add and delete, each through the three structural channels. The matrix grows 851 -> 1184 cells; the gatedAway posture over these is the reviewer's shape, kept optimistic after the gate. Six pre-existing divergences between structure and value under the same rules, pinned as violations (suite green, red in the oracle report): - latest() over a held add sees the parked value of s.n but not the parked structure — none of the three structural channels has a latest() tunnel; - the descriptor trap reads no node: isPending() over it witnesses nothing, and a pre-existing render effect inspecting a key through getOwnPropertyDescriptor never re-runs for an optimistic add or delete. Co-authored-by: Claude via Cursor --- packages/signals/docs/RULES-INDEX.md | 22 +-- .../tests/visibility-oracle-store.states.ts | 141 +++++++++++++++++- 2 files changed, 151 insertions(+), 12 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 72fb32be7..ea25086d9 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -55,28 +55,28 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | core.ts×1 verdict.ts×2 | createMemo.test.ts×1 visibility-oracle-store.states.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.states.ts×4 visibility-oracle-store.test.ts×1 | [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.states.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… | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.states.ts×2 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×5 lanes.ts×2 scheduler.ts×2 store.ts×1 | 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 posture-store-parity.test.ts×2 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.states.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 … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×5 lanes.ts×2 scheduler.ts×2 store.ts×1 | 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 posture-store-parity.test.ts×2 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.states.ts×6 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.states.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×10 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×1 | optimistic-undefined-override.test.ts×1 posture-store-parity.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.states.ts×11 visibility-oracle-store.test.ts×1 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… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×4 constants.ts×2 core.ts×10 invariants.ts×3 optimistic.ts×6 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×1 | optimistic-undefined-override.test.ts×1 posture-store-parity.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.states.ts×24 visibility-oracle-store.test.ts×1 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×7 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 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.states.ts×9 visibility-oracle-store.test.ts×1 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×2 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.states.ts×5 visibility-oracle-store.test.ts×1 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… | +| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | async.ts×1 core.ts×2 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.states.ts×8 visibility-oracle-store.test.ts×1 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:340` | 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:347` | — | 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.states.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.states.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… | +| 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.states.ts×4 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.states.ts×7 visibility-oracle-store.test.ts×1 | [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` | core.ts×1 scheduler.ts×1 store.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.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… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 scheduler.ts×1 store.ts×1 | action-await-contract.test.ts×2 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 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×2 core.ts×20 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 posture-store-parity.test.ts×5 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.states.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` | action.ts×1 core.ts×7 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×3 visibility-oracle-store.test.ts×1 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 … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | action.ts×1 core.ts×7 effect.ts×1 optimistic.ts×1 signals.ts×1 store.ts×3 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 direct-commit-readers-posture.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 latest-held-till-flush.test.ts×2 posture-store-parity.test.ts×4 treeshake.test.ts×1 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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×2 effect.ts×1 scheduler.ts×4 | 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 posture-born-held-and-observation.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` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 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… | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | core.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 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 @@ -157,7 +157,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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.states.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-R11 | live | `docs/rules-mining/core-store.md:52` | — | visibility-oracle-store.states.ts×3 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. | @@ -179,11 +179,11 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | 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.states.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.states.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-R33 | live | `docs/rules-mining/core-store.md:147` | — | visibility-oracle-store.states.ts×4 | 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.states.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.states.ts×1 | Setting store state from effect callbacks and promise resolutions works, applying next flush.\*\* | +| CS-R37 | live | `docs/rules-mining/core-store.md:165` | — | visibility-oracle-store.states.ts×3 | 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.states.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… | diff --git a/packages/signals/tests/visibility-oracle-store.states.ts b/packages/signals/tests/visibility-oracle-store.states.ts index 6ff8c33ce..69e8a4f66 100644 --- a/packages/signals/tests/visibility-oracle-store.states.ts +++ b/packages/signals/tests/visibility-oracle-store.states.ts @@ -320,5 +320,144 @@ export const STATES: State[] = [ isPending: rule(false, "A16 / A19 exception (1): loading, not pending"), authoritative: rule(NOT_READY, "A17 carve-out: nothing landed") } - } + }, + // ── structural reads ────────────────────────────────────────────────────── + // The same states read for STRUCTURE — `in`, Object.keys, a descriptor — + // instead of a value. Structure rides presence nodes (getHasNode) and the + // key-set node, not value slots: every rule above must hold for them too + // (the review of #3523 found an optimistic add lost to committed structure + // the moment its only structural observer left — a cell no value state + // could show). One state per structural channel. + ...( + [ + ["in", (s: any) => "k" in s], + ["Object.keys", (s: any) => Object.keys(s).includes("k")], + ["descriptor", (s: any) => Object.getOwnPropertyDescriptor(s, "k") !== undefined] + ] as Array<[string, (s: any) => boolean]> + ).flatMap(([label, probe]): State[] => [ + { + name: `plain store, structure via ${label}: key added under a live action (setStore inside action, yield forever)`, + build(installStale) { + const [s, set] = createStore>({}); + const x = () => probe(s); + installStale(x); + action(function* () { + set(d => { + d.k = 1; + }); + yield never(); + })(); + flush(); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(false, "A19 (i) / CS-R33: committed structure while the add is held"), + derivesFrom: rule( + true, + "A29: a tracked pass served the staged structure derives from the transaction's world" + ), + published: rule(HELD, "A29 (born held)"), + preexisting: rule(HELD, "A19 (i)"), + staleForeign: rule( + false, + "A15 / A26: a stale reader of a parallel transaction shows committed structure" + ), + childrenForbidden: rule(false, "A32"), + latest: violation( + true, + false, + "A11 for structure: latest() sees the parked VALUE of `s.n` (#3075) but not the parked STRUCTURE — the has-node / key-set / descriptor channels have no latest() tunnel (structural oracle, 2026-09-17)" + ), + isPending: + label === "descriptor" + ? violation( + true, + false, + "the descriptor trap reads no node, so an isPending() probe over it witnesses nothing (structural oracle, 2026-09-17)" + ) + : rule(true, "A19 (i) / CS-R33: a held add pends the key"), + authoritative: rule(true, "A17 carve-out: staged structure is authoritative") + } + }, + { + name: `optimistic store, structure via ${label}: key added inside a live action (override)`, + build(installStale) { + const [s, set] = createOptimisticStore>({}); + const x = () => probe(s); + installStale(x); + action(function* () { + set(d => { + d.k = 5; + }); + yield never(); + })(); + flush(); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(true, "A17 / OS: the optimistic add is the displayed structure"), + derivesFrom: rule(true, "A17"), + published: observed( + true, + "as for the value slot: a fresh mainline memo's publish-or-hold is not stated" + ), + preexisting: + label === "descriptor" + ? violation( + true, + HELD, + "the descriptor trap subscribes to nothing: a pre-existing render effect inspecting the key through getOwnPropertyDescriptor never re-runs for the optimistic add (structural oracle, 2026-09-17)" + ) + : rule(true, "A17: no downstream async, nothing to wait for"), + staleForeign: rule(true, "A17"), + childrenForbidden: rule(true, "A32: the override is the frame"), + latest: rule(true, "A17 / OL-R11"), + isPending: rule(false, "A24 (3) / OS-R37: optimistic writes are verdict-inert"), + authoritative: rule( + false, + "A17 carve-out / authoritativeServe(): never the caller's optimism" + ) + } + }, + { + name: `optimistic store, structure via ${label}: key deleted inside a live action (override)`, + build(installStale) { + const [s, set] = createOptimisticStore>({ k: 1 }); + const x = () => probe(s); + installStale(x); + action(function* () { + set(d => { + delete d.k; + }); + yield never(); + })(); + flush(); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(false, "A17 / OS: the optimistic delete is the displayed structure"), + derivesFrom: rule(false, "A17"), + published: observed( + false, + "as for the value slot: a fresh mainline memo's publish-or-hold is not stated" + ), + preexisting: + label === "descriptor" + ? violation( + false, + HELD, + "the descriptor trap subscribes to nothing: a pre-existing render effect inspecting the key through getOwnPropertyDescriptor never re-runs for the optimistic delete (structural oracle, 2026-09-17)" + ) + : rule(false, "A17: no downstream async, nothing to wait for"), + staleForeign: rule(false, "A17"), + childrenForbidden: rule(false, "A32: the override is the frame"), + latest: rule(false, "A17 / OL-R11"), + isPending: rule(false, "A24 (3) / OS-R37: optimistic writes are verdict-inert"), + authoritative: rule( + true, + "A17 carve-out / authoritativeServe(): never the caller's optimism" + ) + } + } + ]) ];