diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 055f66e8a..da1724085 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -43,51 +43,51 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## A — spec propositions -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:249` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | -| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:257` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | -| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:265` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | -| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | -| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:281` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | -| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:289` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | -| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.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:125` | core.ts×1 verdict.ts×2 | 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:133` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | -| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.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:149` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | -| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | -| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×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 superseded-source-blocks-3462.test.ts×2 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:173` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×2 visibility-oracle.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:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.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:301` | 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:308` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | -| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | -| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | -| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×2 visibility-oracle.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:239` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | -| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.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:231` | — | 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… | -| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.test.ts×8 visibility-oracle.test.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 latest-held-till-flush.test.ts×2 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.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:207` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | -| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | -| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | — | visibility-oracle-store.test.ts×6 visibility-oracle.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… | -| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| --- | ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:249` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | +| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:257` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | +| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:265` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | +| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | +| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:281` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | +| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:289` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | +| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | core.ts×1 verdict.ts×2 | createMemo.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | +| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | +| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | +| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | +| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×3 core.ts×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 posture-born-held-and-observation.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 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 visibility-oracle.states.ts×6 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:173` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×2 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.test.ts×12 visibility-oracle.states.ts×25 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.test.ts×10 visibility-oracle.states.ts×24 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | +| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.states.ts×11 visibility-oracle.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:315` | 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:322` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | +| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | +| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:189` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | +| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×2 visibility-oracle.states.ts×3 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | +| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:239` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | +| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:231` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.test.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 latest-held-till-flush.test.ts×2 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | async-landing-deps-3461.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:83` | core.ts×2 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:91` | — | visibility-oracle-store.test.ts×6 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | +| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------ | -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:371` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | -| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:381` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | -| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:387` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:394` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | -| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:406` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:385` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | +| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:395` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | +| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:401` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:408` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | +| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:420` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B @@ -104,8 +104,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | -------------------------------------------------- | --------------------------------------------------------------------------- | | C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | -| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:337` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | -| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:347` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:351` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | +| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:361` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | | C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:31` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | ## INV — invariants @@ -147,17 +147,17 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | ------ | ------ | ------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | CS-R1 | live | `docs/rules-mining/core-store.md:9` | — | flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.test.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).\*\* | -| CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.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-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).\*\* | | CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | reconcile.ts×1 store.ts×1 | — | Corollary R2a (Ryan, 2026-08-17): \*\*take no responsibility for mutation | | CS-R3 | live | `docs/rules-mining/core-store.md:18` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.\*\* | | CS-R4 | live | `docs/rules-mining/core-store.md:22` | — | — | Write isolation across a store chain: writing through the last store in a derived chain is visible only there; upstream stores and base objects untouched (shallow or deep middle).\*\* | -| CS-R5 | live | `docs/rules-mining/core-store.md:26` | — | visibility-oracle-store.test.ts×4 visibility-oracle.test.ts×4 | Upstream writes propagate downstream through the chain without re-running structural machinery.\*\* | +| CS-R5 | live | `docs/rules-mining/core-store.md:26` | — | visibility-oracle-store.test.ts×4 visibility-oracle.states.ts×4 | Upstream writes propagate downstream through the chain without re-running structural machinery.\*\* | | CS-R6 | live | `docs/rules-mining/core-store.md:30` | — | — | No store write path ever mutates a user-provided source object.\*\* Aligned with 2026-08-16b. | | CS-R7 | live | `docs/rules-mining/core-store.md:34` | — | — | Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).\*\* | | CS-R8 | live | `docs/rules-mining/core-store.md:38` | — | — | `snapshot` returns fully unwrapped values (no proxy anywhere, `$TARGET` undefined), incl. frozen objects/arrays; reflects committed written values incl. writes over inherited prototype props.\*\* | | CS-R9 | live | `docs/rules-mining/core-store.md:42` | store.ts×2 target.ts×1 | — | Proxy identity per logical slot is stable across writes and reconciles\*\* (mapArray keyed flows reuse rows across refetch/reconcile). | | CS-R10 | live | `docs/rules-mining/core-store.md:48` | — | next-smoke.test.ts×1 | Per-property tracking; same-value writes (direct or functional path setter returning prev) do not re-trigger.\*\* | -| CS-R11 | live | `docs/rules-mining/core-store.md:52` | — | visibility-oracle-store.test.ts×1 visibility-oracle.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-R11 | live | `docs/rules-mining/core-store.md:52` | — | visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×2 | Per-path tracking: reading `state.user.firstName` subscribes to that leaf; reading the reference `store[0]` does not subscribe to `store[0].i`.\*\* | | CS-R12 | live | `docs/rules-mining/core-store.md:56` | store.ts×1 | — | Reading an absent key subscribes to that key: other-key changes don't trigger; defining it later (assignment or defineProperty) does.\*\* | | CS-R13 | live | `docs/rules-mining/core-store.md:60` | target.ts×1 | — | `in` tracks presence, not value: undefined-write doesn't retrigger; delete does; adding absent key does. `in`/`has` never invokes source getters.\*\* | | CS-R14 | live | `docs/rules-mining/core-store.md:64` | — | — | `Object.keys` / `for…in` subscribe to key-set membership (root and nested) — distinct from property nodes.\*\* Aligned: key-set node. | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 468af3c9a..7894a8366 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -199,7 +199,7 @@ A resting optimistic node reports pending via exactly the causes a plain async m ### A15. Transition entanglement is graph-driven; lanes settle as one reveal **Status:** **ruled, amended in place** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06; amended 2026-09-14 (#3407: a shared render effect entangles nothing by itself — see the shared-hole corollary); mechanism completed 2026-09-14 (#3443: a held memo made pending by another flight entangles at the propagation, not at its next pass) -**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first) +**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first); `tests/posture-born-held-and-observation.test.ts` (posture matrix, ruled 2026-09-15: `latest(x)` evaluated inside another live action ENTANGLES — "optimistic lanes are transition-bound, so it does need to entangle; that doesn't mean optimism for both can't poke through in the meanwhile" — the held value is served at once, the two transactions reveal as one) **Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407); `notifyStatus`'s pending propagation onto a memo another live transaction holds (stamped, and pending or staged) enters it (`initTransition(sub._transition)`, #3443). (was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands — and it stands from the moment the second flight reaches the memo (#3443): pending _propagates_ onto a held memo without recomputing it (its inputs' values are unchanged), so the propagation itself enters the memo's transaction — when the memo is genuinely _held_ (pending on that transaction's work, or staged by it); a stamp alone decides nothing (#3334), so a second write that supersedes the first through a shared output memo does not drag the superseded flight into the live reveal; waiting for the memo's next pass let the first flight land, reveal its inputs (`A: 1`) beside the memo's committed value (`Sum: 0`), and left `Sum: 2` to arrive with `B: 1`. Consequence: the write that started the second flight is held with the first when its async work flows into a memo the first holds (`page=1` waits with `count=1` while `details` re-asks), even where a plain binding of the same write would have passed through — the async work, not the binding, is what is shared. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`. @@ -294,6 +294,20 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H `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. +## Open rulings + +### O1. Same-tick adoption — an action adopts the ambient writes made before it in the same tick + +**Status:** **ruled, by design** 2026-09-15 — maintainer: "transitions are ambient. Action is just a special case to link them over async; every write is a conceptual transition." Surfaced by the posture matrix (`tests/visibility-oracle-posture.test.ts`, class A: 16 cells across the `staged, ambient` and `override, ambient` states under the `foreignAction` / `foreignLane` postures); pinned by `tests/posture-born-held-and-observation.test.ts` (an ambient write made before an action in the same tick reveals with the action). +**Current behavior:** `initTransition` adopts the ambient batch wholesale (`_pendingNodes`, `_optimisticNodes`, `_affectsNodes`, gated readers), so `set(x, 1); startAction()` in one tick makes `x`'s plain write the action's — `x()` stays 0 until the unrelated action settles, and inside the action's body `latest(x)` answers 1 and `isPending(x)` true (A28 would say 0 / false pre-flush for a write nothing holds). An ambient optimistic write made before the action (`setO(5); startAction()`), which mainline reverts at the next flush (OL-R5), instead lives on as the action's override for its whole lifetime. +**Ruling:** intended. The same-tick window is one ambient transition; an action that opens in it owns it. (#3141 ruled the ASYNC-GAP version a bug — work arriving after the body's first await is not the action's.) Not React's `startTransition`, deliberately. + +### O2. Creation under a transaction escapes the hold — recorded, not ruled + +**Status:** **recorded** 2026-09-15 — maintainer: "generally creation escapes because it isn't visible"; "I'm fine either way as long as we end up somewhere consistent"; "let's capture things as they are." Pinned as OBSERVED by `tests/posture-born-held-and-observation.test.ts`; the matrix (class B, 10 cells) records it. +**Current behavior:** a memo + render effect created inside a live action's body over a value another action holds (or over the superseded / body-ended states) direct-commits (`recompute`'s `create && bornHeld === null` arm — `stagedEntry` is only recorded when `activeTransition` is null) and its render effect publishes the held value, while untracked reads keep the committed frame until both actions settle. Mainline creation over the same value is born held (A29's creation-time form, 2026-09-14). Creation inside the HOLDING action's own body is unaffected: the body's write is unflushed (A28), the creation reads the committed frame. +**Tension:** A29 (born held) applies to mainline creation only; the "isn't visible" premise does not hold for a render effect created in the body. A future ruling either extends A29 to every posture (`creatingPass` in `enterStagedRead`, prototyped 2026-09-15: +83 B, suite green) or states creation-escapes as the rule and re-examines the mainline form. + ## Superseded rules (kept verbatim) Cited by tests and by A24's reasoning; the statements below are as they stood when superseded. diff --git a/packages/signals/tests/posture-born-held-and-observation.test.ts b/packages/signals/tests/posture-born-held-and-observation.test.ts new file mode 100644 index 000000000..b4a39c2d4 --- /dev/null +++ b/packages/signals/tests/posture-born-held-and-observation.test.ts @@ -0,0 +1,123 @@ +/** + * Posture matrix pins (visibility-oracle-posture.test.ts is the discovery + * runner; these are the cells brought to a ruling, 2026-09-15). + * + * A — same-tick adoption is BY DESIGN: transitions are ambient; an action is + * the special case that links one across async, and every write is a + * conceptual transition. An action started later in the same tick owns + * the ambient writes made before it. + * C — latest() ENTANGLES (A15): optimistic lanes are transition-bound, so a + * latest() read inside another live action merges that action with the + * source's — "that doesn't mean optimism for both can't poke through in + * the meanwhile": the value is served immediately, the reveal is joint. + * B — creation under a transaction ESCAPES the hold (spec O2, recorded as + * current behavior, not ruled): a memo + render effect created inside a + * live action over a value another action holds direct-commits and + * publishes the held value while untracked reads keep the committed + * frame. "Generally creation escapes because it isn't visible" — the + * matrix shows the creation's own effect does publish; kept as is + * ("capture things as they are", 2026-09-15). A change here flips the + * pin loudly and is a design decision. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + latest +} from "../src/index.js"; + +const tick = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; + +/** `x` held at 1 by action T (yields until `release`). */ +function heldByAction() { + const [x, setX] = createSignal(0); + let release!: () => void; + const run = action(function* () { + setX(1); + yield new Promise(r => (release = r)); + }); + run(); + flush(); + expect(x()).toBe(0); + return { x, release }; +} + +describe("A — same-tick adoption is by design: transitions are ambient (posture matrix, ruled 2026-09-15)", () => { + it("an action started after an ambient write in the same tick owns that write; it reveals with the action", async () => { + const [x, setX] = createSignal(0); + setX(1); // ambient, unflushed + let release!: () => void; + const run = action(function* () { + yield new Promise(r => (release = r)); + }); + run(); + flush(); + expect(x()).toBe(0); // adopted: held with the action, not committed by the flush + release(); + await tick(); + expect(x()).toBe(1); + }); +}); + +describe("C — latest() entangles: lanes are transition-bound (A15, ruled 2026-09-15)", () => { + it("latest(x) inside another live action serves the held value now and merges that action with x's transaction", async () => { + const { x, release } = heldByAction(); + const [y, setY] = createSignal(0); + let seen: number | undefined; + let releaseU!: () => void; + const runU = action(function* () { + setY(1); + seen = latest(x); // optimism pokes through immediately … + yield new Promise(r => (releaseU = r)); + }); + runU(); + flush(); + expect(seen).toBe(1); + release(); // … but T alone no longer reveals: U and T settle as one + await tick(); + expect(x()).toBe(0); + expect(y()).toBe(0); + releaseU(); + await tick(); + expect(x()).toBe(1); + expect(y()).toBe(1); + }); +}); + +describe("B — creation under a transaction escapes the hold (OBSERVED, spec O2; not a ruling)", () => { + it("a memo + render effect created inside another live action over a held value publishes the held value; the frame keeps the committed one until both actions settle", async () => { + const { x, release } = heldByAction(); + const [y, setY] = createSignal(0); + const published: number[] = []; + let releaseU!: () => void; + const runU = action(function* () { + setY(1); + createRoot(() => { + const m = createMemo(() => x()); + createRenderEffect(m, v => { + published.push(v); + }); + }); + yield new Promise(r => (releaseU = r)); + }); + runU(); + flush(); + expect(published).toEqual([1]); // observed: the creation direct-committed the held value + expect(x()).toBe(0); // while the frame reads committed + release(); // the creation derived from T's world, so U merged into T (A15): T alone does not reveal + await tick(); + expect(x()).toBe(0); + releaseU(); + await tick(); + expect(x()).toBe(1); + expect(published).toEqual([1]); + }); +}); diff --git a/packages/signals/tests/visibility-oracle-posture.test.ts b/packages/signals/tests/visibility-oracle-posture.test.ts new file mode 100644 index 000000000..1899d9008 --- /dev/null +++ b/packages/signals/tests/visibility-oracle-posture.test.ts @@ -0,0 +1,254 @@ +/** + * Visibility oracle — posture matrix (DISCOVERY MODE). + * + * The visibility oracle pins node state × reader KIND with every reader built + * mainline. This file adds the reader's POSTURE — where the reader lives when + * it is built — and a second observable, ENTANGLEMENT: after the reader is + * built, only the SOURCE's hold is released; does `x` reveal, or is its reveal + * now blocked by the reader's own context (a transaction merge, a batch + * adoption, a lane)? + * + * Postures: + * - mainline the reader is built in the imperative scope (the oracle's rows) + * - foreignAction the reader is built inside ANOTHER live action's body; that + * action also writes its own unrelated signal `y` and yields + * forever + * - foreignLane as foreignAction, and the foreign action first writes its own + * createOptimistic (`setO(9)`) so the reader runs under a lane + * that is not the source's + * - behindFallback the reader is built inside a createLoadingBoundary whose + * content is pending on a sibling flight that never lands — + * the fallback is showing, the reader is not on screen + * - disposedReader the reader is built mainline and its root is DISPOSED + * before the source's hold is released — a dead reporter + * + * Readers under a posture: untracked, memo (its pass value and what a render + * effect over it publishes), latest, isPending. + * + * This is the discovery pass: no cell is pinned. Every cell is recorded to + * VISIBILITY_POSTURE_REPORT (a markdown table per state) so red cells can be + * brought to a ruling before anything is written against them. + */ +import { afterAll, describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createLoadingBoundary, + createRoot, + createSignal, + flush, + isPending, + latest, + NotReadyError +} from "../src/index.js"; +import { + HELD, + holds, + never, + releaseAll, + settle, + type Cell, + type State +} from "./visibility-oracle.harness.js"; +import { STATES } from "./visibility-oracle.states.js"; + +const POSTURES = [ + "mainline", + "foreignAction", + "foreignLane", + "behindFallback", + "disposedReader" +] as const; +type Posture = (typeof POSTURES)[number]; +const READERS = ["untracked", "memo", "latest", "isPending"] as const; +type Reader = (typeof READERS)[number]; + +const classify = (fn: () => unknown): Cell => { + try { + return fn(); + } catch (e) { + return e instanceof NotReadyError + ? "throws:NotReady" + : `throws:${(e as Error)?.constructor?.name ?? String(e)}`; + } +}; + +/** Run `build` in the posture's context. Returns the foreign signal `y`'s + * accessor (null for mainline) so the probe can confirm the foreign action is + * still live after the source's hold is released. */ +function enter(posture: Posture, build: () => void): { y: (() => number) | null } { + if (posture === "mainline" || posture === "disposedReader") { + build(); + return { y: null }; + } + if (posture === "behindFallback") { + let view: unknown; + const dispose = createRoot(d => { + const blocker = createMemo(() => never()); + const b = createLoadingBoundary( + () => { + build(); + blocker(); // suspends the content: the fallback shows + return "content"; + }, + () => "fallback" + ); + createRenderEffect(b, v => { + view = v; + }); + return d; + }); + disposers.push(dispose); + flush(); + if (view !== "fallback") + throw new Error("behindFallback posture: fallback not showing (" + String(view) + ")"); + return { y: null }; + } + const [y, setY] = createSignal(0); + let setO: ((v: number) => void) | null = null; + const dispose = createRoot(d => { + if (posture === "foreignLane") [, setO] = createOptimistic(0); + return d; + }); + disposers.push(dispose); + const run = action(function* () { + setY(1); + if (setO) setO(9); + build(); + yield never(); + }); + run(); + return { y }; +} +const disposers: Array<() => void> = []; + +type Row = { + state: string; + posture: Posture; + reader: Reader; + served: Cell; + passValue?: Cell; // memo: what its pass read + afterSourceRelease: Cell; // untracked x() after ONLY the source's holds are released + foreignStillHeld: boolean | null; // y() still 0 (the foreign action did not settle) +}; +const rows: Row[] = []; + +async function cell(state: State, posture: Posture, reader: Reader): Promise { + const built = state.build(() => {}); + const { x, dispose } = built instanceof Promise ? await built : built; + const sourceHolds = holds.length; // everything pushed so far belongs to the source + let served: Cell = HELD; + let passValue: Cell | undefined; + const log: Cell[] = []; + const pass: Cell[] = []; + const { y } = enter(posture, () => { + switch (reader) { + case "untracked": + served = classify(x); + break; + case "latest": + served = classify(() => latest(x)); + break; + case "isPending": + served = classify(() => isPending(x)); + break; + case "memo": { + const d = createRoot(d => { + const m = createMemo(() => { + let v: Cell; + try { + v = x(); + } catch (e) { + pass.push( + e instanceof NotReadyError + ? "throws:NotReady" + : `throws:${(e as Error)?.constructor?.name}` + ); + throw e; + } + pass.push(v); + return v; + }); + createRenderEffect(m, v => { + log.push(v); + }); + return d; + }); + disposers.push(d); + } + } + }); + flush(); + if (reader === "memo") { + served = log.length ? log[log.length - 1] : HELD; + passValue = pass.length ? pass[pass.length - 1] : HELD; + } + if (posture === "disposedReader") { + for (const d of disposers.splice(0)) d(); + flush(); + } + // Entanglement probe: release ONLY the source's holds. + for (const r of holds.splice(0, sourceHolds)) r(); + await settle(); + await settle(); + const afterSourceRelease = classify(x); + const foreignStillHeld = y ? y() === 0 : null; + dispose(); + await releaseAll(); + for (const d of disposers.splice(0)) d(); + flush(); + return { + state: state.name, + posture, + reader, + served, + passValue, + afterSourceRelease, + foreignStillHeld + }; +} + +const fmt = (c: Cell | undefined) => (c === undefined ? "" : c === HELD ? "HELD" : String(c)); + +describe("visibility oracle — posture matrix (discovery)", () => { + for (const state of STATES) + for (const posture of POSTURES) + for (const reader of READERS) + it(`${state.name} × ${posture} × ${reader}`, async () => { + rows.push(await cell(state, posture, reader)); + expect(true).toBe(true); + }); + afterAll(() => { + const out: string[] = ["# visibility oracle — posture matrix (discovery, no pins)", ""]; + for (const state of STATES) { + out.push(`## ${state.name}`, ""); + out.push( + "| reader | posture | served | memo pass | x after source release | foreign still held |" + ); + out.push("|---|---|---|---|---|---|"); + for (const reader of READERS) { + const base = rows.find( + r => r.state === state.name && r.reader === reader && r.posture === "mainline" + ); + for (const posture of POSTURES) { + const r = rows.find( + r => r.state === state.name && r.reader === reader && r.posture === posture + )!; + const entangled = + base && + posture !== "mainline" && + fmt(r.afterSourceRelease) !== fmt(base.afterSourceRelease); + const servedDiff = base && posture !== "mainline" && fmt(r.served) !== fmt(base.served); + out.push( + `| ${reader} | ${posture} | ${fmt(r.served)}${servedDiff ? " **≠**" : ""} | ${fmt(r.passValue)} | ${fmt(r.afterSourceRelease)}${entangled ? " **ENTANGLED**" : ""} | ${r.foreignStillHeld === null ? "—" : r.foreignStillHeld} |` + ); + } + } + out.push(""); + } + const path = process.env.VISIBILITY_POSTURE_REPORT; + if (path) require("node:fs").writeFileSync(path, out.join("\n") + "\n"); + }); +}); diff --git a/packages/signals/tests/visibility-oracle.states.ts b/packages/signals/tests/visibility-oracle.states.ts new file mode 100644 index 000000000..87c015f56 --- /dev/null +++ b/packages/signals/tests/visibility-oracle.states.ts @@ -0,0 +1,645 @@ +/** + * Visibility oracle — node state × reader kind → what is served. (States; + * the runner is visibility-oracle.test.ts, the posture matrix is + * visibility-oracle-posture.test.ts.) + * + * The runtime decides what a read serves at eleven sites (read, readNodeFast, + * supersededRead, latestRead, gatedRead, laneReadsCommitted, heldFromStale, + * store nodeValue/serveDataKey/optimisticView). Every new visibility rule is + * threaded through them by hand. This file pins the OBSERVABLE outcome per + * cell so a rule that reaches one site and misses another shows up as a red + * cell instead of an audit finding. + * + * Rows: node states. Columns: reader kinds. Each expected cell cites the rule + * that fixes it (SPEC-ASYNC-SEMANTICS.md). Cells the spec does not fix are + * `observed(...)`: pinned as current behavior, listed by `reportUnspecified` + * so they can be brought to a ruling — changing one is a design decision. + * + * Reader kinds: + * - untracked x() with no observer + * - derivesFrom the value a mainline memo's PASS read (compute-side log) + * - published what a render effect over that memo published; HELD if nothing + * - preexisting what a render effect built before the state published after it (HELD if nothing) + * - staleForeign that same effect re-run by an unrelated mainline write + * - childrenForbidden createTrackedEffect reading x + * - latest latest(() => x()) + * - isPending isPending(() => x()) + * - authoritative until()'s predicate reading x (CONFIG_AUTHORITATIVE_READ) + */ +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush, + 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 + * (and, when `initDownstream`, the downstream's first flight); `supersede()` + * runs an action that changes the question and guesses wrong, then lands the + * refetch with the differing truth while the action stays live. */ +export function supersededGraph(initDownstream: boolean) { + const [value, setValue] = createSignal(0); + const fetchResolvers: Array<() => void> = []; + const flights: Array<() => void> = []; + let x!: () => unknown; + let setDouble!: (v: number) => void; + const dispose = createRoot(d => { + [x, setDouble] = createOptimistic(() => { + const v = value(); + return new Promise(r => fetchResolvers.push(() => r(v * 2))); + }); + 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())); + return { + x, + dispose, + setValue, + landFetch: () => fetchResolvers.shift()!(), + async prime() { + flush(); + fetchResolvers.shift()!(); + await settle(); + if (initDownstream) { + flights.shift()!(); + await settle(); + } + }, + async supersede() { + const run = action(function* () { + setValue(1); // new question → refetch + setDouble(3); // wrong guess; truth will be 2 + yield never(); + }); + run(); + flush(); + fetchResolvers.shift()!(); // own source lands 2 ≠ 3 while the action is live + await settle(); + } + }; +} + +// ── states ────────────────────────────────────────────────────────────────── +export const STATES: State[] = [ + { + name: "committed (signal, no write)", + build(installStale) { + const [x] = createSignal(0); + installStale(x); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(0, "baseline"), + derivesFrom: rule(0, "baseline"), + published: rule(0, "baseline"), + preexisting: rule(HELD, "baseline: nothing new to publish"), + staleForeign: rule(0, "baseline"), + childrenForbidden: rule(0, "baseline"), + latest: rule(0, "baseline"), + isPending: rule(false, "baseline"), + authoritative: rule(0, "baseline") + } + }, + { + name: "staged, ambient (set(1) before the flush)", + build(installStale) { + const [x, set] = createSignal(0); + installStale(x); + set(1); + return { x, dispose() {} }; + }, + expect: { + // Readers that flush observe the write land — trivially 1. + untracked: rule( + 0, + "A28: an unflushed write is not the committed value — an untracked read serves committed until the flush" + ), + derivesFrom: rule(1, "the flush carries the write"), + published: rule(1, "the flush carries the write"), + preexisting: rule(HELD, "A28: nothing is visible before the flush"), + staleForeign: rule(1, "the flush carries the write"), + childrenForbidden: rule(1, "the flush carries the write"), + latest: rule( + 0, + 'A28: latest() reads the flushed staged world — the pre-write answer until the flush that carries the write ("nothing is ever 30 while its derivations are still 20-shaped")' + ), + isPending: rule( + false, + "A28 (2): isPending is false for an unflushed write — nothing is observable yet to be pending from" + ), + authoritative: rule( + 1, + "A28 (4): until()'s predicate is evaluated inside the flush that carries the write, where the write is promoted — it sees 1" + ) + } + }, + { + name: "held by a live action (set(1) inside action, yield forever)", + build(installStale) { + const [x, set] = createSignal(0); + installStale(x); + const run = action(function* () { + set(1); + yield never(); + }); + run(); // imperative scope: the test body + flush(); + return { x, dispose() {} }; + }, + expect: { + untracked: rule( + 0, + "A19 (i): the observable value is the committed one while the write is held" + ), + derivesFrom: rule( + 1, + "A29: a tracked pass served the staged value derives from the transaction's world" + ), + published: rule( + HELD, + "A29 (born held): a memo created mainline during the hold derives from the transaction’s world and is staged into it; the render effect over it is replayed by the commit, publishing nothing before" + ), + preexisting: rule(HELD, "A19 (i): the held write is not on screen"), + staleForeign: rule( + 0, + "A15 reveal corollary / A26: a stale reader of a parallel transaction shows committed, no entanglement" + ), + childrenForbidden: rule( + 0, + "A32: children-forbidden readers see the frame; a held write is never visible to them" + ), + latest: rule(1, "A8/A11: the held value exists from the write; latest serves it"), + isPending: rule(true, "A19 (i)"), + authoritative: rule( + 1, + "A17 carve-out (2026-09-14): until() reads the landed world — the staged value" + ) + } + }, + { + name: "override active (createOptimistic set to 5 inside a live action)", + build(installStale) { + let x!: () => unknown; + let setO!: (v: number) => void; + const dispose = createRoot(d => { + [x, setO] = createOptimistic(0); + return d; + }); + installStale(x); + const run = action(function* () { + setO(5); + yield never(); + }); + run(); + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule(5, "A17: the override is the displayed value"), + derivesFrom: rule(5, "A17: the override is the graph's value until its own source answers"), + published: observed( + 5, + "A17: direct read shows optimistic; whether a mainline memo over it publishes or holds is not stated" + ), + preexisting: rule( + 5, + "A17: the override is the displayed value; no downstream async, so the lane has nothing to wait for" + ), + staleForeign: rule(5, "A17: the applied frame shows the override"), + childrenForbidden: rule(5, "A32: the override is the frame; it shows through"), + latest: rule(5, "A17 / OL-R11: the override is the value on every channel"), + isPending: rule(false, "A24 (3): optimistic writes are verdict-inert"), + authoritative: rule(0, "A17: until()'s predicate never sees the caller's own optimism") + } + }, + { + name: "override, ambient (createOptimistic set to 5 outside any action, before the flush)", + build(installStale) { + let x!: () => unknown; + let setO!: (v: number) => void; + const dispose = createRoot(d => { + [x, setO] = createOptimistic(0); + return d; + }); + installStale(x); + setO(5); + return { x, dispose }; + }, + expect: { + untracked: rule( + 0, + "A28 (5): an optimistic write is a write — it becomes the active override at the flush that carries it; until then no reader sees it (supersedes OL-R2)" + ), + derivesFrom: rule( + 0, + "OL-R5: an ambient optimistic write reverts at the next flush (the flush the reader forces)" + ), + published: rule(0, "OL-R5"), + preexisting: rule(HELD, "A28: nothing is visible before the flush"), + staleForeign: rule(0, "OL-R5"), + childrenForbidden: rule(0, "OL-R5"), + latest: rule( + 0, + "A28 (5): not visible before the flush on any channel (supersedes OL-R11 pre-flush)" + ), + isPending: rule(false, "A24 (3)"), + authoritative: rule( + 0, + "A17 carve-out: never the caller's optimism (and the ambient override reverts at the flush the reader forces)" + ) + } + }, + { + name: "superseded (own source landed 2 ≠ override 3; initialized downstream flight keeps the action live)", + async build(installStale) { + const built = supersededGraph(true); + await built.prime(); + installStale(built.x); + await built.supersede(); + return built; + }, + expect: { + untracked: rule( + 3, + "A18 (c): untracked reads keep the override until the transaction commits" + ), + derivesFrom: rule(2, "A18 (b): tracked derivations recompute from the arrived value"), + published: rule( + HELD, + "A18 (c) / A29 (born held): a fresh mainline memo over the superseded node derives from the staged truth and is held with the transaction; the frame keeps the override" + ), + preexisting: rule(HELD, "A18 (c): the applied frame keeps the override until commit"), + staleForeign: rule( + 3, + "A18 (c) / A17 amended: a stale reader of another transaction displays the override" + ), + childrenForbidden: rule(3, "A32: the displayed override shows through, superseded or not"), + latest: rule(2, "A18 (d): latest returns the arrived value"), + isPending: rule(true, "A18 (d): pending iff the arrival differs from the override"), + authoritative: rule(2, "A17 carve-out: the authoritative reader sees the staged truth") + } + }, + { + name: "superseded before its first commit (the first landing was held by a downstream reveal that never landed)", + async build(installStale) { + const built = supersededGraph(false); + await built.prime(); + installStale(built.x); + await built.supersede(); + return built; + }, + expect: { + untracked: rule(3, "A18 (c)"), + derivesFrom: rule(2, "A18 (b)"), + published: rule(HELD, "A18 (c) / A29 (born held)"), + preexisting: observed( + HELD, + "this reader (created after the node initialized) holds. A render effect on the node created BEFORE its first landing published the truth (2) at the supersession in a side probe — while untracked reads still served 3 — so the hold here is shape-dependent; follow-up" + ), + staleForeign: observed(3, "displays the override, as in the initialized case"), + childrenForbidden: rule(3, "A32"), + latest: rule(2, "A18 (d)"), + isPending: rule( + true, + "A18 (d): pending iff the arrival differs from the displayed override — even before the node's first commit (the override is the observable value; A19 exception 1 does not apply)" + ), + authoritative: rule(2, "A17 carve-out") + } + }, + { + name: "pending own async (initialized memo refetching on a new question, flight up)", + async build(installStale) { + const [q, setQ] = createSignal(0); + const resolvers: Array<(v: number) => void> = []; + let x!: () => unknown; + const dispose = createRoot(d => { + const m = createMemo(() => { + const v = q(); + return new Promise(r => resolvers.push(r)).then(() => v * 10); + }); + x = m; + createRenderEffect(m, () => {}); + return d; + }); + flush(); + resolvers.shift()!(0); + await settle(); + installStale(x); + setQ(1); // new question; flight never lands + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule( + 0, + "A19 (ii): the observable value is the committed one while the node's own async is in flight" + ), + derivesFrom: rule( + NOT_READY, + "A15: a fresh derivation suspends on the observed flight. (With no reader holding on the flight the pass reads the committed value instead and the reveal holds — the frame is identical; observation-driven transactions make the difference inherent, ruled 2026-09-14.)" + ), + published: rule(HELD, "A15"), + preexisting: rule(HELD, "A15: the reader that observed the flight holds"), + staleForeign: observed( + 0, + "A15 reveal corollary says a stale reader holds when the flight's inputs are published (#3305); here the new question was a mainline write and the reader shows the pre-flight committed value — does INPUTS_PUBLISHED cover a flight opened by the same batch?" + ), + childrenForbidden: rule(0, "A32: a pending node's committed value is the frame"), + latest: rule( + 0, + "A8: latest shows the stale value while the own fetch for a new question is in flight" + ), + isPending: rule(true, "A19 (ii) / A24 (2): a new question pends"), + authoritative: rule( + NOT_READY, + "A17 carve-out: nothing has landed for the new question; the predicate suspends like any reader" + ) + } + }, + { + name: "uninitialized (memo whose first flight never lands)", + build(installStale) { + let x!: () => unknown; + const dispose = createRoot(d => { + const m = createMemo(() => never()); + x = m; + createRenderEffect(m, () => {}); + return d; + }); + installStale(x); + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule( + NOT_READY, + "A19 exception (1): loading, not pending — the NotReady propagates" + ), + derivesFrom: rule( + NOT_READY, + "A16 carve-out (B5a): tracked contexts propagate NotReady to loading boundaries" + ), + published: rule(HELD, "A19 exception (1)"), + preexisting: rule(HELD, "A19 exception (1)"), + staleForeign: rule( + HELD, + "A15 reveal corollary: an uninitialized node has no committed value to show" + ), + childrenForbidden: rule(NOT_READY, "A32: no frame to read yet"), + latest: rule( + NOT_READY, + "A7 (amended 2026-09-14): latest() of an uninitialized node throws in every scope, never undefined" + ), + isPending: rule( + false, + "A16: an unowned isPending never throws; A19 exception (1): loading is not pending" + ), + authoritative: rule(NOT_READY, "A17 carve-out: nothing landed") + } + }, + { + name: "loading window (createMemo with loadingValue -1, first flight up)", + build(installStale) { + let x!: () => unknown; + const dispose = createRoot(d => { + const m = createMemo(() => never(), { loadingValue: -1 }); + x = m; + createRenderEffect(m, () => {}); + return d; + }); + installStale(x); + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule(-1, "A27: the loading value is commit #0"), + derivesFrom: rule(-1, "A27: never suspends readers during the first flight"), + published: rule(-1, "A27"), + preexisting: rule(HELD, "A27: the loading value was published before the state; nothing new"), + staleForeign: rule(-1, "A27"), + childrenForbidden: rule(-1, "A27: loading-class, no suspension"), + latest: rule(-1, "A27"), + isPending: rule(false, "A27: verdict-quiet"), + authoritative: rule( + -1, + "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") + } + } +]; diff --git a/packages/signals/tests/visibility-oracle.test.ts b/packages/signals/tests/visibility-oracle.test.ts index e8c831480..4cd25d485 100644 --- a/packages/signals/tests/visibility-oracle.test.ts +++ b/packages/signals/tests/visibility-oracle.test.ts @@ -1,645 +1,7 @@ /** - * Visibility oracle — node state × reader kind → what is served. - * - * The runtime decides what a read serves at eleven sites (read, readNodeFast, - * supersededRead, latestRead, gatedRead, laneReadsCommitted, heldFromStale, - * store nodeValue/serveDataKey/optimisticView). Every new visibility rule is - * threaded through them by hand. This file pins the OBSERVABLE outcome per - * cell so a rule that reaches one site and misses another shows up as a red - * cell instead of an audit finding. - * - * Rows: node states. Columns: reader kinds. Each expected cell cites the rule - * that fixes it (SPEC-ASYNC-SEMANTICS.md). Cells the spec does not fix are - * `observed(...)`: pinned as current behavior, listed by `reportUnspecified` - * so they can be brought to a ruling — changing one is a design decision. - * - * Reader kinds: - * - untracked x() with no observer - * - derivesFrom the value a mainline memo's PASS read (compute-side log) - * - published what a render effect over that memo published; HELD if nothing - * - preexisting what a render effect built before the state published after it (HELD if nothing) - * - staleForeign that same effect re-run by an unrelated mainline write - * - childrenForbidden createTrackedEffect reading x - * - latest latest(() => x()) - * - isPending isPending(() => x()) - * - authoritative until()'s predicate reading x (CONFIG_AUTHORITATIVE_READ) + * Visibility oracle runner — see visibility-oracle.states.ts for the states + * and the reader/cell vocabulary. */ -import { - action, - createMemo, - createOptimistic, - createRenderEffect, - createRoot, - createSignal, - flush, - 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 - * (and, when `initDownstream`, the downstream's first flight); `supersede()` - * runs an action that changes the question and guesses wrong, then lands the - * refetch with the differing truth while the action stays live. */ -function supersededGraph(initDownstream: boolean) { - const [value, setValue] = createSignal(0); - const fetchResolvers: Array<() => void> = []; - const flights: Array<() => void> = []; - let x!: () => unknown; - let setDouble!: (v: number) => void; - const dispose = createRoot(d => { - [x, setDouble] = createOptimistic(() => { - const v = value(); - return new Promise(r => fetchResolvers.push(() => r(v * 2))); - }); - 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())); - return { - x, - dispose, - setValue, - landFetch: () => fetchResolvers.shift()!(), - async prime() { - flush(); - fetchResolvers.shift()!(); - await settle(); - if (initDownstream) { - flights.shift()!(); - await settle(); - } - }, - async supersede() { - const run = action(function* () { - setValue(1); // new question → refetch - setDouble(3); // wrong guess; truth will be 2 - yield never(); - }); - run(); - flush(); - fetchResolvers.shift()!(); // own source lands 2 ≠ 3 while the action is live - await settle(); - } - }; -} - -// ── states ────────────────────────────────────────────────────────────────── -const STATES: State[] = [ - { - name: "committed (signal, no write)", - build(installStale) { - const [x] = createSignal(0); - installStale(x); - return { x, dispose() {} }; - }, - expect: { - untracked: rule(0, "baseline"), - derivesFrom: rule(0, "baseline"), - published: rule(0, "baseline"), - preexisting: rule(HELD, "baseline: nothing new to publish"), - staleForeign: rule(0, "baseline"), - childrenForbidden: rule(0, "baseline"), - latest: rule(0, "baseline"), - isPending: rule(false, "baseline"), - authoritative: rule(0, "baseline") - } - }, - { - name: "staged, ambient (set(1) before the flush)", - build(installStale) { - const [x, set] = createSignal(0); - installStale(x); - set(1); - return { x, dispose() {} }; - }, - expect: { - // Readers that flush observe the write land — trivially 1. - untracked: rule( - 0, - "A28: an unflushed write is not the committed value — an untracked read serves committed until the flush" - ), - derivesFrom: rule(1, "the flush carries the write"), - published: rule(1, "the flush carries the write"), - preexisting: rule(HELD, "A28: nothing is visible before the flush"), - staleForeign: rule(1, "the flush carries the write"), - childrenForbidden: rule(1, "the flush carries the write"), - latest: rule( - 0, - 'A28: latest() reads the flushed staged world — the pre-write answer until the flush that carries the write ("nothing is ever 30 while its derivations are still 20-shaped")' - ), - isPending: rule( - false, - "A28 (2): isPending is false for an unflushed write — nothing is observable yet to be pending from" - ), - authoritative: rule( - 1, - "A28 (4): until()'s predicate is evaluated inside the flush that carries the write, where the write is promoted — it sees 1" - ) - } - }, - { - name: "held by a live action (set(1) inside action, yield forever)", - build(installStale) { - const [x, set] = createSignal(0); - installStale(x); - const run = action(function* () { - set(1); - yield never(); - }); - run(); // imperative scope: the test body - flush(); - return { x, dispose() {} }; - }, - expect: { - untracked: rule( - 0, - "A19 (i): the observable value is the committed one while the write is held" - ), - derivesFrom: rule( - 1, - "A29: a tracked pass served the staged value derives from the transaction's world" - ), - published: rule( - HELD, - "A29 (born held): a memo created mainline during the hold derives from the transaction’s world and is staged into it; the render effect over it is replayed by the commit, publishing nothing before" - ), - preexisting: rule(HELD, "A19 (i): the held write is not on screen"), - staleForeign: rule( - 0, - "A15 reveal corollary / A26: a stale reader of a parallel transaction shows committed, no entanglement" - ), - childrenForbidden: rule( - 0, - "A32: children-forbidden readers see the frame; a held write is never visible to them" - ), - latest: rule(1, "A8/A11: the held value exists from the write; latest serves it"), - isPending: rule(true, "A19 (i)"), - authoritative: rule( - 1, - "A17 carve-out (2026-09-14): until() reads the landed world — the staged value" - ) - } - }, - { - name: "override active (createOptimistic set to 5 inside a live action)", - build(installStale) { - let x!: () => unknown; - let setO!: (v: number) => void; - const dispose = createRoot(d => { - [x, setO] = createOptimistic(0); - return d; - }); - installStale(x); - const run = action(function* () { - setO(5); - yield never(); - }); - run(); - flush(); - return { x, dispose }; - }, - expect: { - untracked: rule(5, "A17: the override is the displayed value"), - derivesFrom: rule(5, "A17: the override is the graph's value until its own source answers"), - published: observed( - 5, - "A17: direct read shows optimistic; whether a mainline memo over it publishes or holds is not stated" - ), - preexisting: rule( - 5, - "A17: the override is the displayed value; no downstream async, so the lane has nothing to wait for" - ), - staleForeign: rule(5, "A17: the applied frame shows the override"), - childrenForbidden: rule(5, "A32: the override is the frame; it shows through"), - latest: rule(5, "A17 / OL-R11: the override is the value on every channel"), - isPending: rule(false, "A24 (3): optimistic writes are verdict-inert"), - authoritative: rule(0, "A17: until()'s predicate never sees the caller's own optimism") - } - }, - { - name: "override, ambient (createOptimistic set to 5 outside any action, before the flush)", - build(installStale) { - let x!: () => unknown; - let setO!: (v: number) => void; - const dispose = createRoot(d => { - [x, setO] = createOptimistic(0); - return d; - }); - installStale(x); - setO(5); - return { x, dispose }; - }, - expect: { - untracked: rule( - 0, - "A28 (5): an optimistic write is a write — it becomes the active override at the flush that carries it; until then no reader sees it (supersedes OL-R2)" - ), - derivesFrom: rule( - 0, - "OL-R5: an ambient optimistic write reverts at the next flush (the flush the reader forces)" - ), - published: rule(0, "OL-R5"), - preexisting: rule(HELD, "A28: nothing is visible before the flush"), - staleForeign: rule(0, "OL-R5"), - childrenForbidden: rule(0, "OL-R5"), - latest: rule( - 0, - "A28 (5): not visible before the flush on any channel (supersedes OL-R11 pre-flush)" - ), - isPending: rule(false, "A24 (3)"), - authoritative: rule( - 0, - "A17 carve-out: never the caller's optimism (and the ambient override reverts at the flush the reader forces)" - ) - } - }, - { - name: "superseded (own source landed 2 ≠ override 3; initialized downstream flight keeps the action live)", - async build(installStale) { - const built = supersededGraph(true); - await built.prime(); - installStale(built.x); - await built.supersede(); - return built; - }, - expect: { - untracked: rule( - 3, - "A18 (c): untracked reads keep the override until the transaction commits" - ), - derivesFrom: rule(2, "A18 (b): tracked derivations recompute from the arrived value"), - published: rule( - HELD, - "A18 (c) / A29 (born held): a fresh mainline memo over the superseded node derives from the staged truth and is held with the transaction; the frame keeps the override" - ), - preexisting: rule(HELD, "A18 (c): the applied frame keeps the override until commit"), - staleForeign: rule( - 3, - "A18 (c) / A17 amended: a stale reader of another transaction displays the override" - ), - childrenForbidden: rule(3, "A32: the displayed override shows through, superseded or not"), - latest: rule(2, "A18 (d): latest returns the arrived value"), - isPending: rule(true, "A18 (d): pending iff the arrival differs from the override"), - authoritative: rule(2, "A17 carve-out: the authoritative reader sees the staged truth") - } - }, - { - name: "superseded before its first commit (the first landing was held by a downstream reveal that never landed)", - async build(installStale) { - const built = supersededGraph(false); - await built.prime(); - installStale(built.x); - await built.supersede(); - return built; - }, - expect: { - untracked: rule(3, "A18 (c)"), - derivesFrom: rule(2, "A18 (b)"), - published: rule(HELD, "A18 (c) / A29 (born held)"), - preexisting: observed( - HELD, - "this reader (created after the node initialized) holds. A render effect on the node created BEFORE its first landing published the truth (2) at the supersession in a side probe — while untracked reads still served 3 — so the hold here is shape-dependent; follow-up" - ), - staleForeign: observed(3, "displays the override, as in the initialized case"), - childrenForbidden: rule(3, "A32"), - latest: rule(2, "A18 (d)"), - isPending: rule( - true, - "A18 (d): pending iff the arrival differs from the displayed override — even before the node's first commit (the override is the observable value; A19 exception 1 does not apply)" - ), - authoritative: rule(2, "A17 carve-out") - } - }, - { - name: "pending own async (initialized memo refetching on a new question, flight up)", - async build(installStale) { - const [q, setQ] = createSignal(0); - const resolvers: Array<(v: number) => void> = []; - let x!: () => unknown; - const dispose = createRoot(d => { - const m = createMemo(() => { - const v = q(); - return new Promise(r => resolvers.push(r)).then(() => v * 10); - }); - x = m; - createRenderEffect(m, () => {}); - return d; - }); - flush(); - resolvers.shift()!(0); - await settle(); - installStale(x); - setQ(1); // new question; flight never lands - flush(); - return { x, dispose }; - }, - expect: { - untracked: rule( - 0, - "A19 (ii): the observable value is the committed one while the node's own async is in flight" - ), - derivesFrom: rule( - NOT_READY, - "A15: a fresh derivation suspends on the observed flight. (With no reader holding on the flight the pass reads the committed value instead and the reveal holds — the frame is identical; observation-driven transactions make the difference inherent, ruled 2026-09-14.)" - ), - published: rule(HELD, "A15"), - preexisting: rule(HELD, "A15: the reader that observed the flight holds"), - staleForeign: observed( - 0, - "A15 reveal corollary says a stale reader holds when the flight's inputs are published (#3305); here the new question was a mainline write and the reader shows the pre-flight committed value — does INPUTS_PUBLISHED cover a flight opened by the same batch?" - ), - childrenForbidden: rule(0, "A32: a pending node's committed value is the frame"), - latest: rule( - 0, - "A8: latest shows the stale value while the own fetch for a new question is in flight" - ), - isPending: rule(true, "A19 (ii) / A24 (2): a new question pends"), - authoritative: rule( - NOT_READY, - "A17 carve-out: nothing has landed for the new question; the predicate suspends like any reader" - ) - } - }, - { - name: "uninitialized (memo whose first flight never lands)", - build(installStale) { - let x!: () => unknown; - const dispose = createRoot(d => { - const m = createMemo(() => never()); - x = m; - createRenderEffect(m, () => {}); - return d; - }); - installStale(x); - flush(); - return { x, dispose }; - }, - expect: { - untracked: rule( - NOT_READY, - "A19 exception (1): loading, not pending — the NotReady propagates" - ), - derivesFrom: rule( - NOT_READY, - "A16 carve-out (B5a): tracked contexts propagate NotReady to loading boundaries" - ), - published: rule(HELD, "A19 exception (1)"), - preexisting: rule(HELD, "A19 exception (1)"), - staleForeign: rule( - HELD, - "A15 reveal corollary: an uninitialized node has no committed value to show" - ), - childrenForbidden: rule(NOT_READY, "A32: no frame to read yet"), - latest: rule( - NOT_READY, - "A7 (amended 2026-09-14): latest() of an uninitialized node throws in every scope, never undefined" - ), - isPending: rule( - false, - "A16: an unowned isPending never throws; A19 exception (1): loading is not pending" - ), - authoritative: rule(NOT_READY, "A17 carve-out: nothing landed") - } - }, - { - name: "loading window (createMemo with loadingValue -1, first flight up)", - build(installStale) { - let x!: () => unknown; - const dispose = createRoot(d => { - const m = createMemo(() => never(), { loadingValue: -1 }); - x = m; - createRenderEffect(m, () => {}); - return d; - }); - installStale(x); - flush(); - return { x, dispose }; - }, - expect: { - untracked: rule(-1, "A27: the loading value is commit #0"), - derivesFrom: rule(-1, "A27: never suspends readers during the first flight"), - published: rule(-1, "A27"), - preexisting: rule(HELD, "A27: the loading value was published before the state; nothing new"), - staleForeign: rule(-1, "A27"), - childrenForbidden: rule(-1, "A27: loading-class, no suspension"), - latest: rule(-1, "A27"), - isPending: rule(false, "A27: verdict-quiet"), - authoritative: rule( - -1, - "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") - } - } -]; - +import { runOracle } from "./visibility-oracle.harness.js"; +import { STATES } from "./visibility-oracle.states.js"; runOracle("visibility oracle (A7, A15, A16, A17, A18, A19, A24, A26, A27, A29, A32)", STATES);