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/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