Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
725 changes: 368 additions & 357 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H

**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.
**In-flush form (2026-09-16, posture matrix over the store states):** a memo + render effect created inside loading-boundary CONTENT over a held value publishes it too — the content pass entered the hold, the creation direct-committed — for signals and store leaves alike (`tests/posture-store-parity.test.ts`, S2, observed).
**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.

### O3. A render effect gated away from a never-landing flight keeps the source's write held — fixed
Expand All @@ -318,6 +319,12 @@ An error escaping every boundary permanently halts the system with `REACTIVITY_H
**Mechanism (2026-09-16):** the predicate was already right — `reporterBlocksSource` judged the re-run effect dead; nothing RE-JUDGED the parked transaction. `recompute`'s tail now treats a pending reporter recovering without its flight landing as the completion event it is: it wakes the transaction it reported to (`wokenTransitions`, the third site after disposal #3372 and boundary reset #3375; `wakeParked` when the reporter carries no stamp), skipped under that transaction's own flush, which judges the landing itself. 21 → 4 of 1,000 fuzzer cases.
**Remaining form (open):** gate and write in ONE flush (fuzzer case 21; case 79 is the multi-step variant). The effect is notified pending by the write — registering as the reporter — and dirtied by the gate in the same flush; the verdict runs after the pure phase, before effects, so the effect still looks live, the transaction parks, and the effect's run (which would prove it dead) is stashed with the transaction. The hold keeps the run that would release it; disposal releases (#3372). Pinned `it.fails`. Belongs with the effect-phase parking question (#3407: which world an effect's run belongs to when a mainline write and a held flight dirty it in one flush).

### O4. Adopted, unflushed — the signal's verdict channels see a write no flush has carried — violation, open

**Status:** **violation, recorded** 2026-09-16 — posture matrix over the store states beside the signal states (`staged, ambient × foreignAction`): the store leaf answers `latest` 0 / `isPending` false inside the adopting action's body; the signal answers 1 / true. Pinned `it.fails` (signal) beside the passing store twin in `tests/posture-store-parity.test.ts` (S1).
**Rule:** same-tick adoption is by design (O1); A28 (1)/(2) still govern visibility — nothing is visible before the flush that carries the write, on any channel. The store is right.
**Mechanism (current):** adoption stamps the signal with the transaction (`initTransition`'s pending-node loop); `unflushedValue` reads a stamped node with no `_flushedStaged` stash as a flushed held node and serves `_pendingValue`. The store's selection (`nodeValue` / `serveDataKey`, `flushedStaged`) does not take that path. One rule, two implementations — the fix is making "unflushed" mean the same thing at both sites (a node staged outside a flush and adopted before any flush is unflushed whatever its stamp).

## Superseded rules (kept verbatim)

Cited by tests and by A24's reasoning; the statements below are as they stood when superseded.
Expand Down
118 changes: 118 additions & 0 deletions packages/signals/tests/posture-store-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Posture matrix — store / signal PARITY pins (2026-09-16). Discovery source:
* visibility-oracle-posture.test.ts over the store oracle's states
* (visibility-oracle-store.states.ts) beside the signal states.
*
* S1 — VIOLATION (signal side), pinned it.fails: adopted, unflushed. Same-tick
* adoption is by design (spec O1); A28 still says nothing is visible
* before the flush. The store leaf answers latest 0 / isPending false
* inside the adopting action's body; the signal answers 1 / true.
* S2 — OBSERVED, both sides agree: a memo + render effect created inside
* loading-boundary content over a held value publishes the held value
* (the in-flush form of spec O2 — creation under a transaction escapes;
* the content pass entered the hold, the creation direct-committed).
* Mainline creation over the same value is born held (A29).
*
* (A first cut also reported the projection's seed leaking as a value inside
* boundary content, and `isPending` false / override invisible behind a
* fallback. All three were a runner artifact — the boundary content re-ran
* after the entanglement probe resolved the flight and overwrote the
* captured read. The runner now freezes the served value before probing.)
*/
import { describe, expect, it } from "vitest";
import {
action,
createLoadingBoundary,
createMemo,
createRenderEffect,
createRoot,
createSignal,
createStore,
flush,
isPending,
latest
} from "../src/index.js";

const never = () => new Promise<never>(() => {});

describe("S1 — adopted, unflushed: verdict channels inside the adopting action (A28 (1)/(2)) — signal vs store", () => {
// Adoption stamps the signal with the transaction, and `unflushedValue`
// reads a stamped node with no stash as "flushed, held" — so latest()
// serves the staged 1 and isPending answers true for a write no flush has
// carried. The store leaf's selection (nodeValue / serveDataKey) does not
// take that path and answers by A28. The store is right.
it("store leaf: latest / isPending inside the adopting action see nothing before the flush", () => {
const [s, setS] = createStore({ n: 0 });
setS(d => {
d.n = 1;
});
let seen: [number, boolean] | undefined;
action(function* () {
seen = [latest(() => s.n), isPending(() => s.n)];
yield never();
})();
expect(seen).toEqual([0, false]);
});
it.fails(
"signal: latest / isPending inside the adopting action see nothing before the flush (A28) — VIOLATION: 1 / true",
() => {
const [x, setX] = createSignal(0);
setX(1);
let seen: [number, boolean] | undefined;
action(function* () {
seen = [latest(x), isPending(x)];
yield never();
})();
expect(seen).toEqual([0, false]);
}
);
});

describe("S2 — creation in boundary content over a held value publishes it (OBSERVED, spec O2 in-flush form; signal and store agree)", () => {
/** Build a memo + render effect over `read` inside a loading boundary whose
* content is pending on a sibling flight (the fallback shows). */
function behindFallback(read: () => unknown) {
const published: unknown[] = [];
createRoot(() => {
const blocker = createMemo(() => never());
const b = createLoadingBoundary(
() => {
const m = createMemo(read);
createRenderEffect(m, v => {
published.push(v);
});
blocker();
return "content";
},
() => "fallback"
);
createRenderEffect(b, () => {});
});
flush();
return published;
}
it("signal held by a live action: the memo publishes the held 1 while x() reads 0", () => {
const [x, setX] = createSignal(0);
action(function* () {
setX(1);
yield never();
})();
flush();
expect(x()).toBe(0);
expect(behindFallback(x)).toEqual([1]);
expect(x()).toBe(0);
});
it("store leaf held by a live action: the memo publishes the held 1 while s.n reads 0", () => {
const [s, setS] = createStore({ n: 0 });
action(function* () {
setS(d => {
d.n = 1;
});
yield never();
})();
flush();
expect(s.n).toBe(0);
expect(behindFallback(() => s.n)).toEqual([1]);
expect(s.n).toBe(0);
});
});
10 changes: 8 additions & 2 deletions packages/signals/tests/visibility-oracle-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
type State
} from "./visibility-oracle.harness.js";
import { STATES as ORACLE_STATES } from "./visibility-oracle.states.js";
import { STATES as STORE_STATES } from "./visibility-oracle-store.states.js";

/** Matrix-only states (no reader-kind expectations): shapes whose question is
* the READER's hold rather than the served value. */
Expand Down Expand Up @@ -87,7 +88,7 @@ const MATRIX_STATES: State[] = [
expect: {} as State["expect"]
}
];
const STATES = [...ORACLE_STATES, ...MATRIX_STATES];
const STATES = [...ORACLE_STATES, ...STORE_STATES, ...MATRIX_STATES];

const POSTURES = [
"mainline",
Expand Down Expand Up @@ -257,6 +258,11 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise<Row
served = log.length ? log[log.length - 1] : HELD;
passValue = pass.length ? pass[pass.length - 1] : HELD;
}
// Freeze the served value HERE: the closure readers (untracked / latest /
// isPending) assign `served` inside the posture's own compute (boundary
// content), which may re-run after the probes below change the world — a
// later pass must not overwrite what the reader saw at build time.
const servedAtBuild = served;
let afterGate: string | undefined;
if (gated) {
setShow(false);
Expand All @@ -282,7 +288,7 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise<Row
state: state.name,
posture,
reader,
served,
served: servedAtBuild,
passValue,
afterSourceRelease,
foreignStillHeld,
Expand Down
Loading
Loading