diff --git a/eval/harness/build.mjs b/eval/harness/build.mjs
index e39698d..3471046 100644
--- a/eval/harness/build.mjs
+++ b/eval/harness/build.mjs
@@ -6,12 +6,14 @@
// vanilla arm: `src/main.ts` default-exports `mount(root: HTMLElement)`, called once with the `#app` container
// foldkit arm: `src/main.ts` exports Model, init, update and view; booted with Runtime.makeApplication and
// Runtime.run, as Foldkit's own `src/entry.ts` does
+// contract arm: an Exact app, built by its own `exact.mjs web-build` (harness/contract.mjs)
// The app's API is `src/api.js` over `src/flights.js`: every call is a flight the test lands or fails.
// Usage: node harness/build.mjs
[rx|react] (the arm defaults to what `/arm` says)
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
+import { buildContract } from "./contract.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const evalDir = path.resolve(here, "..");
@@ -35,6 +37,7 @@ async function solidPlugin() {
export async function build(dir, arm = armOf(dir)) {
dir = realpathSync(path.resolve(dir));
+ if (arm === "contract") return buildContract(dir);
const work = path.join(dir, ".build");
rmSync(work, { recursive: true, force: true }); mkdirSync(work, { recursive: true });
// the app's bare imports resolve from the harness's packages
diff --git a/eval/harness/check.mjs b/eval/harness/check.mjs
index 45328c3..b7c92fe 100644
--- a/eval/harness/check.mjs
+++ b/eval/harness/check.mjs
@@ -4,12 +4,14 @@
// solid: tsc --noEmit -p . and eslint (eslint-plugin-solid's v2 config)
// vanilla: tsc --noEmit -p . (strict)
// foldkit: tsc --noEmit -p . with Foldkit's template tsconfig (its lint, oxlint + effect-tsgo, is not on npm)
+// contract: `exact.mjs contract build app.contract`, which also type-checks app.ts
// Usage: node harness/check.mjs
import { execFileSync } from "node:child_process";
import { existsSync, symlinkSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { RXC, armOf } from "./build.mjs";
+import { checkContract } from "./contract.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const evalDir = path.resolve(here, "..");
@@ -21,6 +23,7 @@ const run = (cmd, args, cwd) => {
export function check(dir) {
dir = path.resolve(dir);
+ if (armOf(dir) === "contract") return checkContract(dir);
if (!existsSync(path.join(dir, "node_modules"))) symlinkSync(path.join(evalDir, "node_modules"), path.join(dir, "node_modules"));
if (armOf(dir) === "rx") return run(RXC, ["check", "src/main.rx"], dir);
const bin = n => path.join(evalDir, "node_modules/.bin", n);
diff --git a/eval/harness/contract.mjs b/eval/harness/contract.mjs
new file mode 100644
index 0000000..8a89024
--- /dev/null
+++ b/eval/harness/contract.mjs
@@ -0,0 +1,50 @@
+// The contract arm: an Exact app (https://github.com/expo/exact), `app.contract` for the interface and `app.ts` for its
+// data module, which imports the task's `src/api.js`. A desk is the directory `exact new` makes, with the eval's files
+// beside it. EXACT2 names the Exact checkout the desk is made from; the desk's own `exact.mjs` keeps that path.
+//
+// The build is Exact's web JS target, a release build (`exact.mjs web-build --out`). The data module runs in the page's
+// realm, so `src/flights.js` puts `__flights` on the page as it does in every other arm, and the driver is unchanged.
+// BUN names the Bun to run `exact.mjs` with (default: `bun` on PATH).
+import { execFileSync, spawnSync } from "node:child_process";
+import { cpSync, existsSync, readFileSync, rmSync } from "node:fs";
+import path from "node:path";
+
+const bun = () => process.env.BUN ?? "bun";
+const exactEnv = () => ({ ...process.env, EXACT_FEEDBACK: "0", NO_COLOR: "1" });
+
+export function exact2() {
+ const dir = process.env.EXACT2;
+ if (!dir || !existsSync(path.join(dir, "scripts/exact.mjs"))) throw new Error("EXACT2 must name an Exact checkout (its scripts/exact.mjs)");
+ return path.resolve(dir);
+}
+
+/** `/dist`, built by the desk's `exact.mjs` (Exact keeps a build inside an app in `dist`, which its source scans
+ * skip); a compile error is the `compile` stage's output */
+export function buildContract(dir) {
+ const dist = path.join(dir, "dist");
+ const r = spawnSync(bun(), ["exact.mjs", "web-build", "--out", dist], { cwd: dir, env: exactEnv(), encoding: "utf8", maxBuffer: 64 << 20 });
+ if (r.status !== 0) return { ok: false, stage: "compile", output: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
+ return { ok: true, dist };
+}
+
+/** the compiler's diagnostics, warnings included, as the agent's `./check` shows them */
+export function checkContract(dir) {
+ const r = spawnSync(bun(), ["exact.mjs", "contract", "build", "app.contract"], { cwd: dir, env: exactEnv(), encoding: "utf8", maxBuffer: 64 << 20 });
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() || "ok" };
+}
+
+/** `exact new `, then the scaffold's own files go in beside it. The placeholder test is removed: `./test` is the
+ * desk's test command, as in every arm. */
+export function newContractDesk(dir) {
+ if (existsSync(dir)) throw new Error(`${dir} exists; a contract desk starts from \`exact new\``);
+ execFileSync(bun(), [path.join(exact2(), "scripts/exact.mjs"), "new", dir], { env: exactEnv(), stdio: "pipe" });
+ rmSync(path.join(dir, "app.test.contract"), { force: true });
+}
+
+/** Exact's one required guide, for the prompt's inline docs */
+export const startHere = () => readFileSync(path.join(exact2(), "docs/start-here.md"), "utf8");
+
+/** copies a reference app's files over a desk's placeholders */
+export function copyContractReference(from, dir) {
+ for (const f of ["app.contract", "app.ts"]) cpSync(path.join(from, f), path.join(dir, f));
+}
diff --git a/eval/harness/driver.mjs b/eval/harness/driver.mjs
index 47ec5ea..7d0f062 100644
--- a/eval/harness/driver.mjs
+++ b/eval/harness/driver.mjs
@@ -23,7 +23,13 @@ export async function serve(dist) {
}
/** CHROMIUM=/path/to/chromium drives a system browser (NixOS and the like, where Playwright's download does not run). */
-export async function launch() { return chromium.launch(process.env.CHROMIUM ? { executablePath: process.env.CHROMIUM } : {}); }
+// Without CHROMIUM, a Playwright whose own browser is missing falls back to a preinstalled one (/opt/pw-browsers/chromium,
+// as in cloud containers), so an agent's ./test runs there too.
+export async function launch() {
+ if (process.env.CHROMIUM) return chromium.launch({ executablePath: process.env.CHROMIUM });
+ try { return await chromium.launch(); }
+ catch (e) { const alt = "/opt/pw-browsers/chromium"; if (existsSync(alt)) return chromium.launch({ executablePath: alt }); throw e; }
+}
/** One test: a fresh page on the app, `run(app)` with the helpers below. Returns { ok, error }. */
export async function runTest(browser, url, test) {
diff --git a/eval/harness/prompt-arms.json b/eval/harness/prompt-arms.json
index 2f18e30..1dab00d 100644
--- a/eval/harness/prompt-arms.json
+++ b/eval/harness/prompt-arms.json
@@ -58,5 +58,17 @@
"LANG_RULE": "plain TypeScript and the browser DOM, with no UI framework or library: `src/main.ts` must default-export `function mount(root: HTMLElement): void`, which the harness calls once with the page's `#app` element. You may add files under `src/`. No packages are installed for you to use.",
"API_TYPES": "`src/api.d.ts`",
"CHECKER": "the TypeScript compiler (strict)"
+ },
+ "contract": {
+ "DOCS_RULE": ", except that you may read Exact's documentation in {{EXACT2}}/docs and look names up with `bun exact.mjs contract vocab ` and `bun exact.mjs docs `",
+ "LANG_RULE": "Exact, with Contract for the interface and TypeScript for its data module. This directory is an Exact app that `exact new` made from the Exact checkout at {{EXACT2}}; the tests run its web build in a browser. Exact's guide is `{{EXACT2}}/docs/start-here.md`, the only required reading; the rest of `{{EXACT2}}/docs` is lookup. `app.contract` is the interface and `app.ts` its data module, whose sources call the backend: import it with `import * as api from './src/api.js'`. Give an element a test id with `testId=\"…\"`. The only commands that build, test, drive or serve the app are `./check` and `./test` below: do not run the `bun exact.mjs` verbs that do (`web`, `web-build`, `serve`, `test`, `verify`, `agent`, or a platform's).",
+ "API_TYPES": "`src/api.d.ts`",
+ "CHECKER": "the Contract compiler (`bun exact.mjs contract build app.contract`), which also type-checks `app.ts`"
+ },
+ "contract-inline": {
+ "DOCS_RULE": ", except that you may read Exact's documentation in {{EXACT2}}/docs and look names up with `bun exact.mjs contract vocab ` and `bun exact.mjs docs `",
+ "LANG_RULE": "Exact, with Contract for the interface and TypeScript for its data module. This directory is an Exact app that `exact new` made from the Exact checkout at {{EXACT2}}; the tests run its web build in a browser. Exact's guide, `docs/start-here.md`, is at the end of this message: it is the only required reading, and the rest of `{{EXACT2}}/docs` is lookup. `app.contract` is the interface and `app.ts` its data module, whose sources call the backend: import it with `import * as api from './src/api.js'`. Give an element a test id with `testId=\"…\"`. The only commands that build, test, drive or serve the app are `./check` and `./test` below: do not run the `bun exact.mjs` verbs that do (`web`, `web-build`, `serve`, `test`, `verify`, `agent`, or a platform's).",
+ "API_TYPES": "`src/api.d.ts`",
+ "CHECKER": "the Contract compiler (`bun exact.mjs contract build app.contract`), which also type-checks `app.ts`"
}
}
diff --git a/eval/harness/scaffold.mjs b/eval/harness/scaffold.mjs
index ffd3211..b8a78c2 100644
--- a/eval/harness/scaffold.mjs
+++ b/eval/harness/scaffold.mjs
@@ -18,6 +18,7 @@
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
+import { copyContractReference, exact2, newContractDesk, startHere } from "./contract.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const evalDir = path.resolve(here, "..");
@@ -30,6 +31,7 @@ const statelyDocs = process.env.STATELY_DOCS ?? path.resolve(evalDir, "../../sta
export function scaffold(task, arm, dir, { reference = false, brief = null, base = null, inline = false } = {}) {
const t = path.join(evalDir, "tasks", task);
dir = path.resolve(dir);
+ if (arm === "contract") newContractDesk(dir);
mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "tests"), { recursive: true });
writeFileSync(path.join(dir, "arm"), `${arm}\n`);
cpSync(path.join(t, brief ? `brief-${brief}.md` : "brief.md"), path.join(dir, "BRIEF.md"));
@@ -69,7 +71,8 @@ export function scaffold(task, arm, dir, { reference = false, brief = null, base
}
// Solid's own docs ship in its package: the agent can read them there before its first build
if (arm === "solid" && !existsSync(path.join(dir, "node_modules"))) symlinkSync(path.join(evalDir, "node_modules"), path.join(dir, "node_modules"));
- if (reference) cpSync(path.join(evalDir, "reference", task, arm), path.join(dir, "src"), { recursive: true });
+ if (reference && arm === "contract") copyContractReference(path.join(evalDir, "reference", task, arm), dir);
+ else if (reference) cpSync(path.join(evalDir, "reference", task, arm), path.join(dir, "src"), { recursive: true });
if (base) {
cpSync(path.join(t, "BASE.md"), path.join(dir, "BASE.md"));
// everything the earlier app had (a chain step's base is the previous agent's src/, which may hold more than
@@ -85,11 +88,13 @@ export function scaffold(task, arm, dir, { reference = false, brief = null, base
export function prompt(arm, dir, { plain = false, change = false, inline = false } = {}) {
const docs = inline && arm !== "react";
const fill = { ...JSON.parse(readFileSync(path.join(here, "prompt-arms.json"), "utf8"))[docs ? `${arm}-inline` : arm] };
+ if (arm === "contract") fill.EXACT2 = exact2();
fill.BACKEND_NOTE = plain ? "" : " whose answers the tests control";
let text = readFileSync(path.join(here, change ? "prompt-change.md" : "prompt.md"), "utf8");
for (let i = 0; i < 2; i++) for (const k of [...Object.keys(fill), "DIR"]) text = text.replaceAll(`{{${k}}}`, k === "DIR" ? dir : fill[k]);
if (docs && arm === "rx") for (const f of ["language.md", "async.md", "reference.md"]) text += `\n---\n\n${readFileSync(path.join(evalDir, "../guide", f), "utf8")}`;
if (docs && arm === "foldkit") text += `\n---\n\n${readFileSync(path.join(foldkitRepo, "packages/create-foldkit-app/templates/base/FOLDKIT.md"), "utf8")}`;
+ if (docs && arm === "contract") text += `\n---\n\n${startHere()}`;
if (docs && arm === "xstate") text += `\n---\n\n${readFileSync(path.join(statelyDocs, "content/docs/cheatsheet.mdx"), "utf8").replace(/^---\n[\s\S]*?\n---\n+/, "")}\n---\n\n${readFileSync(path.join(evalDir, "node_modules/@xstate/react/README.md"), "utf8")}`;
if (docs && arm === "solid") text += `\n---\n\n${readFileSync(path.join(evalDir, "node_modules/solid-js/skills/solid-2.0/SKILL.md"), "utf8").replace(/^---\n[\s\S]*?\n---\n+/, "")}`;
return text;
diff --git a/eval/harness/score.mjs b/eval/harness/score.mjs
index 02f5ec9..cd97569 100644
--- a/eval/harness/score.mjs
+++ b/eval/harness/score.mjs
@@ -24,7 +24,7 @@ const round5 = new Set(["desk", "labels", "related", "count", "chain1", "chain2"
const rows = [];
for (const name of readdirSync(runs).sort()) {
const dir = path.join(runs, name);
- const m = name.match(/^(\w+)-(rx|react|solid|foldkit|vanilla)-(\d+)$/);
+ const m = name.match(/^(\w+)-(rx|react|solid|foldkit|vanilla|contract)-(\d+)$/);
if (!m) continue;
const [, task, arm, n] = m;
const alongside = round5.has(task) ? round5Alongside : round4Alongside;
diff --git a/eval/reference/desk9/contract/README.md b/eval/reference/desk9/contract/README.md
new file mode 100644
index 0000000..e5b419a
--- /dev/null
+++ b/eval/reference/desk9/contract/README.md
@@ -0,0 +1,11 @@
+# desk9 in Contract (Exact)
+
+Exact team's hand-written reference, from expo/exact `bench/reactivescript/harness.patch` (main 8c742e6a, 2026-11-13).
+MIT License, Copyright (c) 2026 650 Industries, Inc. (Expo).
+
+The `contract` arm (`harness/contract.mjs` and its hooks in build, check, scaffold, prompt-arms and score) comes from
+the same patch, ported to this harness. It needs `EXACT2` (an Exact checkout, `bun install` and `cargo fetch` run in
+it; the web build compiles Exact's Rust workspace once) and Bun on PATH.
+
+Graded here on 2026-11-13 (`harness/grade.sh`): 77/77 hidden, 38/38 overlap, 0/20 fuzz, 0 defects, as in Exact's own
+harness.
diff --git a/eval/reference/desk9/contract/app.contract b/eval/reference/desk9/contract/app.contract
new file mode 100644
index 0000000..2c31b03
--- /dev/null
+++ b/eval/reference/desk9/contract/app.contract
@@ -0,0 +1,401 @@
+// The issue desk: issues, help and settings behind three tabs.
+//
+// Every resource is the root's, so a page's state lives here too, and opening a page resets it. A resource asks only
+// while its page is open and it has something to ask (`when`), and asks again when that turns true; "loading" is
+// `not current(…)`, so a refresh keeps what shows. Writes show at once through app.ts's overlay. Each kind of write
+// is a `parallel` mutation: every send is asked at once, and each shows until its own reply.
+
+shape Row
+ id: number
+ title: string
+ assignee: option
+
+shape IssueList
+ loaded: bool
+ rows: Row[]
+
+shape Detail
+ found: bool
+ title: string
+ body: string
+
+shape Comment
+ author: string
+ text: string
+ sending: bool
+
+shape Hit
+ id: number
+ name: string
+
+shape Item
+ found: bool
+ name: string
+ blurb: string
+
+shape Settings
+ name: string
+ theme: string
+ density: string
+ saving: bool
+
+shape Assigned
+ id: number
+ assignee: string
+
+shape Renamed
+ id: number
+ title: string
+
+shape Closed
+ id: number
+
+shape Posted
+ author: string
+ text: string
+
+shape Saved
+ name: string
+ theme: string
+ density: string
+
+fn rowText(r: Row): string = `#${r.id} ${r.title} (${match r.assignee { case some(a) => a, case none => "unassigned" }})`
+fn message(f: option): string = match f { case some(x) => x.message, case none => "" }
+
+component Desk
+ state page = "issues"
+
+ // The issues page. The tab's count reads the open list, which keeps its last answer while the closed list shows,
+ // while it loads again, and while another page is open.
+ state filter = "open"
+ state user = "guest"
+ state selected = 0
+ state titleDraft = ""
+ state commentDraft = ""
+ state labelDraft = ""
+
+ // The help page.
+ state query = ""
+ state hit = 0
+
+ derive onIssues = page == "issues"
+ derive chosen = onIssues and selected != 0
+
+ resource openIssues = fetchIssues("open") when onIssues and filter == "open" as shape IssueList else empty()
+ resource closedIssues = fetchIssues("closed") when onIssues and filter == "closed" as shape IssueList else empty()
+ resource detail = fetchIssue(selected) when chosen as shape Detail else empty()
+ resource comments = fetchComments(selected) when chosen as shape Comment[] else empty()
+ resource labels = fetchLabels(selected) when chosen as shape string[] else empty()
+ resource related = search(detail.title) when chosen and current(detail) and detail.found as shape Hit[] else empty()
+ resource results = search(query) when page == "help" and query.length >= 2 as shape Hit[] else empty()
+ resource item = fetchItem(hit) when page == "help" and hit != 0 as shape Item else empty()
+ resource settings = loadSettings() when page == "settings" as shape Settings else empty()
+
+ mutation assigned as shape Assigned parallel refreshes openIssues, closedIssues
+ mutation renamed as shape Renamed parallel refreshes openIssues, closedIssues, detail
+ mutation closed as shape Closed parallel refreshes openIssues, closedIssues
+ mutation rowAssigned as shape Assigned parallel refreshes openIssues, closedIssues
+ mutation rowClosed as shape Closed parallel refreshes openIssues, closedIssues
+ mutation posted as shape Posted parallel refreshes comments
+ mutation labelAdded as shape string[] parallel refreshes labels
+ mutation labelRemoved as shape string[] parallel refreshes labels
+ mutation saved as shape Saved parallel refreshes settings
+
+ derive open = filter == "open"
+ derive rows = open ? openIssues.rows : closedIssues.rows
+ derive listLoading = open ? not current(openIssues) : not current(closedIssues)
+ derive listError = open ? message(failure(openIssues)) : message(failure(closedIssues))
+ derive issuesTab = openIssues.loaded ? `Issues (${openIssues.rows.length})` : "Issues"
+ derive rowError = message(failure(rowAssigned)) != "" ? message(failure(rowAssigned)) : message(failure(rowClosed))
+ derive labelError = message(failure(labelAdded)) != "" ? message(failure(labelAdded)) : message(failure(labelRemoved))
+
+ action openIssuesPage
+ if page != "issues"
+ page = "issues"
+ filter = "open"
+ user = "guest"
+ selected = 0
+ titleDraft = ""
+ commentDraft = ""
+ labelDraft = ""
+ assigned = none
+ renamed = none
+ closed = none
+ posted = none
+ labelAdded = none
+ labelRemoved = none
+ rowAssigned = none
+ rowClosed = none
+ action openHelpPage
+ if page != "help"
+ page = "help"
+ query = ""
+ hit = 0
+ action openSettingsPage
+ if page != "settings"
+ page = "settings"
+ saved = none
+
+ action setUser(v: string)
+ user = v
+ action showOpen
+ filter = "open"
+ action showClosed
+ filter = "closed"
+ action select(id: number)
+ selected = id
+ titleDraft = ""
+ commentDraft = ""
+ labelDraft = ""
+ assigned = none
+ renamed = none
+ closed = none
+ posted = none
+ labelAdded = none
+ labelRemoved = none
+ action retry
+ refresh detail
+
+ action assign
+ send assigned = assign(selected, user)
+ action setTitle(v: string)
+ titleDraft = v
+ action rename
+ if titleDraft != ""
+ send renamed = renameIssue(selected, titleDraft)
+ action close
+ send closed = closeIssue(selected)
+ action rowAssign(id: number)
+ send rowAssigned = assign(id, user)
+ rowClosed = none
+ action rowClose(id: number)
+ send rowClosed = closeIssue(id)
+ rowAssigned = none
+
+ action setComment(v: string)
+ commentDraft = v
+ action post
+ if commentDraft != ""
+ send posted = addComment(selected, user, commentDraft)
+ commentDraft = ""
+ action setLabel(v: string)
+ labelDraft = v
+ action addLabel
+ if labelDraft != "" and not labels.includes(labelDraft)
+ send labelAdded = addLabel(selected, labelDraft)
+ labelRemoved = none
+ labelDraft = ""
+ action removeLabel(label: string)
+ send labelRemoved = removeLabel(selected, label)
+ labelAdded = none
+
+ action openRelated(id: number)
+ let title = detail.title
+ page = "help"
+ query = title
+ hit = id
+
+ action setQuery(v: string)
+ query = v
+ hit = 0
+ action pick(id: number)
+ hit = id
+ action retryItem
+ refresh item
+
+ action retryLoad
+ refresh settings
+ action save(name: string, theme: string, density: string)
+ send saved = saveSettings(name, theme, density)
+
+ view
+ column gap=12 padding=12
+ row gap=8
+ button press=openIssuesPage testId="tab-issues"
+ text issuesTab
+ button press=openHelpPage testId="tab-help"
+ text "Help"
+ button press=openSettingsPage testId="tab-settings"
+ text "Settings"
+ if page == "issues"
+ column gap=8
+ input value=user input=setUser aria-label="Current user" testId="user"
+ row gap=8
+ button press=showOpen testId="filter-open"
+ text "open"
+ button press=showClosed testId="filter-closed"
+ text "closed"
+ column gap=4 testId="list"
+ if listError != ""
+ text `list error: ${listError}`
+ else if listLoading
+ text `loading ${filter} issues`
+ else
+ for r in rows key=r.id
+ row gap=8
+ button press=select(r.id) testId="issue"
+ text rowText(r)
+ button press=rowAssign(r.id) testId="row-assign"
+ text `assign to ${user}`
+ if open
+ button press=rowClose(r.id) testId="row-close"
+ text "close"
+ if rowError != ""
+ text `row error: ${rowError}` testId="row-error"
+ column gap=6 testId="detail"
+ if selected == 0
+ text "select an issue"
+ else
+ if failed(detail)
+ text `detail error: ${message(failure(detail))}`
+ else if not current(detail)
+ text `loading #${selected}`
+ else if not detail.found
+ text `detail error: issue #${selected} not found`
+ else
+ text `#${selected} ${detail.title}` role="heading" aria-level=2
+ text detail.body
+ button press=retry testId="retry"
+ text "retry"
+ button press=assign testId="assign"
+ text `assign to ${user}`
+ if message(failure(assigned)) != ""
+ text message(failure(assigned))
+ row gap=8
+ input value=titleDraft input=setTitle aria-label="New title" testId="title-input"
+ button press=rename testId="rename"
+ text "rename"
+ if message(failure(renamed)) != ""
+ text `rename error: ${message(failure(renamed))}`
+ button press=close testId="close"
+ text "close"
+ if message(failure(closed)) != ""
+ text `close error: ${message(failure(closed))}`
+ column gap=4 testId="comments"
+ if failed(comments)
+ text `comments error: ${message(failure(comments))}`
+ else if not current(comments)
+ text "loading comments"
+ else
+ for c, i in comments key=i
+ text `${c.author}: ${c.text}${c.sending ? " (sending…)" : ""}` testId="comment"
+ row gap=8
+ input value=commentDraft input=setComment aria-label="Comment" testId="comment-input"
+ button press=post testId="post"
+ text "post"
+ if message(failure(posted)) != ""
+ text `post error: ${message(failure(posted))}`
+ column gap=4 testId="labels"
+ if failed(labels)
+ text `labels error: ${message(failure(labels))}`
+ else if not current(labels)
+ text "loading labels"
+ else
+ for l in labels key=l
+ row gap=8
+ text l testId="label"
+ button press=removeLabel(l) testId="remove-label"
+ text `remove ${l}`
+ row gap=8
+ input value=labelDraft input=setLabel aria-label="Label" testId="label-input"
+ button press=addLabel testId="add-label"
+ text "add label"
+ if labelError != ""
+ text `label error: ${labelError}`
+ if current(detail) and detail.found
+ column gap=4 testId="related"
+ if failed(related)
+ text `related help failed: ${message(failure(related))}`
+ else if not current(related)
+ text "finding related help"
+ else if related.length == 0
+ text "no related help"
+ else
+ for h in related.slice(0, 3) key=h.id
+ button press=openRelated(h.id) testId="related-hit"
+ text h.name
+ if page == "help"
+ column gap=8
+ input value=query input=setQuery aria-label="Search help" testId="query"
+ column gap=4 testId="results"
+ if query.length == 1
+ text `too short: ${query}`
+ else if query.length >= 2
+ if failed(results)
+ text `search failed: ${message(failure(results))}`
+ else
+ if current(results)
+ text `${results.length} results for ${query}`
+ else
+ text "searching…"
+ for h in results key=h.id
+ button press=pick(h.id) testId="hit"
+ text `${h.id == hit ? "> " : ""}${h.name}`
+ column gap=4 testId="item"
+ if hit == 0
+ text "pick a result"
+ else if failed(item)
+ text `failed: ${message(failure(item))}`
+ button press=retryItem testId="retry-item"
+ text "retry"
+ else if not current(item)
+ text "loading…"
+ else if not item.found
+ text `item ${hit} not found`
+ else
+ text item.name role="heading" aria-level=2
+ text item.blurb
+ if page == "settings"
+ column gap=8
+ if not current(settings)
+ column gap=4 testId="load"
+ if failed(settings)
+ text `Could not load settings: ${message(failure(settings))}`
+ button press=retryLoad testId="retry-load"
+ text "retry"
+ else
+ text "Loading settings…"
+ else
+ text `Saved: ${settings.name} · ${settings.theme} · ${settings.density}${settings.saving ? " (saving…)" : ""}` testId="saved"
+ if message(failure(saved)) != ""
+ text `Save failed: ${message(failure(saved))}` testId="save-error"
+ SettingsForm(name0=settings.name, theme0=settings.theme, density0=settings.density, save=save)
+
+// The draft: filled from the settings loaded when it appears, then the person's own. A save never changes it.
+component SettingsForm
+ props
+ name0: string
+ theme0: string
+ density0: string
+ save: action
+ state name = name0
+ state theme = theme0
+ state density = density0
+ derive nameError = name == "" ? "Display name is required" : name.length > 20 ? `Display name must be at most 20 characters, not ${name.length}` : ""
+ action setName(v: string)
+ name = v
+ action light
+ theme = "light"
+ action dark
+ theme = "dark"
+ action toggleDensity
+ density = density == "compact" ? "comfortable" : "compact"
+ action trySave
+ if nameError == ""
+ save(name, theme, density)
+ view
+ column gap=8
+ input value=name input=setName aria-label="Display name" testId="name"
+ text `${name.length}/20` testId="name-count"
+ if nameError != ""
+ text nameError testId="name-error"
+ row gap=8
+ button press=light testId="theme-light"
+ text "light"
+ button press=dark testId="theme-dark"
+ text "dark"
+ button press=toggleDensity testId="density-toggle"
+ text density
+ text `Preview: ${theme} / ${density}` testId="preview"
+ button press=trySave buttonStyle="filled" testId="save"
+ text "Save"
diff --git a/eval/reference/desk9/contract/app.ts b/eval/reference/desk9/contract/app.ts
new file mode 100644
index 0000000..a23f67f
--- /dev/null
+++ b/eval/reference/desk9/contract/app.ts
@@ -0,0 +1,108 @@
+// The desk's data module: each source calls the backend in src/api.js and projects its answer onto the declared shape.
+// The overlay shows writes still out over the answers they change.
+import type { Answer, Overlay, Result, Sources } from './app.contract.d.ts';
+import * as api from './src/api.js';
+
+export const grants = '';
+
+type IssueList = Result<'fetchIssues'>;
+type Detail = Result<'fetchIssue'>;
+type Comments = Result<'fetchComments'>;
+type Labels = Result<'fetchLabels'>;
+type Settings = Result<'loadSettings'>;
+
+const sources: Sources = {
+ fetchIssues: async ([filter]): Promise => {
+ const rows = await api.fetchIssues(filter as 'open' | 'closed');
+ return { loaded: true, rows: rows.map((r) => ({ id: r.id, title: r.title, assignee: r.assignee })) };
+ },
+ fetchIssue: async ([id]): Promise => {
+ const d = await api.fetchIssue(id);
+ return d ? { found: true, title: d.title, body: d.body } : { found: false, title: '', body: '' };
+ },
+ fetchComments: async ([id]): Promise =>
+ (await api.fetchComments(id)).map((c) => ({ author: c.author, text: c.text, sending: false })),
+ fetchLabels: async ([id]): Promise => api.fetchLabels(id),
+ search: async ([q]) => (await api.search(q)).map((h) => ({ id: h.id, name: h.name })),
+ fetchItem: async ([id]) => {
+ const item = await api.fetchItem(id);
+ return item ? { found: true, name: item.name, blurb: item.blurb } : { found: false, name: '', blurb: '' };
+ },
+ loadSettings: async (): Promise => {
+ const s = await api.loadSettings();
+ return { name: s.name, theme: s.theme, density: s.density, saving: false };
+ },
+ assign: async ([id, user]) => {
+ const r = await api.assign(id, user);
+ return { id: r.id, assignee: r.assignee };
+ },
+ renameIssue: async ([id, title]) => {
+ const r = await api.renameIssue(id, title);
+ return { id: r.id, title: r.title };
+ },
+ closeIssue: async ([id]) => ({ id: (await api.closeIssue(id)).id }),
+ addComment: async ([id, author, text]) => {
+ const c = await api.addComment(id, author, text);
+ return { author: c.author, text: c.text };
+ },
+ addLabel: async ([id, label]) => api.addLabel(id, label),
+ removeLabel: async ([id, label]) => api.removeLabel(id, label),
+ saveSettings: async ([name, theme, density]) => {
+ const s = await api.saveSettings({ name, theme: theme as 'light' | 'dark', density: density as 'comfortable' | 'compact' });
+ return { name: s.name, theme: s.theme, density: s.density };
+ },
+};
+export const answer: Answer = (source, args, store, storage, native) => sources[source](args, store, storage, native);
+
+// The writes still out, over the answers they change. A write whose reply landed before the answer was asked is in the
+// answer already (`answered`), so only the others are laid over it, in the order the runner gives them.
+export const overlay: Overlay = (source, args, answer, writes) => {
+ const out = writes.filter((w) => !w.answered);
+ const id = args[0];
+ switch (source) {
+ case 'fetchIssues': {
+ let rows = (answer as IssueList).rows;
+ for (const w of out) {
+ const issue = w.args[0] as number;
+ if (w.source === 'assign') rows = rows.map((r) => (r.id === issue ? { ...r, assignee: w.args[1] as string } : r));
+ if (w.source === 'renameIssue') rows = rows.map((r) => (r.id === issue ? { ...r, title: w.args[1] as string } : r));
+ if (w.source === 'closeIssue' && id === 'open') rows = rows.filter((r) => r.id !== issue);
+ }
+ return { ...(answer as IssueList), rows };
+ }
+ case 'fetchIssue': {
+ const detail = answer as Detail;
+ let title = detail.title;
+ for (const w of out) if (w.source === 'renameIssue' && w.args[0] === id) title = w.args[1] as string;
+ return detail.found ? { ...detail, title } : undefined;
+ }
+ case 'fetchComments': {
+ const rows = [...(answer as Comments)];
+ for (const w of out) {
+ if (w.source !== 'addComment' || w.args[0] !== id) continue;
+ rows.push({ author: w.args[1] as string, text: w.args[2] as string, sending: w.reply === undefined });
+ }
+ return rows;
+ }
+ case 'fetchLabels': {
+ let shown = answer as Labels;
+ for (const w of out) {
+ if (w.args[0] !== id) continue;
+ const label = w.args[1] as string;
+ if (w.source === 'addLabel' && !shown.includes(label)) shown = [...shown, label];
+ if (w.source === 'removeLabel') shown = shown.filter((l) => l !== label);
+ }
+ return shown;
+ }
+ case 'loadSettings': {
+ let shown = answer as Settings;
+ for (const w of out) {
+ const reply = w.reply as { name: string; theme: string; density: string } | undefined;
+ const [name, theme, density] = w.args as string[];
+ shown = reply ? { ...shown, ...reply, saving: false } : { ...shown, name, theme, density, saving: true };
+ }
+ return shown;
+ }
+ }
+ return undefined;
+};
diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md
new file mode 100644
index 0000000..617685b
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/README.md
@@ -0,0 +1,87 @@
+# desk9 on the new guide, Opus, 6 apps (2026-11-13)
+
+Not preregistered. Question: with `guide/async.md`'s two new sections (#58: "lay every view of a write over the
+answer", "data belongs with its lifetime"), does Opus get near the hand-written reference's 0?
+
+**Setup:** Solid `next` + the #4828 fix (b8a8c2805, solidjs/solid#4829), rxc from main at 7e5bc81, desk9 scaffolded
+`--brief plain --inline-docs`, Opus via general-purpose agents, graded with `harness/grade.sh`. The reference
+(`reference/desk9/rx`) grades 77/77, 38/38, 0/20 on this build.
+
+## Results
+
+| app | guide | hidden | overlap | fuzz holes | defects |
+| --- | --- | ---: | ---: | ---: | ---: |
+| rx-1 | #58 | 76 | 37 | 2 | 4 |
+| rx-2 | #58 | 76 | 37 | 0 | 2 |
+| rx-3 | #58 | 76 | 37 | 3 | 5 |
+| rx-4 | #58 + `visit := -1` | 77 | 37 | 1 | 2 |
+| rx-5 | #58 + `visit := -1` | 77 | 37 | 1 | 2 |
+| rx-6 | #58 + `visit := -1` | 77 | 36 | 1 | 3 |
+
+rx-1..3 could not run `./test`: the eval's Playwright wanted its own Chromium build, which this container lacks, and
+`./test` did not pass `CHROMIUM`. They wrote blind. `harness/driver.mjs` now falls back to `/opt/pw-browsers/chromium`.
+rx-4..6 tested normally.
+
+## What the defects are
+
+- **Guide-caused, every app: settings moved into a module.** All six applied "data belongs with its lifetime" to the
+ settings page too, with `guess saved := next` on the module's question. Reopening settings while a save is out then
+ waits for the save: the tab switch is held with the reload, so the page never shows "Loading settings…" (overlap
+ "a save that answers after the page was left"). rx-1..3 also loaded settings at app start (`visit := 0`), one ask
+ too many (hidden "loads again when reopened"); the `-1` line fixed that for rx-4..6.
+- **Guide-caused, rx-4/5/6: the open count moved out of the list's region.** With the data in a module, all three
+ put the count `effect` beside it (module or App). An effect outside the list's `loading` waits for every re-ask, so
+ a close guessed while a refresh is out shows in the rows but not in the tab (fuzz rx-4 s13, rx-5 s17, rx-6 s16).
+ The reference's `OpenCount` sits inside the region.
+- **Detail heading blank** (rx-1 s11, s15; rx-3 s15): `heading = detail == null ? "" : titleOf(renaming, …)` reads
+ `""` while `detail.title` in the same frame reads the answer (probed). Needs a rename guess out, a failed list
+ re-ask and a close's re-ask; a minimal scenario does not reproduce it. Unresolved: likely Solid.
+- rx-3 s11, s19: page hangs (120 s). Not traced.
+- rx-6: a row error from a write that answered after the issues page was left shows on its return (agent).
+
+## Guide change after this probe
+
+`guide/async.md` "Data belongs with its lifetime" now gives one rule (each value has one lifetime: the visit, the write
+or the screen; split a value that would need two) and derives the module list, the save, the guess and the count from it.
+
+## Rerun on the lifetime rule (rx-7..9)
+
+| app | hidden | overlap | fuzz holes | defects |
+| --- | ---: | ---: | ---: | ---: |
+| rx-7 | 75 | 38 | 0 | 2 |
+| rx-8 | 77 | 38 | 1 | 1 |
+| rx-9 | 77 | 38 | 0 | **0** |
+
+Mean 1.0 (rx-4..6: 2.33). No settings or count-placement failures: all three kept settings in the page and put the
+count inside the list's region.
+
+- rx-7: the help item's `errored` sits outside its `loading on(selected)`, so a new selection keeps the old failure
+ or not-found message instead of showing loading (agent; not a lifetime case).
+- rx-8 s16: the rows render as empty text (`["","",""]`) after the open list lands, while the tab counts 3. Same
+ shape as rx-1/rx-3's blank heading: a text node left empty while its value is there. Likely Solid; no minimal repro.
+
+## The blank text, in plain Solid
+
+`repro/blank-rows.spec.tsx` (drop into solid-next `packages/web/test/`) reproduces rx-8 s16 in about 50 lines: a
+keyed `createProjection` over a memo of an async list, two `createOptimistic` guess lists (assigns, closes), a declined
+close among overlapping assigns, then a switch to another list. When the new list's re-ask lands, `For` renders one
+`` per row with no text. It fails on next b8a8c2805 and on cd4de2874 (before #4828). Without the projection
+(`For` over the memo) it passes. Errored, `Loading on` and the page remount are not needed.
+
+## On next f00ce6f52 (with the #4831 fix)
+
+The repro passes. rx-8 regrades 77/77, 38/38, 0/20: **0 defects** (rx-7..9 now 2, 0, 0). The reference stays at 0.
+rx-1 and rx-3 keep their blank detail heading (s11, s15): a plain memo, not a projection, so a separate bug.
+
+## The blank heading, in plain Solid
+
+`repro/blank-heading.spec.tsx` reproduces rx-1/rx-3 s15 on next f00ce6f52 in about 45 lines. There is no projection
+and no error boundary. Setup:
+
+- a list memo, and a detail memo that is still loading;
+- one `createOptimistic` rename guess, read by both the list's rows and the heading memo;
+- another action, out at the same time, that re-asks the list;
+- the rename itself, still out.
+
+When the other action lands (its re-ask goes out), and then the detail lands, the `` renders empty while
+`detail.body` beside it shows. Without the other action the test passes.
diff --git a/eval/results/desk13-probe-2026-11-13/reference.grade.txt b/eval/results/desk13-probe-2026-11-13/reference.grade.txt
new file mode 100644
index 0000000..980f6dc
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/reference.grade.txt
@@ -0,0 +1,6 @@
+== hidden
+77/77 passed
+== overlap
+38/38 passed
+== fuzz
+ref-desk9: 0/20 seeds found a hole
diff --git a/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx b/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx
new file mode 100644
index 0000000..6f06a16
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx
@@ -0,0 +1,46 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ */
+import { test, expect } from "vitest";
+import { For, Loading, Show, action, createOptimistic, createMemo, createSignal, refresh, flush } from "solid-js";
+import { render } from "../src/index.js";
+const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
+test("a heading that reads a guess shows it when its async source lands", async () => {
+ const asks: { key: string; res: (v: any) => void }[] = [];
+ const ask = (key: string) => new Promise(res => asks.push({ key, res }));
+ const pick = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; };
+ const c = document.body.appendChild(document.createElement("div"));
+ const frames: string[] = []; const snap = (l: string) => frames.push(`${l.padEnd(18)} ${c.innerHTML} | in the air: ${asks.map(a => a.key)}`);
+
+ const [selected, setSelected] = createSignal(false);
+ const [renamesT] = createSignal(() => []);
+ const [renames, guessRename] = createOptimistic(() => renamesT());
+
+ const list = createMemo(() => ask("list"));
+ const rows = createMemo(() => (list() as any[]).map(i => ({ id: i.id, title: renames().reduce((t, r) => r, i.title) })));
+ const detail = createMemo(() => selected() ? ask("detail") : null);
+ const heading = createMemo(() => { const d: any = detail(); return d == null ? "" : renames().reduce((t, r) => r, d.title); });
+
+ const rename = action(function* (title: string) { guessRename(rs => [...rs, title]); yield ask("rename"); refresh(list); refresh(detail); });
+ const other = action(function* () { yield ask("other"); refresh(list); });
+
+ render(() =>
+ loading}>{(it: any) => - {it.title}
}
+ select}>
+ loading detail}>{heading()}
{(detail() as any)?.body}
+
+ , c);
+
+ const steps: [string, () => void][] = [
+ ["list lands", () => pick("list").res([{ id: 2, title: "Old bug" }])],
+ ["select", () => setSelected(true)],
+ ["other action", () => other()],
+ ["rename to Delta", () => rename("Delta")],
+ ["other lands", () => pick("other").res({})], // re-asks the list
+ ["detail lands", () => pick("detail").res({ title: "Old bug", body: "long gone" })],
+ ];
+ for (const [n, f] of steps) { f(); await tick(); snap(n); }
+ console.log(frames.join("\n"));
+ expect(c.querySelector("h2")?.textContent).toBe("Delta");
+});
diff --git a/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx b/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx
new file mode 100644
index 0000000..afc462b
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx
@@ -0,0 +1,55 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ */
+import { test, expect } from "vitest";
+import { For, Loading, action, createOptimistic, createMemo, createSignal, createProjection, refresh, flush } from "solid-js";
+import { render } from "../src/index.js";
+const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
+test("rows that land while guesses are out render their text", async () => {
+ const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = [];
+ const inAir = new Map>();
+ const ask = (key: string) => new Promise((res, rej) => asks.push({ key, res, rej }));
+ const askQ = (key: string) => { let p = inAir.get(key); if (!p) { p = ask(key).finally(() => inAir.delete(key)); inAir.set(key, p); } return p; }; // a question asked again while in the air joins it
+ const pick = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; };
+ const c = document.body.appendChild(document.createElement("div"));
+ const frames: string[] = []; const snap = (l: string) => frames.push(`${l.padEnd(22)} ${c.innerHTML} | in the air: ${asks.map(a => a.key)}`);
+
+ const [filter, setFilter] = createSignal("closed");
+ const server = createMemo(() => askQ(filter()));
+ const [assigningT] = createSignal<{ id: number; user: string }[]>(() => []);
+ const [assigning, guessAssigning] = createOptimistic(() => assigningT());
+ const [closingT] = createSignal(() => []);
+ const [closing, guessClosing] = createOptimistic(() => closingT());
+ const rowsSrc = createMemo(() => (server() as any[]).filter(i => !closing().includes(i.id))
+ .map(i => ({ id: i.id, title: i.title, assignee: assigning().reduce((n, x) => x.id === i.id ? x.user : n, i.assignee) })));
+ const rows = createProjection(() => rowsSrc() as any, [] as any, { key: "id" } as any);
+ const showOpen = action(function* () { setFilter("open"); });
+ const assignTo = action(function* (id: number, user: string) {
+ try { guessAssigning(xs => [...xs, { id, user }]); yield ask(`assign:${user}`); refresh(server); } catch (e) {}
+ });
+ const closeOne = action(function* (id: number) {
+ try { guessClosing(xs => [...xs, id]); yield ask(`close`); refresh(server); } catch (e) {}
+ });
+ const list = () => {(it: any) => - {`#${it.id} ${it.title} (${it.assignee})`}
}
;
+ render(() => loading}>{list()}, c);
+
+ const steps: [string, () => void][] = [
+ ["mount", () => {}],
+ ["closed list lands", () => pick("closed").res([{ id: 2, title: "Old bug", assignee: "eve" }])],
+ ["assign #2 ann", () => assignTo(2, "ann")],
+ ["close #2", () => closeOne(2)],
+ ["assign #2 guest", () => assignTo(2, "guest")],
+ ["that assign lands", () => pick("assign:guest").res({})], // re-asks the closed list
+ ["close declined", () => pick("close").rej(new Error("declined"))],
+ ["assign #2 guest again", () => assignTo(2, "guest")],
+ ["assign ann lands", () => pick("assign:ann").res({})], // re-asks the closed list (joins the one in the air)
+ ["switch to open", () => showOpen()],
+ ["open list lands", () => pick("open").res([{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }])],
+ ["second assign lands", () => pick("assign:guest").res({})], // re-asks the open list
+ ["open list lands again", () => pick("open").res([{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }])],
+ ];
+ for (const [n, f] of steps) { f(); await tick(); snap(n); }
+ console.log(frames.join("\n"));
+ expect(c.textContent).toBe("#1 Login fails (null)#3 Crash on save (null)");
+});
diff --git a/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt
new file mode 100644
index 0000000..a78b2c3
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt
@@ -0,0 +1,12 @@
+== hidden
+✗ base: tabs: the settings page loads again when reopened, with a fresh draft
+76/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-1: 2/20 seeds found a hole
+ seed 11 step 44: detail title: "#3 Bravofooter says 2019finding related helpretry assign to ", server "Delta"
+ tab help ; tab issues ; tab help ; land loadSettings: ; tab issues ; filter closed ; tab help ; land fetchIssues:open ; land fetchIssues:closed ; tab issues ; tab help ; tab issues ; focus ; land fetchIssues:open ; select #4 ; rename Alpha ; rename Bravo ; post me too ; post fixed? ; land fetchIssue:4 ; row-assign 0 ; row-close 0 ; select #3 ; focus ; close ; land fetchComments:4 ; row-close 0
+ seed 15 step 33: detail title: "#2 long gonefinding related helpretry assign to ann close re", server "Old bug"
+ land fetchIssues:open ; row-assign 1 ; row-assign 0 ; land assign:3,guest ; land fetchIssues:open ; land loadSettings: ; filter closed ; land assign:1,guest ; land fetchIssues:closed ; user ann ; row-close 0 ; row-close 0 ; row-close 0 ; land closeIssue:2 ; row-close 0 ; select #2 ; land fetchIssues:closed ; assign ; row-close 0 ; land fetchComments:2 ; land closeIssue:2 ; assign ; land fetchI
diff --git a/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt
new file mode 100644
index 0000000..dc7efdb
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt
@@ -0,0 +1,8 @@
+== hidden
+✗ base: tabs: the settings page loads again when reopened, with a fresh draft
+76/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-2: 0/20 seeds found a hole
diff --git a/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt
new file mode 100644
index 0000000..e1aabc5
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt
@@ -0,0 +1,14 @@
+== hidden
+✗ base: tabs: the settings page loads again when reopened, with a fresh draft
+76/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-3: 3/20 seeds found a hole
+ seed 11 step -1: page crash: session timed out (120 s): the page hangs
+
+ seed 15 step 33: detail title: "#2 long gonefinding related helpretryassign to ann renameclo", server "Old bug"
+ land fetchIssues:open ; row-assign 1 ; row-assign 0 ; land assign:3,guest ; land fetchIssues:open ; land loadSettings: ; filter closed ; land assign:1,guest ; land fetchIssues:closed ; user ann ; row-close 0 ; row-close 0 ; row-close 0 ; land closeIssue:2 ; row-close 0 ; select #2 ; land fetchIssues:closed ; assign ; row-close 0 ; land fetchComments:2 ; land closeIssue:2 ; assign ; land fetchI
+ seed 19 step -1: page crash: session timed out (120 s): the page hangs
+
diff --git a/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt
new file mode 100644
index 0000000..0127383
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt
@@ -0,0 +1,9 @@
+== hidden
+77/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-4: 1/20 seeds found a hole
+ seed 13 step 7: count: tab Issues (3), 0 open rows shown
+ filter closed ; fail fetchIssues:open ; land fetchIssues:closed ; row-assign 0 ; filter open ; land assign:2,guest ; land fetchIssues:open
diff --git a/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt
new file mode 100644
index 0000000..5cc165a
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt
@@ -0,0 +1,9 @@
+== hidden
+77/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-5: 1/20 seeds found a hole
+ seed 17 step 8: count: tab Issues (2), 1 open rows shown
+ user bob ; land fetchIssues:open ; row-assign 1 ; land assign:3,bob ; land fetchIssues:open ; row-close 2 ; land closeIssue:4 ; row-close 1
diff --git a/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt
new file mode 100644
index 0000000..76aea22
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt
@@ -0,0 +1,10 @@
+== hidden
+77/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+✗ rows: a row action that answers after the issues page was left changes nothing on its return
+36/38 passed
+== fuzz
+desk13-rx-6: 1/20 seeds found a hole
+ seed 16 step 34: count: tab Issues (3), 0 open rows shown
+ user guest ; fail fetchIssues:open ; focus ; tab help ; tab issues ; filter closed ; land fetchIssues:open ; focus ; land fetchIssues:closed ; select #2 ; land fetchComments:2 ; close ; user ann ; fail closeIssue:2 ; row-assign 0 ; close ; user bob ; user guest ; assign ; land assign:2,guest ; land fetchLabels:2 ; fail closeIssue:2 ; assign ; land assign:2,ann ; focus ; land fetchIssue:2 ; lan
diff --git a/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt
new file mode 100644
index 0000000..e7ef0e8
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt
@@ -0,0 +1,8 @@
+== hidden
+✗ base: help: selecting after a failure shows loading…, not the old failure
+✗ base: help: selecting after not found shows loading…, not the old message
+75/77 passed
+== overlap
+38/38 passed
+== fuzz
+desk13-rx-7: 0/20 seeds found a hole
diff --git a/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt
new file mode 100644
index 0000000..443aee1
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt
@@ -0,0 +1,8 @@
+== hidden
+77/77 passed
+== overlap
+38/38 passed
+== fuzz
+desk13-rx-8: 1/20 seeds found a hole
+ seed 16 step 34: count: tab Issues (3), 0 open rows shown
+ user guest ; fail fetchIssues:open ; focus ; tab help ; tab issues ; filter closed ; land fetchIssues:open ; focus ; land fetchIssues:closed ; select #2 ; land fetchComments:2 ; close ; user ann ; fail closeIssue:2 ; row-assign 0 ; close ; user bob ; user guest ; assign ; land assign:2,guest ; land fetchLabels:2 ; fail closeIssue:2 ; assign ; land assign:2,ann ; focus ; land fetchIssue:2 ; lan
diff --git a/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt
new file mode 100644
index 0000000..40d2a37
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt
@@ -0,0 +1,6 @@
+== hidden
+77/77 passed
+== overlap
+38/38 passed
+== fuzz
+desk13-rx-9: 0/20 seeds found a hole
diff --git a/guide/async.md b/guide/async.md
index e04289a..4105212 100644
--- a/guide/async.md
+++ b/guide/async.md
@@ -200,17 +200,29 @@ export action rename(id: string, name: string) {
### Data belongs with its lifetime
In .rx, state is not tied to components: a `source` can be declared in a module, a component or a region, and a
-component only reads and calls what it is given. So declare data where it lives as long as it is needed. A page's list usually belongs to the page: it loads when the page
-opens and goes when it closes. A write in flight lives until the server answers, which can be after its page closed;
-the data it refreshes must live at least as long. If it lives in the page, the write's `refresh` re-asks the closed
-page's question, and the page opened again keeps the answer it asked for before the write landed, beside views asked
-after it that show the write.
-
-So for the data such a write changes (the list and the item it refreshes), declare it, its writes in flight and its
-actions in a module (top-level `source`s and `action`s), and let the page read it and call the actions. The page's own
-inputs (a draft, a text field) live as long as the page and stay in it. To make the page start fresh when it opens,
-have opening it call an action that resets the module's inputs and moves a counter the question reads
-(`source visit := 0`, `items = visit >= 0 ? await loadItems(filter) : []`).
+component only reads and calls what it is given. So give each value one lifetime, and declare it where that lifetime
+is. There are three:
+
+- **The visit.** What a page asks for and its inputs: asked when the page opens, gone when it closes.
+- **The write.** A write lives until the server answers, which can be after its page closed. Its guess lives exactly
+ as long, and so does whatever it re-asks.
+- **The screen.** What copies what is shown lives where it is shown, inside the region that shows it.
+
+When one value would need two lifetimes, split it into two values. Each case below follows from that:
+
+- **A list a write refreshes** is needed by the visit and by the write, so it outlives both: declare it, the writes
+ and their actions in a module (top-level `source`s and `action`s), and let the page read it. The visit becomes a
+ counter its question reads, moved by opening the page (`source visit := -1`,
+ `items = visit >= 0 ? await loadItems(filter) : []`). A module's question is asked when the app starts, so the
+ counter starts below the guard and the first open asks; start it at `0` only for the page the app opens on.
+- **A write whose answer is the new value** (a save that returns the saved settings) re-asks nothing, so its data
+ needs only the visit and stays in the page.
+- **A guess** never goes on the module's question: that would give the question the write's lifetime, and the next
+ visit's load, with the page switch that asks it, would wait for the write. Guesses go in lists of their own, laid
+ over the answer (the next section).
+- **A count in a tab** copies the rows on screen, so it is an `effect` in a component inside the list's region
+ (`` under the list's `loading`), not beside the module: outside the region it waits
+ for every re-ask of the list and misses the guesses the rows already show.
### Data the server owns: three layers