diff --git a/.changeset/body-end-visibility.md b/.changeset/body-end-visibility.md new file mode 100644 index 000000000..03a42bb75 --- /dev/null +++ b/.changeset/body-end-visibility.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +The window after an action body ends and its override is superseded by the committed truth (#3427) now reads like a landing supersession for every reader: a stale reader re-run by an unrelated write keeps displaying the override, a memo created mainline during the window is held with the transaction, and `isPending()` reads true while the truth differs from the override (`latest()` already answered the truth). The node carries no transaction stamp in that window — an override written inside an action never passes the adoption loop that stamps one — so `supersededRead` and the verdict now resolve the owning transaction through `_overrideOwner`. Also: a `latest()` / `isPending()` pull from mainline never enters a transaction (it is an observation); one did through the supersession path and captured the caller's synchronous block. diff --git a/.changeset/latest-seed-invisibility.md b/.changeset/latest-seed-invisibility.md new file mode 100644 index 000000000..9fa9a49aa --- /dev/null +++ b/.changeset/latest-seed-invisibility.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`latest(() => store.key)` on a derived store (projection) that has not yet resolved threw for tracked and untracked reads but returned the **seed** through `latest()`: `read()` routes a `latest()` read to the companion before its firewall/status logic, and the leaf's own `_value` is the seed. `latest()` now judges "uninitialized" on the leaf's owner — the projection's firewall — and throws `NotReadyError` like every other read (A25: the seed is a draft, never a value; A7). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 86fee5347..1248f7082 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -22,13 +22,13 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | vocabulary | rules | cited in src | cited in tests | cited nowhere | | ---------- | ----- | ------------ | -------------- | ------------- | -| A | 31 | 13 | 31 | 0 | +| A | 31 | 14 | 31 | 0 | | V | 5 | 2 | 5 | 0 | | B | 5 | 0 | 5 | 0 | | C | 4 | 0 | 3 | 1 | | INV | 11 | 11 | 5 | 0 | | RUL | 13 | 6 | 6 | 5 | -| R (CS) | 59 | 18 | 11 | 34 | +| R (CS) | 59 | 18 | 15 | 31 | | R (OL) | 37 | 0 | 0 | 37 | | R (OS) | 46 | 2 | 0 | 44 | | R (PJ) | 36 | 6 | 1 | 30 | @@ -43,39 +43,39 @@ 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:229` | — | 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:237` | — | 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:245` | — | 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:253` | — | 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:261` | — | 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:269` | — | 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:109` | verdict.ts×1 | spec-async-semantics.test.ts×2 visibility-oracle.test.ts×2 | [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:117` | — | createMemo.test.ts×1 visibility-oracle.test.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:125` | — | spec-async-semantics.test.ts×3 | [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:133` | invariants.ts×1 | createMemo.test.ts×1 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:51` | — | latest-isPending-consistency.test.ts×1 visibility-oracle.test.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:141` | — | 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:149` | 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:157` | — | 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:191` | async.ts×3 core.ts×3 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 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 treeshake.test.ts×4 visibility-oracle.test.ts×7 | [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:165` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×3 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×1 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.test.ts×16 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | -| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 verdict.ts×1 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle.test.ts×13 | [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:93` | 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.test.ts×11 | [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:281` | 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:288` | — | 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:173` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | -| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | 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:101` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle.test.ts×4 | [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:219` | — | uninitialized-visibility.test.ts×3 | [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:59` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle.test.ts×2 | [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:211` | — | loading-value.test.ts×2 visibility-oracle.test.ts×10 | [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… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×4 effect.ts×1 optimistic.ts×1 | born-held.test.ts×3 held-conditional-memo.test.ts×1 treeshake.test.ts×1 visibility-oracle.test.ts×5 | [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:199` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | -| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | 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:83` | — | visibility-oracle.test.ts×7 | [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… | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| --- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | — | 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:237` | — | 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:245` | — | 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:253` | — | 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:261` | — | 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:269` | — | 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:109` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.test.ts×2 | [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:117` | — | createMemo.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.test.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:125` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | invariants.ts×1 | createMemo.test.ts×1 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:51` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.test.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:141` | — | 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:149` | 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:157` | — | 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:191` | async.ts×3 core.ts×3 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 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 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 visibility-oracle.test.ts×7 | [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:165` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×2 visibility-oracle.test.ts×3 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.test.ts×12 visibility-oracle.test.ts×26 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.test.ts×10 visibility-oracle.test.ts×25 | [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:93` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.test.ts×12 | [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:281` | 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:288` | — | 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:173` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | +| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | 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:101` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×2 visibility-oracle.test.ts×4 | [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:219` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.test.ts×2 | [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:211` | — | loading-value.test.ts×2 visibility-oracle.test.ts×19 | [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… | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×4 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.test.ts×6 | [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:199` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | 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:83` | — | visibility-oracle-store.test.ts×6 visibility-oracle.test.ts×10 | [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… | ## V — fixed violations @@ -142,67 +142,67 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## 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 | 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:12` | — | syncThenable.test.ts×12 visibility-oracle.test.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:16` | — | — | 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:19` | — | — | 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:22` | — | visibility-oracle.test.ts×4 | Upstream writes propagate downstream through the chain without re-running structural machinery.\*\* | -| CS-R6 | live | `docs/rules-mining/core-store.md:25` | — | — | 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:28` | — | — | Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).\*\* | -| CS-R8 | live | `docs/rules-mining/core-store.md:31` | — | — | `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:34` | 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:39` | — | 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:42` | — | visibility-oracle.test.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:45` | 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:48` | 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:51` | — | — | `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:54` | 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:57` | — | — | `length` independently trackable; index write extending the array notifies length subscribers.\*\* | -| CS-R17 | live | `docs/rules-mining/core-store.md:60` | — | — | 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:63` | — | — | `snapshot` is non-tracking.\*\* Aligned with read table. | -| CS-R19 | live | `docs/rules-mining/core-store.md:66` | — | — | `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.\*\* | -| CS-R20 | live | `docs/rules-mining/core-store.md:69` | 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:72` | 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:76` | — | — | 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:81` | 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:84` | — | 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:88` | — | 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:92` | — | — | 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:95` | 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:98` | — | — | `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:101` | 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:105` | 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:108` | 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:112` | store.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:115` | — | — | 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:118` | store.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.\*\* | -| CS-R35 | live | `docs/rules-mining/core-store.md:122` | — | — | 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:125` | — | — | 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:129` | — | — | Setting store state from effect callbacks and promise resolutions works, applying next flush.\*\* | -| CS-R38 | live | `docs/rules-mining/core-store.md:134` | — | — | 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:137` | — | — | 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:140` | — | — | 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:143` | 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:147` | 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:150` | — | — | 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:153` | 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:157` | — | — | 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:160` | — | — | 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:165` | — | — | 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:169` | — | — | 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:172` | — | — | Null-prototype objects wrap and track; function-valued props callable through the proxy.\*\* | -| CS-R50 | live | `docs/rules-mining/core-store.md:175` | — | — | Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).\*\* | -| CS-R51 | live | `docs/rules-mining/core-store.md:179` | 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:182` | — | — | 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:185` | — | — | 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:188` | — | — | 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:190` | — | — | Functions stored as values served raw, replaceable, slot-tracked.\*\* | -| CS-R56 | live | `docs/rules-mining/core-store.md:194` | — | — | 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:197` | — | — | Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.\*\* | -| CS-R58 | live | `docs/rules-mining/core-store.md:200` | — | — | 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 | 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:12` | — | syncThenable.test.ts×12 visibility-oracle-store.test.ts×1 visibility-oracle.test.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:16` | — | — | 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:19` | — | — | 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:22` | — | visibility-oracle-store.test.ts×4 visibility-oracle.test.ts×4 | Upstream writes propagate downstream through the chain without re-running structural machinery.\*\* | +| CS-R6 | live | `docs/rules-mining/core-store.md:25` | — | — | 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:28` | — | — | Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).\*\* | +| CS-R8 | live | `docs/rules-mining/core-store.md:31` | — | — | `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:34` | 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:39` | — | 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:42` | — | visibility-oracle-store.test.ts×2 visibility-oracle.test.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:45` | 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:48` | 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:51` | — | — | `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:54` | 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:57` | — | — | `length` independently trackable; index write extending the array notifies length subscribers.\*\* | +| CS-R17 | live | `docs/rules-mining/core-store.md:60` | — | — | 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:63` | — | — | `snapshot` is non-tracking.\*\* Aligned with read table. | +| CS-R19 | live | `docs/rules-mining/core-store.md:66` | — | — | `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.\*\* | +| CS-R20 | live | `docs/rules-mining/core-store.md:69` | 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:72` | 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:76` | — | — | 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:81` | 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:84` | — | 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:88` | — | 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:92` | — | — | 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:95` | 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:98` | — | — | `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:101` | 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:105` | 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:108` | 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:112` | store.ts×1 | visibility-oracle-store.test.ts×1 | A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.\*\* Aligned: O7's resolution (a test already exists). | +| CS-R33 | live | `docs/rules-mining/core-store.md:115` | — | visibility-oracle-store.test.ts×2 | Action/async lane semantics on store properties: a write held by an action makes isPending true for that property (per-property, not whole-store) while showing the committed value; applies on settle.\*… | +| CS-R34 | live | `docs/rules-mining/core-store.md:118` | store.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.\*\* | +| CS-R35 | live | `docs/rules-mining/core-store.md:122` | — | — | 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:125` | — | — | 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:129` | — | visibility-oracle-store.test.ts×1 | Setting store state from effect callbacks and promise resolutions works, applying next flush.\*\* | +| CS-R38 | live | `docs/rules-mining/core-store.md:134` | — | — | 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:137` | — | visibility-oracle-store.test.ts×1 | Shallow setter-scope reads serve raws; in-place mutation of a served raw is reactively inert — records replaced, never edited.\*\* | +| CS-R40 | live | `docs/rules-mining/core-store.md:140` | — | — | 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:143` | 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:147` | 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:150` | — | — | 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:153` | 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:157` | — | — | 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:160` | — | — | 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:165` | — | — | 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:169` | — | — | 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:172` | — | — | Null-prototype objects wrap and track; function-valued props callable through the proxy.\*\* | +| CS-R50 | live | `docs/rules-mining/core-store.md:175` | — | — | Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).\*\* | +| CS-R51 | live | `docs/rules-mining/core-store.md:179` | 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:182` | — | — | 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:185` | — | — | 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:188` | — | — | 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:190` | — | — | Functions stored as values served raw, replaceable, slot-tracked.\*\* | +| CS-R56 | live | `docs/rules-mining/core-store.md:194` | — | — | 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:197` | — | — | Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.\*\* | +| CS-R58 | live | `docs/rules-mining/core-store.md:200` | — | — | 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`) diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index c449c426b..441bb080e 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -44,7 +44,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t **Pinned by:** `tests/spec-async-semantics.test.ts` (same pins — behavior coincides in unmerged graphs); `tests/optimistic-store-layer-scope.test.ts` (store corollary: disjoint-key independence, nested rows, same-key entanglement, delete survival, ambient flush-end); `tests/optimistic-lane-transaction-ownership.test.ts` (node corollary: shared-subscriber lane merge with swapped write order, three-action signal hijack); `tests/optimistic-lane-release.test.ts` (body-end corollary, and #3426: the last async reader unmounting releases the frame) **Mechanism (index, 2026-09-14):** `CONFIG_OVERRIDE_SUPERSEDED` set by `supersedeOverride` on a differing, postdating source landing; `supersededRead` serves the arrived value to tracked readers while untracked reads keep `_overrideValue` until commit (#3331); `_overrideTime` / `_overrideStamp` decide "postdates" and provenance (2026-09-10); `_overrideOwner` answers ownership instead of the lane (#2912); the arrival itself is staged in `_pendingValue` and elevated by `commitPendingNode` like any transition write; `endOptimism` (`GlobalQueue._endOptimism`, from `flush` after the heap, before the verdict) supersedes with the node's own truth once `_acted` and no `_actions` remain and nothing authoritative blocks (#3427). -**Statement (current).** (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. **Supersession (re-ruled 2026-09-09, #3331): own-source arrival removes the optimism from the graph immediately; the display keeps it until the transaction commits.** Maintainer: "a new value from the source should remove the optimism immediately.. if it matches then no more work, if it doesn't match then that work gets folded into the parent transition"; "when the optimism drops we might not see it until end of transition because it folds into the parent's transition." This replaces the mechanical sentence above ("elevate to `_value` only at their transition's commit; the elevation is unobservable under the override mask") — that model let the override's own downstream flight serialize ahead of the truth's, doubling the delay the reporter saw. Now: (a) a landing that _equals_ the override confirms silently — nothing re-runs, the lane's in-flight work completes the frame; (b) a landing that _differs_ marks the node superseded: its subscribers recompute from the arrived value on the plain channel (their lane affinity is dropped, so this is held transaction work, not lane work), downstream async restarts from the truth _now_, and the override's own downstream flight is inert when it lands; (c) untracked reads and the applied screen keep the override until the transaction — holding for whatever the corrected derivations observe (A15) — commits and clears the override; (d) `latest` returns the arrived value, `isPending` reads `true` iff the arrival differs (consequence (3) unchanged in statement, now true in mechanism). **(d) before the first commit (2026-09-15, visibility oracle):** the verdict holds for a node that has never committed — its first landing held by a reveal that never landed — because the override is the observable value; A19 exception (1) ("uninitialized is loading, not pending: no observable value exists") does not apply under a displayed override. Pinned: `tests/superseded-before-first-commit.test.ts`. A later landing on the same node that equals the override un-supersedes it (the override is again the graph's value). **Scope (ruled 2026-09-10): "the source" is whatever recomputes the node** — its own async landing, or a synchronous recompute driven by an upstream change (`createOptimistic(() => userCategory())` over an async memo is the common real-world shape): "if the source recomputes it doesn't matter if it is async or not." **Ordering:** a new value from the source is one that _postdates_ the override — a source write and an override in the same batch derive nothing new (the override is written over that batch's truth knowingly and stays the graph's value until the commit reveals it). **Provenance (ruled 2026-09-10):** "a new value from the source" answers the override's _own_ question or a newer one. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override; that answer is a question the user has since changed — it is staged for the commit like any landing (and reveals then iff it is still the truth) but does **not** supersede: no downstream re-derivation, no pending flip on downstream readers. "A slow source shouldn't leak back in like that." Only the override's own action, a later action, or mainline (no action — a fresh question by definition) supersedes. **Body-end corollary (2026-09-14, #3427):** the action bodies were the optimism's justification; once they have all ended, an override still in force is superseded by the truth already at hand — the staged value an equal (A17-silent) landing left, else the committed value — exactly as an arriving differing truth would be, _unless_ the transaction is still waiting on authoritative work: an override node's own source in flight (that answer confirms or supersedes on arrival), or a held flight that does not derive from an override (a plain load the action asked for; a co-written `saving` flag stays through it — the optimistic world is one, A17). Flights that derive from an override are questions about a guess that is about to revert, and nobody reads their answer: the graph re-derives from the truth as the transaction's held work and settles when _that_ lands, so the correction starts at the body's end instead of after the obsolete flight (which used to reveal the obsolete optimistic frame first, then revert and re-ask — a waterfall with a flash). Display is unchanged: the override stays on screen until the commit (c). Optimistic store edits keep the settle-then-revert order (their truth is a base layer under an overlay, with no tracked/displayed split), and companions snap at settlement. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — verdict during the window per A24 (amended 2026-07-13; was "false throughout" under the A20 mask): a held correction that _differs_ from the displayed override reads pending; a matching confirm stays quiet — corrections still _propagate_ internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. **Store corollary (2026-07-17, #2899): the optimistic layer obeys the same per-transaction lifetime.** `createOptimisticStore`'s override layer is one record per store target, but each entry is owned by the transaction that wrote it (`STORE_OPTIMISTIC_OWNERS` stamps, merge chains resolved): a settling action consumes only its own keys, so concurrent actions on disjoint keys revert independently — first-settling no longer wipes the other's live overrides. Same-key writes still entangle through the shared node (one joint settle); ambient (transaction-less) entries clear at plain flush end; a derived store's projection landing still consumes the whole layer (fresh authority supersedes every tentative write). **Node corollary (2026-07-18, #2912): ownership never travels through lanes.** Lanes are scheduling affinity — a shared subscriber (one effect reading keys touched by two actions) merges them correctly for flushing, but the merged root's `_transition` must not answer "which transaction owns this override": that let one action's settle revert another's live override, and same-key follow-up writes entangle with the wrong transaction. Every optimistic write stamps `_overrideOwner` on the node (post-merge, so entangled writers share the joint root; cleared at settle); `resolveTransition` prefers a live owner stamp over the lane, falling back to lane `_transition` for nodes without overrides (async routing) exactly as before. Pinned: `tests/spec-async-semantics.test.ts` ("#3331" describe: own-async, sync-wrapper, same-batch, provenance, simple graph; A18 entangled pin re-expected: the merged correction reveals as one frame, never the committed-behind-the-mask tear); `tests/createOptimistic.test.ts` (CategoryDisplay no-double-flicker pin, unchanged: the older action's answer never moves the graph; "second action while first still in flight" pin, resolver repaired and re-expected to the same rule). +**Statement (current).** (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. **Supersession (re-ruled 2026-09-09, #3331): own-source arrival removes the optimism from the graph immediately; the display keeps it until the transaction commits.** Maintainer: "a new value from the source should remove the optimism immediately.. if it matches then no more work, if it doesn't match then that work gets folded into the parent transition"; "when the optimism drops we might not see it until end of transition because it folds into the parent's transition." This replaces the mechanical sentence above ("elevate to `_value` only at their transition's commit; the elevation is unobservable under the override mask") — that model let the override's own downstream flight serialize ahead of the truth's, doubling the delay the reporter saw. Now: (a) a landing that _equals_ the override confirms silently — nothing re-runs, the lane's in-flight work completes the frame; (b) a landing that _differs_ marks the node superseded: its subscribers recompute from the arrived value on the plain channel (their lane affinity is dropped, so this is held transaction work, not lane work), downstream async restarts from the truth _now_, and the override's own downstream flight is inert when it lands; (c) untracked reads and the applied screen keep the override until the transaction — holding for whatever the corrected derivations observe (A15) — commits and clears the override; (d) `latest` returns the arrived value, `isPending` reads `true` iff the arrival differs (consequence (3) unchanged in statement, now true in mechanism). **(d) before the first commit (2026-09-15, visibility oracle):** the verdict holds for a node that has never committed — its first landing held by a reveal that never landed — because the override is the observable value; A19 exception (1) ("uninitialized is loading, not pending: no observable value exists") does not apply under a displayed override. Pinned: `tests/superseded-before-first-commit.test.ts`. A later landing on the same node that equals the override un-supersedes it (the override is again the graph's value). **Scope (ruled 2026-09-10): "the source" is whatever recomputes the node** — its own async landing, or a synchronous recompute driven by an upstream change (`createOptimistic(() => userCategory())` over an async memo is the common real-world shape): "if the source recomputes it doesn't matter if it is async or not." **Ordering:** a new value from the source is one that _postdates_ the override — a source write and an override in the same batch derive nothing new (the override is written over that batch's truth knowingly and stays the graph's value until the commit reveals it). **Provenance (ruled 2026-09-10):** "a new value from the source" answers the override's _own_ question or a newer one. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override; that answer is a question the user has since changed — it is staged for the commit like any landing (and reveals then iff it is still the truth) but does **not** supersede: no downstream re-derivation, no pending flip on downstream readers. "A slow source shouldn't leak back in like that." Only the override's own action, a later action, or mainline (no action — a fresh question by definition) supersedes. **Body-end corollary (2026-09-14, #3427):** the action bodies were the optimism's justification; once they have all ended, an override still in force is superseded by the truth already at hand — the staged value an equal (A17-silent) landing left, else the committed value — exactly as an arriving differing truth would be, _unless_ the transaction is still waiting on authoritative work: an override node's own source in flight (that answer confirms or supersedes on arrival), or a held flight that does not derive from an override (a plain load the action asked for; a co-written `saving` flag stays through it — the optimistic world is one, A17). Flights that derive from an override are questions about a guess that is about to revert, and nobody reads their answer: the graph re-derives from the truth as the transaction's held work and settles when _that_ lands, so the correction starts at the body's end instead of after the obsolete flight (which used to reveal the obsolete optimistic frame first, then revert and re-ask — a waterfall with a flash). Display is unchanged: the override stays on screen until the commit (c). Optimistic store edits keep the settle-then-revert order (their truth is a base layer under an overlay, with no tracked/displayed split), and companions snap at settlement. **Visibility during the body-end window (2026-09-15, visibility oracle):** identical to a landing supersession — the display and stale readers keep the override, a fresh derivation is held (A29), `latest()` answers the truth and `isPending()` reads true iff it differs — although nothing is staged (the truth at hand is the committed value). The node carries no `_transition` stamp in this window (an override written inside an action never passes the adoption loop that stamps one), so the read path and the verdict resolve the owning transaction through `_overrideOwner` (node corollary, #2912). Pinned: `tests/body-end-supersession-visibility.test.ts`. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — verdict during the window per A24 (amended 2026-07-13; was "false throughout" under the A20 mask): a held correction that _differs_ from the displayed override reads pending; a matching confirm stays quiet — corrections still _propagate_ internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. **Store corollary (2026-07-17, #2899): the optimistic layer obeys the same per-transaction lifetime.** `createOptimisticStore`'s override layer is one record per store target, but each entry is owned by the transaction that wrote it (`STORE_OPTIMISTIC_OWNERS` stamps, merge chains resolved): a settling action consumes only its own keys, so concurrent actions on disjoint keys revert independently — first-settling no longer wipes the other's live overrides. Same-key writes still entangle through the shared node (one joint settle); ambient (transaction-less) entries clear at plain flush end; a derived store's projection landing still consumes the whole layer (fresh authority supersedes every tentative write). **Node corollary (2026-07-18, #2912): ownership never travels through lanes.** Lanes are scheduling affinity — a shared subscriber (one effect reading keys touched by two actions) merges them correctly for flushing, but the merged root's `_transition` must not answer "which transaction owns this override": that let one action's settle revert another's live override, and same-key follow-up writes entangle with the wrong transaction. Every optimistic write stamps `_overrideOwner` on the node (post-merge, so entangled writers share the joint root; cleared at settle); `resolveTransition` prefers a live owner stamp over the lane, falling back to lane `_transition` for nodes without overrides (async routing) exactly as before. Pinned: `tests/spec-async-semantics.test.ts` ("#3331" describe: own-async, sync-wrapper, same-batch, provenance, simple graph; A18 entangled pin re-expected: the merged correction reveals as one frame, never the committed-behind-the-mask tear); `tests/createOptimistic.test.ts` (CategoryDisplay no-double-flicker pin, unchanged: the older action's answer never moves the graph; "second action while first still in flight" pin, resolver repaired and re-expected to the same rule). **History (superseded mechanism, kept verbatim).** The 2026-07-07b mechanical model — replaced by the 2026-09-09 supersession, which the statement now leads with; take the mechanism from the statement, not from here: Mechanically: authoritative values arriving under an active override hold in `_pendingValue` like any other transition write and **elevate to `_value` only at their transition's commit** (`_value` changes at commit points, period); the elevation is unobservable under the override mask (A17); reverting is a pure drop — there is no revert target and reverts commit nothing. This supersedes the earlier "bound to its own async source, not its transition" formulation, which was implemented by escaping the transition commit (revert-target commit at revert) and allowed a mid-flight arrival to reveal before its own transition completed. @@ -68,7 +68,7 @@ Sync derivations of transition-held sources are visible through `latest()`/`isPe **Status:** **ruled, amended in place** 2026-09-13 (#3408) — maintainer ruling, Cluster 4 triage: a value derived from the held world is that transaction's work, whichever path first read it; amended 2026-09-14 (creation-time form — "born held"; the entry is the pass's, never the mainline block's) **Pinned by:** `tests/held-conditional-memo.test.ts` (#3408: a memo whose branch flips mainline and starts reading a held signal reveals with it, not before); `tests/born-held.test.ts` (creation-time form: fresh memo + effect held, fresh direct effect shows committed, unrelated write after the mount stays mainline, untracked read throws until commit); `tests/visibility-oracle.test.ts` (published column, held and superseded states) -**Mechanism (index, 2026-09-14):** `enterStagedRead` on each of `read()`'s value selections that return `_pendingValue` (both fast paths and the slow path) → `globalQueue.initTransition(el._transition)`; no-op for the ambient batch (`_transition` null), the active transaction, and a probe read (`pendingCheckActive`). Creation-time form: outside a flush `enterStagedRead` records the transaction (`stagedEntry`) instead of entering; `recompute` stages the pass's node into it (`_transition` stamped, pushed to its `_pendingNodes`, `STATUS_UNINITIALIZED` kept), adds an effect to its `_gatedSubs` and skips the synchronous first run (`effect()`); `commitPendingNode` initializes it; `read()` holds readers of a node with a staged value and no committed one. Verdict pulls (`GlobalQueue._verdictPull`) and optimistic-posture nodes keep the entering path. +**Mechanism (index, 2026-09-14):** `enterStagedRead` on each of `read()`'s value selections that return `_pendingValue` (both fast paths and the slow path) → `globalQueue.initTransition(el._transition)`; no-op for the ambient batch (`_transition` null), the active transaction, and a probe read (`pendingCheckActive`). Creation-time form: outside a flush `enterStagedRead` records the transaction (`stagedEntry`) instead of entering; `recompute` stages the pass's node into it (`_transition` stamped, pushed to its `_pendingNodes`, `STATUS_UNINITIALIZED` kept), adds an effect to its `_gatedSubs` and skips the synchronous first run (`effect()`); `commitPendingNode` initializes it; `read()` holds readers of a node with a staged value and no committed one. Optimistic-posture nodes keep the entering path; verdict pulls (`GlobalQueue._verdictPull` — `latest()`/`isPending()` companions and their pulls) are observations and never enter from mainline (a `latest()` call that entered captured the caller's block, 2026-09-15). A tracked computation served a node's staged `_pendingValue` — a value a live transaction holds — derives from that transaction's world, so its pass enters the transaction and its result is held with it. This is the read twin of the two entries that already existed, `setSignal` on a stamped node and `recompute` of a stamped node, and closes the gap between them: a conditional memo whose branch flipped mainline (`panel = show() ? count() : "hidden"`) started reading a held `count`, was served the staged value (non-stale readers keep speculation), and published a value derived from the held world into the mainline frame — `Panel: 1` beside `Count: 0`. A stale (render) reader is unchanged: the reveal carve-out (A15) serves it the committed value with no entanglement, which is why the same shape written as a plain JSX expression already showed a coherent frame. A probe (`isPending(() => x())`) observes, it does not derive, so it enters nothing (A23). **Creation-time form (amended 2026-09-14, "born held"):** the same rule for a memo or effect CREATED from mainline code while the hold is live — a component mounting on a click while an action is in flight. Its creation pass is served the staged value and derives from the transaction's world, so its result is the transaction's: staged into it, committed with it, and — for an effect — first run by its commit; it has no committed value until then (an untracked read of it throws `NotReadyError`, A19 exception 1; a stale reader of it cannot fall back to the committed frame and enters instead). A fresh reader that reads the held node DIRECTLY is a stale reader of a parallel transaction and shows the committed value as before (A15) — so a mainline mount shows the committed frame for direct bindings and holds derived ones until the commit reveals both. The entry is the pass's alone: `activeTransition` and the ambient batch are never touched from creation code, so a write made after the mount is a mainline write. (Maintainer, on the earlier direct-commit: "new creation wouldn't be on screen anyway… we could just be preemptive" — true for nodes the transaction itself creates, which stay inside it; a mainline mount IS on screen.) Before this the creation pass direct-committed the staged value into the mainline frame, and the ambient entry swallowed the handler's later writes into the action. @@ -112,7 +112,7 @@ A memo's value is one shared slot every reader sees, so its pass runs under the **Pinned by:** `tests/latest-async.test.ts`, `tests/createMemo.test.ts` **Mechanism (index, 2026-09-14):** `latest()` shadow (`_latestValueComputed`) is backfilled from `_pendingValue` when created after the write (verdict.ts, #3041). -After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amended 2026-09-14:** before the first landing there is no visible value, and `latest()` never fabricates one — it throws `NotReadyError` in every scope, owned or unowned, exactly like the plain read. (Maintainer: returning `undefined` "would mess with types" — `latest` returns `T`.) The unowned scope used to return `undefined` by sharing the pending-shadow fallback's condition; the `isPending` twin stays `false` there because `false` inhabits `boolean` (A16). +After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amended 2026-09-14:** before the first landing there is no visible value, and `latest()` never fabricates one — it throws `NotReadyError` in every scope, owned or unowned, exactly like the plain read. (Maintainer: returning `undefined` "would mess with types" — `latest` returns `T`.) The unowned scope used to return `undefined` by sharing the pending-shadow fallback's condition; the `isPending` twin stays `false` there because `false` inhabits `boolean` (A16). **Judged on the owner (2026-09-15, store oracle):** a store leaf behind a projection's firewall is a plain signal whose `_value` is the seed; `latest(() => store.key)` on an uninitialized projection returned the seed — A25's draft made visible through the one read path that bypasses the firewall gate. `latest()` now asks the leaf's owner (the firewall) whether anything has landed, as `read()` does. Pinned: `tests/visibility-oracle-store.test.ts` (projection uninitialized × latest). ### A8. `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel @@ -214,7 +214,7 @@ An effect's frame is the run its value is applied by, not its value slot (#3438) **Pinned by:** `tests/loading-value.test.ts` **Mechanism (index, 2026-09-14):** `_loading`; `handleAsync` serves `_value` instead of `NotReadyError` while set; `parkLoadingWindow`; window closes on the first OBSERVABLE landing (#2990). -(**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed via `loadingValue` (memos: `createMemo` / `createSignal(fn)` / `createOptimistic(fn)`) or `seedLoadingValue` (projections: `createProjection` / `createStore(fn)` / `createOptimisticStore(fn)`) starts with the loading value as commit #0 of its lineage instead of `STATUS_UNINITIALIZED`. While its first real answer is in flight, the window is loading-class on every axis: (1) **reads** — every consumer path serves commit #0 (no `NotReadyError`, no Loading-boundary suspension; `latest()` and `resolve()` return it; it is the compute's first `prev`); (2) **transitions** — the window never initiates or extends one (matching boundary-fallback semantics): ambient writes concurrent with the window commit immediately rather than being held, and a loading node mounted inside a live transition does not add to what that transition waits for; (3) **verdict** — `isPending` reads false at the source, upstream, and downstream, in both forms. The quiet ruling is not an exception to A19 but its question scoping applied: commit #0 answers the question **by declaration**, so the first flight is re-ask-shaped — the shown answer still answers the question (A24 family). The alternative was rejected as structurally unavailable: genuine pending is chain-shaped (the shadow of a held commit — held write upstream, in-flight async at the node, propagated non-finality downstream), and the window has no held commit to shadow, so a true verdict could only exist as a point anomaly at the probed node while upstream and downstream read false — and making it propagate would reintroduce exactly the status machinery the window exists to silence (and re-open the server/client split: `isPending` is always false on the server). First-load affordances are therefore the **value channel**'s job — the author encodes provenance (`null`, a `skeleton` flag) into the loading value itself — and `data.skeleton || isPending(data)` covers the two disjoint states. The window closes at the first real landing on any path (sync return, sync-resolved thenable, first iterator yield, async settle); a real error answers reads but does not close it — a retry serves commit #0 again. After close, A19 applies unchanged: refetches are pending-class forever. A25 is unchanged for plain seeds: without `seedLoadingValue` a derived store's seed remains an unobservable draft; `seedLoadingValue` is precisely the author promoting the seed to commit #0 — observable by declaration. +(**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed via `loadingValue` (memos: `createMemo` / `createSignal(fn)` / `createOptimistic(fn)`) or `seedLoadingValue` (projections: `createProjection` / `createStore(fn)` / `createOptimisticStore(fn)`) starts with the loading value as commit #0 of its lineage instead of `STATUS_UNINITIALIZED`. While its first real answer is in flight, the window is loading-class on every axis: (1) **reads** — every consumer path serves commit #0 (no `NotReadyError`, no Loading-boundary suspension; `latest()` and `resolve()` return it; it is the compute's first `prev`); (2) **transitions** — the window never initiates or extends one (matching boundary-fallback semantics): ambient writes concurrent with the window commit immediately rather than being held, and a loading node mounted inside a live transition does not add to what that transition waits for; (3) **verdict** — `isPending` reads false at the source, upstream, and downstream, in both forms. The quiet ruling is not an exception to A19 but its question scoping applied: commit #0 answers the question **by declaration**, so the first flight is re-ask-shaped — the shown answer still answers the question (A24 family). The alternative was rejected as structurally unavailable: genuine pending is chain-shaped (the shadow of a held commit — held write upstream, in-flight async at the node, propagated non-finality downstream), and the window has no held commit to shadow, so a true verdict could only exist as a point anomaly at the probed node while upstream and downstream read false — and making it propagate would reintroduce exactly the status machinery the window exists to silence (and re-open the server/client split: `isPending` is always false on the server). First-load affordances are therefore the **value channel**'s job — the author encodes provenance (`null`, a `skeleton` flag) into the loading value itself — and `data.skeleton || isPending(data)` covers the two disjoint states. The window closes at the first real landing on any path (sync return, sync-resolved thenable, first iterator yield, async settle); a real error answers reads but does not close it — a retry serves commit #0 again. After close, A19 applies unchanged: refetches are pending-class forever. A25 is unchanged for plain seeds: without `seedLoadingValue` a derived store's seed remains an unobservable draft; `seedLoadingValue` is precisely the author promoting the seed to commit #0 — observable by declaration. **Amended 2026-09-15 (visibility oracle):** the window is a loading boundary's twin, and two things follow. (1) Verdict-quiet is a _hydration invariant_, not only ergonomics: the server always answers `isPending` false, so the client cannot answer anything else on creation without a mismatch — through the window the verdict is false whatever re-asked the node, including an action's held write. Maintainer: "loadingValue has the same SSR hydration concerns… isPending can't be true on creation or we might get a hydration mismatch." (2) The window's first real landing is _initial-load class_, like a boundary's first content reveal (A19 exception 1): it commits on arrival even when the input that re-asked it is still held by an action — the derived value is on screen before its input, the same initial-load exception boundaries have. From that landing on the node is an ordinary memo: new questions pend (A24), held landings pend (A19 iii) and are held. Pinned: `tests/visibility-oracle.test.ts` (loading window over a held input). ### A25. A derived store's seed is a draft, never an observable value diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 12e06e677..c805a95f4 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1506,9 +1506,11 @@ function heldFromStale(el: Signal | Computed, c: Computed): boole * transaction (born held) and mainline is never touched. */ let stagedEntry: Transition | null = null; -export function enterStagedRead(el: Signal | Computed): void { - const t = el._transition; - if (t === null || t === activeTransition || pendingCheckActive) return; +export function enterStagedRead( + el: Signal | Computed, + t: Transition | null | undefined = el._transition +): void { + if (!t || t === activeTransition || pendingCheckActive) 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 @@ -1516,16 +1518,21 @@ export function enterStagedRead(el: Signal | Computed): void { // entering path. // (`context` is non-null here: every caller selected a value for a reader.) const ctx = context as Computed; - if ( - activeTransition === null && - !globalQueue._running && - !GlobalQueue._verdictPull && - ctx._flags & REACTIVE_RECOMPUTING_DEPS && - !(ctx._config & CONFIG_OPTIMISTIC) && - (stagedEntry === null || stagedEntry === t) - ) - stagedEntry = t; - else globalQueue.initTransition(t); + if (activeTransition === null && !globalQueue._running) { + // Verdict pulls are observations, not derivations: a latest() / + // isPending() call from mainline must never enter a transaction (it + // would capture the rest of the caller's synchronous block). + if (GlobalQueue._verdictPull) return; + if ( + ctx._flags & REACTIVE_RECOMPUTING_DEPS && + !(ctx._config & CONFIG_OPTIMISTIC) && + (stagedEntry === null || stagedEntry === t) + ) { + stagedEntry = t; + return; + } + } + globalQueue.initTransition(t); } export function readNodeFast(el: Signal): T | typeof READ_SLOW { diff --git a/packages/signals/src/core/optimistic.ts b/packages/signals/src/core/optimistic.ts index f10e8af94..07e147582 100644 --- a/packages/signals/src/core/optimistic.ts +++ b/packages/signals/src/core/optimistic.ts @@ -338,13 +338,19 @@ function endOptimism(transition: Transition): boolean { * OTHER transaction, the same visibility a foreign transaction's staged * write has. */ function supersededRead(el: OptimisticNode): unknown { - if (stale && el._transition && activeTransition !== el._transition) - return unwrapOverride(el._x?._overrideValue); - if (el._pendingValue === NOT_PENDING) return el._value; - // The staged truth is a staged read like any other (A29): the pass that - // derives from it is the transaction's. - enterStagedRead(el); - return el._pendingValue; + // The owning transaction: `_overrideOwner` (#2912), not the stamp — an + // override written directly inside an action never passes the adoption + // loop that stamps `_transition`, and a body-end supersession (#3427) + // stages nothing that would queue it. Without the owner a stale reader of + // a body-ended node read the committed truth beside a display still + // showing the override. + const owner = resolveTransition(el); + if (stale && owner && activeTransition !== owner) return unwrapOverride(el._x?._overrideValue); + // A superseded read is a staged read (A29) whether the truth is staged or + // already committed: the pass that derives from it derives from the + // owning transaction's world (the override is still displayed by it). + enterStagedRead(el, owner); + return el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; } /** diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 54899ce17..dfa45170f 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -17,7 +17,8 @@ import { STATUS_ERROR, STATUS_PENDING, STATUS_UNINITIALIZED, - CONFIG_HAS_COMPANIONS + CONFIG_HAS_COMPANIONS, + CONFIG_OVERRIDE_SUPERSEDED } from "./constants.js"; import { context, @@ -219,6 +220,15 @@ function computePendingState(el: Signal | Computed): boolean { // the window's own landing in flight to its commit — verdict-quiet like the // rest of the window (the UNINITIALIZED check suppresses exactly this frame // for windowless first loads; born-committed nodes need their own gate, #2990). + // A18 (d) for a body-end supersession (#3427): the truth at hand is the + // COMMITTED value — nothing staged — yet the display still shows the + // override; pending iff they differ, as for a staged arrival below. + if ( + el._config & CONFIG_OVERRIDE_SUPERSEDED && + el._pendingValue === NOT_PENDING && + hasActiveOverride(el) + ) + return !el._equals || !el._equals(el._value as any, unwrapOverride(el._x?._overrideValue)); if (el._pendingValue !== NOT_PENDING && !comp._loading) { // A18 (d): under a displayed override the observable value is the // override, so the verdict is "the arrived truth differs from it" — @@ -392,6 +402,16 @@ function getLatestValueComputed(el: Signal | Computed): Computed { } /** The latest()-mode read path, installed as GlobalQueue._latestRead. */ +/** A7: the source has no visible value yet — judged on the OWNER, as read() + * does: a store leaf behind a projection's firewall is a plain signal whose + * `_value` is the seed (A25: a draft, never a value), and read() routes a + * latest() read here before its own firewall/status logic. An override + * displays a value even before the first commit (A17). */ +function uninitializedSource(el: Signal | Computed): boolean { + const owner = ((el as FirewallSignal)._firewall || el) as Computed; + return !!(owner._statusFlags & STATUS_UNINITIALIZED) && !hasActiveOverride(el); +} + function latestRead(el: Signal | Computed): T { const pendingComputed = getLatestValueComputed(el); const prevPending = latestReadActive; @@ -440,13 +460,15 @@ function latestRead(el: Signal | Computed): T { // uninitialized source has no visible value — latest() throws in every // scope rather than fabricate `undefined` for a `T` that excludes it // (A7; the unowned scope used to return undefined here). - if (e instanceof NotReadyError && !((el as Computed)._statusFlags & STATUS_UNINITIALIZED)) - return visibleValue; + if (e instanceof NotReadyError && !uninitializedSource(el)) return visibleValue; throw e; } finally { setLatestReadActive(prevPending); } - if (pendingComputed._statusFlags & STATUS_PENDING) return visibleValue; + if (pendingComputed._statusFlags & STATUS_PENDING) { + if (uninitializedSource(el)) throw new NotReadyError(el); + return visibleValue; + } if (stale && currentOptimisticLane && pendingComputed._x?._optimisticLane) { const pcLane = findLane(pendingComputed._x?._optimisticLane); const curLane = findLane(currentOptimisticLane); diff --git a/packages/signals/tests/body-end-supersession-visibility.test.ts b/packages/signals/tests/body-end-supersession-visibility.test.ts new file mode 100644 index 000000000..cf72b36d7 --- /dev/null +++ b/packages/signals/tests/body-end-supersession-visibility.test.ts @@ -0,0 +1,113 @@ +/** + * A18 body-end corollary (#3427) — visibility during the correction window. + * + * When the action bodies have ended and nothing authoritative is in flight, + * each override is superseded by the truth at hand — here the COMMITTED value, + * since no landing staged anything. The window that follows must look like a + * landing supersession to every reader: the display keeps the override until + * the commit (A18 c), a fresh derivation is held (A29), and the verdict says + * the truth differs (A18 d). Found by the visibility oracle: with nothing + * staged the node carried no `_transition` stamp (an override written inside + * an action never passes the adoption loop), so `supersededRead` served the + * committed truth to a stale reader beside a display still showing the + * override, a fresh memo published it, and `isPending` read false while + * `latest` read the truth. Ownership for an override node is `_overrideOwner` + * (#2912); the read path and the verdict now resolve it. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest +} from "../src/index.js"; + +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; + +describe("A18 body-end supersession: the correction window", () => { + it("display keeps the override, a stale re-run keeps it, a fresh derivation is held, the verdict says it differs", async () => { + const flights: Array<() => void> = []; + const [u, setU] = createSignal(0); + const staleLog: number[] = []; + let x!: () => number; + let setX!: (v: number) => void; + createRoot(() => { + [x, setX] = createOptimistic(0); + const downstream = createMemo(() => { + const n = x(); + return new Promise(r => flights.push(() => r(`${n}!`))); + }); + createRenderEffect(downstream, () => {}); + }); + flush(); + flights.shift()!(); // prime downstream(0) + await settle(); + // A pre-existing reader in another root (a sibling component), also + // tracking an unrelated signal so it can be re-run mainline. + createRoot(() => { + createRenderEffect( + () => { + u(); + return x(); + }, + v => { + staleLog.push(v); + } + ); + }); + flush(); + staleLog.length = 0; + + action(function* () { + setX(1); + yield Promise.resolve(); // the body ends; downstream(1) is still up + })(); + flush(); + await settle(); + await settle(); + // Body-end: the override (1) is superseded by the committed truth (0); the + // graph re-derives (a downstream flight for 0 starts) and the transaction + // waits for it. + expect(flights.length).toBe(2); + expect(x()).toBe(1); // display + expect(latest(x)).toBe(0); // truth + expect(isPending(x)).toBe(true); // differs + // (Reading the verdicts here is deliberate: a latest() pull once entered + // the owning transaction ambiently and the two checks below depended on + // whether latest() had been called first.) + + // A stale reader re-run by an unrelated write keeps displaying the override. + setU(1); + flush(); + expect(staleLog).toEqual([1]); + + // A fresh mainline derivation derives from the truth and is held. + const fresh: number[] = []; + createRoot(() => { + const m = createMemo(() => x()); + createRenderEffect(m, v => { + fresh.push(v); + }); + }); + flush(); + expect(fresh).toEqual([]); + + // The 0-flight lands: the transaction commits, everything reveals at once. + for (const f of flights.splice(0)) f(); + await settle(); + await settle(); + expect(x()).toBe(0); + expect(isPending(x)).toBe(false); + expect(fresh).toEqual([0]); + expect(staleLog.at(-1)).toBe(0); + }); +}); diff --git a/packages/signals/tests/visibility-oracle-store.test.ts b/packages/signals/tests/visibility-oracle-store.test.ts new file mode 100644 index 000000000..cc3aa12af --- /dev/null +++ b/packages/signals/tests/visibility-oracle-store.test.ts @@ -0,0 +1,323 @@ +/** + * Visibility oracle — STORE side. The same node states as + * visibility-oracle.test.ts, entered through store leaves (plain store, + * optimistic store, derived store / projection), read by the same nine + * reader kinds through the store's own read sites (`serveDataKey` for + * tracked reads, `nodeValue` for untracked ones, `optimisticView` for + * composed views). Store rules that differ from the signal side by ruling: + * A25 (a derived store's seed is a draft), A9/A22 (the firewall's verdict), + * A18's store corollary (#2899) and #3434's store carve-out (optimistic store + * edits keep settle-then-revert; no tracked/displayed split). + */ +import { + action, + createMemo, + createOptimisticStore, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush +} from "../src/index.js"; +import { + HELD, + NOT_READY, + holds, + never, + observed, + rule, + runOracle, + settle, + type State +} from "./visibility-oracle.harness.js"; + +type N = { n: number }; + +const STATES: State[] = [ + { + name: "plain store: committed", + build(installStale) { + const [s] = createStore({ n: 0 }); + const x = () => s.n; + installStale(x); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(0, "baseline"), + derivesFrom: rule(0, "baseline"), + published: rule(0, "baseline"), + preexisting: rule(HELD, "baseline"), + staleForeign: rule(0, "baseline"), + childrenForbidden: rule(0, "baseline"), + latest: rule(0, "baseline"), + isPending: rule(false, "baseline"), + authoritative: rule(0, "baseline") + } + }, + { + name: "plain store: staged, ambient (setStore before the flush)", + build(installStale) { + const [s, set] = createStore({ n: 0 }); + const x = () => s.n; + installStale(x); + set(d => { + d.n = 1; + }); + return { x, dispose() {} }; + }, + expect: { + untracked: observed( + 0, + "pre-flush untracked store read serves the committed backing — same as the signal side; A28 (#3337) territory" + ), + derivesFrom: rule(1, "the flush carries the write"), + published: rule(1, "the flush carries the write"), + preexisting: observed(HELD, "pre-flush: nothing has run yet"), + staleForeign: rule(1, "the flush carries the write"), + childrenForbidden: rule(1, "the flush carries the write"), + latest: observed(1, "pre-flush latest() serves the unflushed write; A28 territory"), + isPending: observed( + true, + "pre-flush verdict flips on the unflushed write — same as the signal side; A28 territory" + ), + authoritative: observed(1, "pre-flush; A28 territory") + } + }, + { + name: "plain store: held by a live action (setStore inside action, yield forever)", + build(installStale) { + const [s, set] = createStore({ n: 0 }); + const x = () => s.n; + installStale(x); + action(function* () { + set(d => { + d.n = 1; + }); + yield never(); + })(); + flush(); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(0, "A19 (i) / CS-R33: the committed backing while the write is held"), + derivesFrom: rule( + 1, + "A29: a tracked pass served the staged leaf derives from the transaction's world" + ), + published: rule(HELD, "A29 (born held)"), + preexisting: rule(HELD, "A19 (i)"), + staleForeign: rule(0, "A15 / A26: a stale reader of a parallel transaction shows committed"), + childrenForbidden: rule(0, "A32"), + latest: rule(1, "A11 / nodeValue: latest() sees the in-flight parked value (#3075)"), + isPending: rule(true, "A19 (i) / CS-R33: a write held by an action pends that property"), + authoritative: rule(1, "A17 carve-out: staged values are authoritative") + } + }, + { + name: "optimistic store: override active (edit inside a live action)", + build(installStale) { + const [s, set] = createOptimisticStore({ n: 0 }); + const x = () => s.n; + installStale(x); + action(function* () { + set(d => { + d.n = 5; + }); + yield never(); + })(); + flush(); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(5, "A17 / OS: the override is the displayed value"), + derivesFrom: rule(5, "A17"), + published: observed( + 5, + "as on the signal side: whether a fresh mainline memo publishes or holds is not stated" + ), + preexisting: rule(5, "A17: no downstream async, nothing to wait for"), + staleForeign: rule(5, "A17"), + childrenForbidden: rule(5, "A32: the override is the frame"), + latest: rule(5, "A17 / OL-R11"), + isPending: rule(false, "A24 (3) / OS-R37: optimistic writes are verdict-inert"), + authoritative: rule(0, "A17 carve-out / authoritativeServe(): never the caller's optimism") + } + }, + { + name: "optimistic store: override, ambient (edit outside any action, before the flush)", + build(installStale) { + const [s, set] = createOptimisticStore({ n: 0 }); + const x = () => s.n; + installStale(x); + set(d => { + d.n = 5; + }); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(5, "OL-R2: synchronously visible before any flush"), + derivesFrom: rule(0, "OL-R5: an ambient optimistic write reverts at the next flush"), + published: rule(0, "OL-R5"), + preexisting: observed(HELD, "pre-flush: nothing has run yet"), + staleForeign: rule(0, "OL-R5"), + childrenForbidden: rule(0, "OL-R5"), + latest: rule(5, "OL-R11 pre-flush"), + isPending: rule(false, "A24 (3)"), + authoritative: rule(0, "A17 carve-out") + } + }, + { + name: "derived optimistic store: own truth landed 2 ≠ override 3, action live (A18 through store nodes)", + async build(installStale) { + const [value, setValue] = createSignal(0); + const fetches: Array<() => void> = []; + const flights: Array<() => void> = []; + let s!: N; + let set!: (fn: (d: N) => void) => void; + const dispose = createRoot(d => { + [s, set] = createOptimisticStore( + () => { + const v = value(); + return new Promise(r => fetches.push(() => r({ n: v * 2 }))); + }, + { n: -1 } + ); + const downstream = createMemo(() => { + const n = s.n; + return new Promise(r => flights.push(() => r(`${n}!`))); + }); + createRenderEffect(downstream, () => {}); + return d; + }); + holds.push(() => flights.splice(0).forEach(f => f())); + flush(); + fetches.shift()!(); // initial truth {n: 0} + await settle(); + flights.shift()!(); + await settle(); + const x = () => s.n; + installStale(x); + action(function* () { + setValue(1); + set(d => { + d.n = 3; + }); + yield never(); + })(); + flush(); + fetches.shift()!(); // own truth lands {n: 2} ≠ 3 + await settle(); + return { x, dispose }; + }, + expect: { + untracked: rule(3, "A18 (c): the overlay stays displayed until settle"), + derivesFrom: rule( + 2, + "A18 (b) through a store node: the tracked pass derives from the landed truth. (#3434's store carve-out is about the body-end SETTLE order, not landing supersession.)" + ), + published: rule( + HELD, + "A18 (c) / A29 (born held): the fresh derivation is held with the transaction; the frame keeps the overlay" + ), + preexisting: rule(HELD, "A18 (c): the frame keeps the overlay until settle"), + staleForeign: rule(3, "A18 (c): a stale reader displays the overlay"), + childrenForbidden: rule(3, "A32"), + latest: rule(2, "A18 (d) / #3075: latest() sees the landed truth beneath the overlay"), + isPending: rule(true, "A18 (d) / OS-R39: the landing differs from the override"), + authoritative: rule(2, "A17 carve-out / authoritativeServe(): the base layer") + } + }, + { + name: "derived store (projection): pending refetch on a new question, flight up", + async build(installStale) { + const [q, setQ] = createSignal(0); + const fetches: Array<() => void> = []; + let s!: N; + const dispose = createRoot(d => { + [s] = createStore( + () => { + const v = q(); + return new Promise(r => fetches.push(() => r({ n: v * 10 }))); + }, + { n: -1 } + ); + createRenderEffect( + () => s.n, + () => {} + ); + return d; + }); + flush(); + fetches.shift()!(); // {n: 0} + await settle(); + const x = () => s.n; + installStale(x); + setQ(1); // new question; the flight never lands + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule( + 0, + "A19 (ii) / OS-R32: untracked reads flow the committed value during a refetch window" + ), + derivesFrom: rule( + NOT_READY, + "A15 / A9: a tracked reader of a firewall-backed leaf whose firewall is refetching suspends" + ), + published: rule(HELD, "A15"), + preexisting: rule(HELD, "A15: the reader observing the flight holds"), + staleForeign: observed( + 0, + "as on the signal side: the stale reader re-run by an unrelated write shows the pre-flight committed value — same open question (INPUTS_PUBLISHED for a flight opened by the same batch)" + ), + childrenForbidden: rule(0, "A32"), + latest: rule( + 0, + "A8 / A9: latest shows the stale value while the firewall's new-question refetch is in flight" + ), + isPending: rule( + true, + "A9: a store leaf behind a firewall reports the firewall's new-question refetch" + ), + authoritative: observed(NOT_READY, "A17 carve-out: nothing landed for the new question") + } + }, + { + name: "derived store (projection): uninitialized — the seed is a draft, never a value (A25)", + build(installStale) { + let s!: N; + const dispose = createRoot(d => { + [s] = createStore(() => never() as unknown as Promise, { n: -1 }); + createRenderEffect( + () => s.n, + () => {} + ); + return d; + }); + const x = () => s.n; + installStale(x); + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule( + NOT_READY, + "A25: to every outside consumer the store is uninitialized until the first resolution lands — the seed is never visible" + ), + derivesFrom: rule(NOT_READY, "A25 / A16 carve-out"), + published: rule(HELD, "A25"), + preexisting: rule(HELD, "A25"), + staleForeign: rule(HELD, "A25 / A15: nothing committed to show"), + childrenForbidden: rule(NOT_READY, "A32: no frame yet"), + latest: rule( + NOT_READY, + "A7 (amended): latest() of an uninitialized source throws in every scope" + ), + isPending: rule(false, "A16 / A19 exception (1): loading, not pending"), + authoritative: rule(NOT_READY, "A17 carve-out: nothing landed") + } + } +]; + +runOracle("visibility oracle — stores (A9, A17, A18, A19, A25, A29, A32, CS/OS rules)", STATES); diff --git a/packages/signals/tests/visibility-oracle.harness.ts b/packages/signals/tests/visibility-oracle.harness.ts new file mode 100644 index 000000000..e6b69175d --- /dev/null +++ b/packages/signals/tests/visibility-oracle.harness.ts @@ -0,0 +1,256 @@ +/** + * Visibility-oracle harness — shared by the signal/memo oracle + * (visibility-oracle.test.ts) and the store oracle + * (visibility-oracle-store.test.ts). Model: node states × reader kinds → the + * value each reader is served; see visibility-oracle.test.ts for the reader + * definitions and the cell vocabulary (rule / observed / violation). + */ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + NotReadyError, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createTrackedEffect, + flush, + isPending, + latest, + until +} from "../src/index.js"; + +// ── cell values ───────────────────────────────────────────────────────────── +export const HELD = "HELD" as const; // nothing published / the reader is holding +export const NOT_READY = "throws:NotReady" as const; +export type Cell = unknown | typeof HELD | typeof NOT_READY | `throws:${string}`; + +export type Expect = + | { value: Cell; rule: string } + | { observed: Cell; note: string } + | { violation: { rule: Cell; current: Cell }; note: string } + | { na: string }; +export const rule = (value: Cell, rule: string): Expect => ({ value, rule }); +export const observed = (observed: Cell, note: string): Expect => ({ observed, note }); +/** The spec fixes this cell to `rule`; the runtime currently serves `current`. + * Pinned at `current` so the suite is green; listed red by the report. When + * the runtime is fixed this cell fails — flip it to `rule(...)`. */ +export const violation = (ruleValue: Cell, current: Cell, note: string): Expect => ({ + violation: { rule: ruleValue, current }, + note +}); +export const na = (why: string): Expect => ({ na: why }); + +export const READERS = [ + "untracked", + "derivesFrom", + "published", + "preexisting", + "staleForeign", + "childrenForbidden", + "latest", + "isPending", + "authoritative" +] as const; +export type Reader = (typeof READERS)[number]; + +export type State = { + name: string; + /** Build the state. Returns the node accessor and a disposer. The stale + * foreign reader must be created BEFORE the state is entered, so builders + * receive a hook to install it. */ + build: ( + installStale: (x: () => unknown) => void + ) => + | { x: () => unknown; dispose: () => void } + | Promise<{ x: () => unknown; dispose: () => void }>; + expect: Record; +}; + +// ── helpers ───────────────────────────────────────────────────────────────── +export const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; +// Every open-ended promise a state creates is registered here and released +// between cells: a never-settling action would otherwise leave its transaction +// live across the next cell (observed: an unrelated async memo's NotReady then +// escaped flush()). +export const holds: Array<() => void> = []; +export const never = () => new Promise(r => holds.push(r as () => void)); +export const releaseAll = async () => { + for (const r of holds.splice(0)) r(); + await settle(); + await settle(); +}; +const classify = (fn: () => unknown): Cell => { + try { + return fn(); + } catch (e) { + return e instanceof NotReadyError + ? NOT_READY + : `throws:${(e as Error)?.constructor?.name ?? String(e)}`; + } +}; + +function readCell( + reader: Reader, + x: () => unknown, + stale: { log: Cell[]; bump: () => void } | null +): Cell { + switch (reader) { + case "untracked": + return classify(x); + case "latest": + return classify(() => latest(() => x())); + case "isPending": + return classify(() => isPending(() => x())); + case "derivesFrom": { + const log: Cell[] = []; + const dispose = createRoot(d => { + const m = createMemo(() => { + let v: Cell; + try { + v = x(); + } catch (e) { + log.push( + e instanceof NotReadyError ? NOT_READY : `throws:${(e as Error)?.constructor?.name}` + ); + throw e; // suspend like a real derivation + } + log.push(v); + return v; + }); + createRenderEffect(m, () => {}); + return d; + }); + flush(); + dispose(); + return log.length ? log[log.length - 1] : HELD; + } + case "published": { + const log: Cell[] = []; + const dispose = createRoot(d => { + const m = createMemo(() => x()); + createRenderEffect(m, v => { + log.push(v); + }); + return d; + }); + flush(); + dispose(); + return log.length ? log[log.length - 1] : HELD; + } + case "preexisting": { + if (!stale) return na("no pre-existing reader") as any; + return stale.log.length ? stale.log[stale.log.length - 1] : HELD; + } + case "staleForeign": { + if (!stale) return na("no stale reader") as any; + stale.log.length = 0; + stale.bump(); + flush(); + return stale.log.length ? stale.log[stale.log.length - 1] : HELD; + } + case "childrenForbidden": { + const log: Cell[] = []; + const dispose = createRoot(d => { + createTrackedEffect(() => { + log.push(classify(x)); + }); + return d; + }); + flush(); + dispose(); + return log.length ? log[log.length - 1] : HELD; + } + case "authoritative": { + let seen: Cell = HELD; + // until()'s predicate is the authoritative reader; it runs synchronously + // on the first evaluation. Resolve immediately so nothing is left open. + until(() => { + seen = classify(x); + return true; + }).catch(() => {}); + flush(); + return seen; + } + } +} + +// ── run ───────────────────────────────────────────────────────────────────── +export function runOracle(title: string, STATES: State[]): void { + const unspecified: string[] = []; + const violations: string[] = []; + afterEach(async () => { + await releaseAll(); + flush(); + }); + afterAll(() => { + if (process.env.VISIBILITY_ORACLE_REPORT) + require("node:fs").appendFileSync( + process.env.VISIBILITY_ORACLE_REPORT, + [ + `# ${title}`, + "## violations", + ...violations, + "", + "## unspecified", + ...unspecified, + "" + ].join("\n") + "\n" + ); + }); + describe(title, () => { + for (const state of STATES) { + describe(state.name, () => { + for (const reader of READERS) { + const exp = state.expect[reader]; + if ("na" in exp) continue; + const title = + "value" in exp + ? `${String(exp.value)} [${exp.rule}]` + : "violation" in exp + ? `VIOLATION: rule says ${String(exp.violation.rule)}, runtime serves ${String(exp.violation.current)} (${exp.note})` + : `${String(exp.observed)} (observed; ${exp.note})`; + it(`${reader} → ${title}`, async () => { + let stale: { log: Cell[]; bump: () => void } | null = null; + const [u, setU] = createSignal(0); + const installStale = (x: () => unknown) => { + const log: Cell[] = []; + createRoot(() => { + createRenderEffect( + () => { + u(); + return x(); // a throw suspends the pass → HELD + }, + v => { + log.push(v); + } + ); + }); + flush(); + log.length = 0; + stale = { log, bump: () => setU(n => n + 1) }; + }; + const built = state.build(installStale); + const { x, dispose } = built instanceof Promise ? await built : built; + const got = readCell(reader, x, stale); + dispose(); + await releaseAll(); + if ("value" in exp) expect(got).toEqual(exp.value); + else if ("violation" in exp) { + violations.push( + `${state.name} × ${reader}: rule ${String(exp.violation.rule)}, runtime ${String(got)} — ${exp.note}` + ); + expect(got).toEqual(exp.violation.current); + } else { + unspecified.push(`${state.name} × ${reader} = ${String(got)} — ${exp.note}`); + expect(got).toEqual(exp.observed); + } + }); + } + }); + } + }); +} diff --git a/packages/signals/tests/visibility-oracle.test.ts b/packages/signals/tests/visibility-oracle.test.ts index e217583fb..d8a06bd22 100644 --- a/packages/signals/tests/visibility-oracle.test.ts +++ b/packages/signals/tests/visibility-oracle.test.ts @@ -24,21 +24,28 @@ * - isPending isPending(() => x()) * - authoritative until()'s predicate reading x (CONFIG_AUTHORITATIVE_READ) */ -import { afterAll, afterEach, describe, expect, it } from "vitest"; import { - NotReadyError, action, createMemo, createOptimistic, createRenderEffect, createRoot, createSignal, - createTrackedEffect, flush, - isPending, - latest, until } from "../src/index.js"; +import { + HELD, + holds, + NOT_READY, + never, + observed, + rule, + runOracle, + settle, + violation, + type State +} from "./visibility-oracle.harness.js"; /** The #3331 reporter graph: an optimistic computed over an async fetch of a * signal, with an async memo downstream. `prime()` lands the initial fetch @@ -67,6 +74,8 @@ function supersededGraph(initDownstream: boolean) { return { x, dispose, + setValue, + landFetch: () => fetchResolvers.shift()!(), async prime() { flush(); fetchResolvers.shift()!(); @@ -90,165 +99,6 @@ function supersededGraph(initDownstream: boolean) { }; } -// ── cell values ───────────────────────────────────────────────────────────── -const HELD = "HELD" as const; // nothing published / the reader is holding -const NOT_READY = "throws:NotReady" as const; -type Cell = unknown | typeof HELD | typeof NOT_READY | `throws:${string}`; - -type Expect = - | { value: Cell; rule: string } - | { observed: Cell; note: string } - | { violation: { rule: Cell; current: Cell }; note: string } - | { na: string }; -const rule = (value: Cell, rule: string): Expect => ({ value, rule }); -const observed = (observed: Cell, note: string): Expect => ({ observed, note }); -/** The spec fixes this cell to `rule`; the runtime currently serves `current`. - * Pinned at `current` so the suite is green; listed red by the report. When - * the runtime is fixed this cell fails — flip it to `rule(...)`. */ -const violation = (ruleValue: Cell, current: Cell, note: string): Expect => ({ - violation: { rule: ruleValue, current }, - note -}); -const na = (why: string): Expect => ({ na: why }); - -const READERS = [ - "untracked", - "derivesFrom", - "published", - "preexisting", - "staleForeign", - "childrenForbidden", - "latest", - "isPending", - "authoritative" -] as const; -type Reader = (typeof READERS)[number]; - -type State = { - name: string; - /** Build the state. Returns the node accessor and a disposer. The stale - * foreign reader must be created BEFORE the state is entered, so builders - * receive a hook to install it. */ - build: ( - installStale: (x: () => unknown) => void - ) => - | { x: () => unknown; dispose: () => void } - | Promise<{ x: () => unknown; dispose: () => void }>; - expect: Record; -}; - -// ── helpers ───────────────────────────────────────────────────────────────── -const settle = async () => { - await Promise.resolve(); - await Promise.resolve(); - flush(); -}; -// Every open-ended promise a state creates is registered here and released -// between cells: a never-settling action would otherwise leave its transaction -// live across the next cell (observed: an unrelated async memo's NotReady then -// escaped flush()). -let holds: Array<() => void> = []; -const never = () => new Promise(r => holds.push(r as () => void)); -const releaseAll = async () => { - for (const r of holds.splice(0)) r(); - await settle(); - await settle(); -}; -const classify = (fn: () => unknown): Cell => { - try { - return fn(); - } catch (e) { - return e instanceof NotReadyError - ? NOT_READY - : `throws:${(e as Error)?.constructor?.name ?? String(e)}`; - } -}; - -function readCell( - reader: Reader, - x: () => unknown, - stale: { log: Cell[]; bump: () => void } | null -): Cell { - switch (reader) { - case "untracked": - return classify(x); - case "latest": - return classify(() => latest(() => x())); - case "isPending": - return classify(() => isPending(() => x())); - case "derivesFrom": { - const log: Cell[] = []; - const dispose = createRoot(d => { - const m = createMemo(() => { - let v: Cell; - try { - v = x(); - } catch (e) { - log.push( - e instanceof NotReadyError ? NOT_READY : `throws:${(e as Error)?.constructor?.name}` - ); - throw e; // suspend like a real derivation - } - log.push(v); - return v; - }); - createRenderEffect(m, () => {}); - return d; - }); - flush(); - dispose(); - return log.length ? log[log.length - 1] : HELD; - } - case "published": { - const log: Cell[] = []; - const dispose = createRoot(d => { - const m = createMemo(() => x()); - createRenderEffect(m, v => { - log.push(v); - }); - return d; - }); - flush(); - dispose(); - return log.length ? log[log.length - 1] : HELD; - } - case "preexisting": { - if (!stale) return na("no pre-existing reader") as any; - return stale.log.length ? stale.log[stale.log.length - 1] : HELD; - } - case "staleForeign": { - if (!stale) return na("no stale reader") as any; - stale.log.length = 0; - stale.bump(); - flush(); - return stale.log.length ? stale.log[stale.log.length - 1] : HELD; - } - case "childrenForbidden": { - const log: Cell[] = []; - const dispose = createRoot(d => { - createTrackedEffect(() => { - log.push(classify(x)); - }); - return d; - }); - flush(); - dispose(); - return log.length ? log[log.length - 1] : HELD; - } - case "authoritative": { - let seen: Cell = HELD; - // until()'s predicate is the authoritative reader; it runs synchronously - // on the first evaluation. Resolve immediately so nothing is left open. - until(() => { - seen = classify(x); - return true; - }).catch(() => {}); - flush(); - return seen; - } - } -} - // ── states ────────────────────────────────────────────────────────────────── const STATES: State[] = [ { @@ -589,74 +439,201 @@ const STATES: State[] = [ "A17 carve-out / A27: the loading value is commit #0 — landed by declaration" ) } + }, + { + name: "body ended (override's downstream flight still up; nothing authoritative in flight — #3427)", + async build(installStale) { + const flights: Array<() => void> = []; + let x!: () => unknown; + let setX!: (v: number) => void; + const dispose = createRoot(d => { + [x, setX] = createOptimistic(0); + const downstream = createMemo(() => { + const n = x(); + return new Promise(r => flights.push(() => r(`${n}!`))); + }); + createRenderEffect(downstream, () => {}); + return d; + }); + holds.push(() => flights.splice(0).forEach(f => f())); + flush(); + flights.shift()!(); // prime downstream(0) + await settle(); + installStale(x); + action(function* () { + setX(1); + yield Promise.resolve(); // the body ends; the downstream flight for 1 is still up + })(); + flush(); + await settle(); + await settle(); + return { x, dispose }; + }, + expect: { + untracked: rule(1, "A18 (c): the display keeps the override until the commit"), + derivesFrom: rule( + 0, + "A18 body-end corollary: the override is superseded by the truth at hand (committed 0); the graph re-derives from it" + ), + published: rule( + HELD, + "A18 (c) / A29: a superseded read is a staged read whether the truth is staged or committed — the fresh derivation is the owning transaction's and is held" + ), + preexisting: rule(HELD, "A18 (c): display unchanged until commit"), + staleForeign: rule( + 1, + "A18 (c): a stale reader of the owning transaction displays the override (owner via _overrideOwner, #2912 — the node carries no stamp)" + ), + childrenForbidden: rule( + 1, + "A32: the displayed override shows through — as for landing supersession" + ), + latest: rule(0, "A18 (d): latest returns the truth"), + isPending: rule( + true, + "A18 (d): the truth (committed 0) differs from the displayed override (1)" + ), + authoritative: rule(0, "A17 carve-out: the truth beneath the override") + } + }, + { + name: "un-superseded (a later mainline landing equal to the override — A18)", + async build(installStale) { + const built = supersededGraph(true); + await built.prime(); + installStale(built.x); + await built.supersede(); // truth 2 ≠ override 3 + built.setValue(1.5); // mainline new question; the refetch lands 3 === override + flush(); + built.landFetch(); + await settle(); + return built; + }, + expect: { + untracked: rule(3, "A17: the override is the displayed value"), + derivesFrom: rule( + 3, + "A18: a later landing equal to the override un-supersedes it — the override is again the graph's value" + ), + published: observed( + 3, + "the override is display AND graph; a fresh reader publishes it — no rule names the fresh-reader cell of an un-superseded node" + ), + preexisting: rule(HELD, "A18 (c): the display never changed"), + staleForeign: rule(3, "A17"), + childrenForbidden: rule(3, "A32"), + latest: rule(3, "A18 (d): the arrived value equals the override"), + isPending: rule(false, "A18 (d): the arrival does not differ"), + authoritative: rule(3, "A17 carve-out: the staged truth (3) equals the override") + } + }, + { + name: "held truth (a foreign primitive's confirming landing stolen by an awaited until(), action still open — #3164)", + async build(installStale) { + let landV1!: () => void; + const v1 = new Promise(r => (landV1 = r)); + let stream!: () => { version: number }; + let setSaving!: (v: boolean) => void; + const dispose = createRoot(d => { + [, setSaving] = createOptimistic(false); + stream = createMemo(async function* () { + yield { version: 0 }; + await v1; + yield { version: 1 }; + }); + createRenderEffect(stream, () => {}); + return d; + }); + flush(); + await settle(); + const x = () => stream().version; + installStale(x); + action(function* () { + setSaving(true); + yield until(() => stream().version >= 1); + yield never(); // stays open past the flip: the confirmation is held + })(); + flush(); + await settle(); + landV1(); + await settle(); + await settle(); + return { x, dispose }; + }, + expect: { + untracked: rule( + 0, + "A17 held truth (#3164): staged confirming truth is masked from ordinary readers until the transaction's reveal" + ), + derivesFrom: rule(0, "A17 held truth: ordinary tracked readers keep committed"), + published: observed( + 0, + "a fresh reader of a held-truth node publishes the committed value; no rule names the fresh-reader cell" + ), + preexisting: observed( + 0, + "the pre-existing effect re-runs when the stolen landing arrives and re-publishes the committed 0 — the frame does not change, but the run is observable" + ), + staleForeign: rule(0, "A17 held truth"), + childrenForbidden: rule(0, "A32"), + latest: rule(1, "A17 held truth: latest() sees the staged truth (the deadlock-free tunnel)"), + isPending: observed( + true, + "the stolen landing is a held fresh value (A19 iii); whether held truth pends is not stated in A17's mask rule" + ), + authoritative: rule( + 1, + "A17 carve-out: until()'s predicate sees the staged truth — the tunnel that keeps the hold deadlock-free" + ) + } + }, + { + name: "loading window over a held input (loadingValue memo re-asked by an action-held write; its flight lands while the input is held)", + async build(installStale) { + const [q, setQ] = createSignal(0); + const fetches: Array<() => void> = []; + let x!: () => unknown; + const dispose = createRoot(d => { + const m = createMemo( + () => { + const v = q(); + return new Promise(r => fetches.push(() => r(v * 10))); + }, + { loadingValue: -1 } + ); + x = m; + createRenderEffect(m, () => {}); + return d; + }); + flush(); + installStale(x); + action(function* () { + setQ(1); // new question while the window is still open + yield never(); + })(); + flush(); + fetches.splice(0).pop()!(); // the newest flight lands (10) — held by the action + await settle(); + return { x, dispose }; + }, + expect: { + untracked: rule( + 10, + "A27 (ruled 2026-09-15): the window's first real landing is initial-load class — like a boundary's first content reveal — and commits on arrival even when the input that re-asked it is held by an action" + ), + derivesFrom: rule(10, "A27: initial-load class"), + published: rule(10, "A27: initial-load class"), + preexisting: rule(10, "A27: the landing reveals on arrival"), + staleForeign: rule(10, "A27: initial-load class"), + childrenForbidden: rule(10, "A27: initial-load class"), + latest: rule(10, "A27: initial-load class"), + isPending: rule( + false, + "A27: verdict-quiet through the window — a hydration invariant (the server answers false; the client must match on creation)" + ), + authoritative: rule(10, "A27: initial-load class") + } } ]; -// ── run ───────────────────────────────────────────────────────────────────── -const unspecified: string[] = []; -const violations: string[] = []; -afterEach(async () => { - await releaseAll(); - flush(); -}); - -afterAll(() => { - if (process.env.VISIBILITY_ORACLE_REPORT) - require("node:fs").writeFileSync( - process.env.VISIBILITY_ORACLE_REPORT, - ["# violations", ...violations, "", "# unspecified", ...unspecified].join("\n") + "\n" - ); -}); - -describe("visibility oracle (A7, A15, A16, A17, A18, A19, A24, A26, A27, A29, A32)", () => { - for (const state of STATES) { - describe(state.name, () => { - for (const reader of READERS) { - const exp = state.expect[reader]; - if ("na" in exp) continue; - const title = - "value" in exp - ? `${String(exp.value)} [${exp.rule}]` - : "violation" in exp - ? `VIOLATION: rule says ${String(exp.violation.rule)}, runtime serves ${String(exp.violation.current)} (${exp.note})` - : `${String(exp.observed)} (observed; ${exp.note})`; - it(`${reader} → ${title}`, async () => { - let stale: { log: Cell[]; bump: () => void } | null = null; - const [u, setU] = createSignal(0); - const installStale = (x: () => unknown) => { - const log: Cell[] = []; - createRoot(() => { - createRenderEffect( - () => { - u(); - return x(); // a throw suspends the pass → HELD - }, - v => { - log.push(v); - } - ); - }); - flush(); - log.length = 0; - stale = { log, bump: () => setU(n => n + 1) }; - }; - const built = state.build(installStale); - const { x, dispose } = built instanceof Promise ? await built : built; - const got = readCell(reader, x, stale); - dispose(); - await releaseAll(); - if ("value" in exp) expect(got).toEqual(exp.value); - else if ("violation" in exp) { - violations.push( - `${state.name} × ${reader}: rule ${String(exp.violation.rule)}, runtime ${String(got)} — ${exp.note}` - ); - expect(got).toEqual(exp.violation.current); - } else { - unspecified.push(`${state.name} × ${reader} = ${String(got)} — ${exp.note}`); - expect(got).toEqual(exp.observed); - } - }); - } - }); - } -}); +runOracle("visibility oracle (A7, A15, A16, A17, A18, A19, A24, A26, A27, A29, A32)", STATES); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 679f49510..4ddb18190 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -590,7 +590,13 @@ module.exports = [ // 11050 (+146 — the core seams plus the verdict pulls' `_verdictPull` // brackets and supersededRead's entry); rebased over #3443/#3444: 11213 B // against `next`'s 11084 (+129); see the core floor note. - limit: "11.25 KB", + // Body-end visibility + latest() seed (#3455, 2026-09-15): 11.25 -> 11.30 KB, + // measured at 11267 B against `next`'s 11213 (+54) — `supersededRead` + // resolves the override owner and enters for a committed truth too, the + // verdict's body-end A18 (d) branch, and `uninitializedSource`'s owner + // walk; all in the verdict/optimistic modules this scenario retains + // (core floor 8820 -> 8832, +createStore 15743 -> 15780, both in cap). + limit: "11.30 KB", modifyEsbuildConfig }, {