From 140e167817d2a7605832c85c0696171e2b73de12 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 5 Oct 2026 15:06:57 -0700 Subject: [PATCH 1/7] =?UTF-8?q?docs(server-components):=20frames=20rulings?= =?UTF-8?q?=20=E2=80=94=20the=20three=20seams=20the=20consistency=20reds?= =?UTF-8?q?=20and=20the=20size=20audit's=20duplicates=20share=20(proposed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft, marked "proposed — maintainer ruling pending"; no engine changes. The frames/hydration consistency contract (spec/frames-consistency-contract) found thirteen reds across C2, C3, C4, C5, C6, C7, C12 and C17; the SC size audit (size/sc-audit §4) listed the layer's duplicated seams. This document tests the hypothesis that they are the same seams seen from two sides, and drafts the rulings — numbered one-sentence statements in the L2 section's voice, each with the mechanism that carries it today, the state it lives in twice, and the reds it decides — grouped by the three questions the reds cluster on: - Response identity (C5, C6, C17): a response owns what it delivered; a `data` chunk lands in its own response's table or nowhere; a record resolves its refs through its own response's table wherever the frame is bound; a bump drops what the previous version never applied; the shell gate is the bound address's; a switch keeps on screen what was on screen. - Applied state per version (C7, C2, C4): the store is the truth and the applied state is a cache of it keyed by version; a bump re-applies a byte-identical root; a reveal is an apply; applied means shown. - Hydration-done accounting (C3, C12, S1's C3b and `.fails`): done counts every occurrence the document delivered; every frames hold is counted once per frame; a claim owns the fragment's outcome; the client consumes the ids the server consumed. For each: the fix shape as one collapsed carrier (response-owned data cells, records carrying their resolver, one shell gate, one applied record, one hold counter), a byte estimate (− collapse / + carrier) read off the audit's function table, the pins that flip, and what touches public surface or the server half. Two-readings items for the maintainer: 1.4 (narrow/full), 1.6 (per-address gate value vs holds-latest), 3.1 (done counts the page vs the root pass — S1's pin and the contract's contradict). What the rulings do not decide (plain bugs, product questions, the wire) and an order of work gated by the contract's pins and scripts/size. Co-authored-by: Claude via Cursor --- .../server-components/frames-rulings.md | 710 ++++++++++++++++++ 1 file changed, 710 insertions(+) create mode 100644 documentation/server-components/frames-rulings.md diff --git a/documentation/server-components/frames-rulings.md b/documentation/server-components/frames-rulings.md new file mode 100644 index 000000000..bbd7c85ca --- /dev/null +++ b/documentation/server-components/frames-rulings.md @@ -0,0 +1,710 @@ +# Frames rulings — the three seams (2026-10-05) + +**Status: proposed — maintainer ruling pending.** Nothing here changes an +engine. Branch `spec/frames-rulings` off `next` @ `01e80a601`; this document +only. + +The frames/hydration consistency contract +(`frames-consistency-contract.md`, branch `spec/frames-consistency-contract`) +pinned seventeen invariants against `next` and found eight red: thirteen +`test.fails` under `packages/web/test/consistency/` across C2, C3, C4, C5, C6, +C7, C12 and C17, each diagnosed to a mechanism (its §Red R1–R8). The +server-components size audit (`documentation/plans/sc-layer-audit.md` §4, branch +`size/sc-audit`) separately listed the layer's duplicated seams: two dedupes, +two gates, two late-boundary waiters, two `_$SC` bootstraps, two version +spaces, two asset loaders, three region-rename sites, two reveal engines, and +`preview` re-implementing the args arm. The hypothesis this document tests is +that the reds and the duplicates are the same seams seen from two sides — that +each red is a question two carriers answer differently, and each duplicate is +a question asked twice because no ruling said who answers it. + +The finding: **yes for every red but one, and for five of the nine duplicates +directly.** The reds cluster on three questions — which response owns a thing, +what a version bump resets, what hydration-done counts — and five duplicates +(the two dedupes, the two gates, the two version spaces, the two reveal +engines, `preview`'s data half) are the two-carrier answers the reds fall +between; each collapses under the ruling that decides its red. Two more (the +two late-boundary waiters, the two `_$SC` bootstraps) sit on the seams' +questions with no red to show for it and stay with the audit's S9. Two (the +asset-loader mirror, the region-rename sites) are outside the three seams — +the audit's S10/S7. The one red outside the seams is C13 (one sweep, one +frame), which needs a wire delimiter and is the server half's. + +The form is the signals core's L2 section (`packages/signals/docs/SPEC-ASYNC-SEMANTICS.md`, +"The hold model — L2"): one-sentence rulings, the mechanism meant to carry +each today (`file:function`), the state it currently lives in twice, the reds +it decides. Where two readings are possible, both are stated with +consequences and one is recommended, as that section did. The method for +turning rulings into fixes is the carve's (`documentation/plans/size-reduction-carve-step1.md` +§40–§42): fix under the ruling, collapse the duplicated carrier into one, state +the bytes, flip the pins — not thirteen patches. + +Vocabulary is the contract's (occurrence, claim, shows, quiescent, hold). +"Response" below means one HTTP response for one address, identified on the +client by the version the handler's `bump` stamped at its header; the document +is a response too — version 0, the t = 0 frame (DR-4). + +## The seams, the reds, the duplicates + +| seam | question | reds (pins that fail on `next`) | duplicates (audit §4) | +| --- | --- | --- | --- | +| **1. Response identity** | which response owns a record, a `{$ref}` wait, a data table, the shell gate | **C5** (a, b, e) a superseded response's late `data` lands in the current table — R4; **C6** (a1, b2) a held `slot:*` record outlives its response and resolves through the next one's data — R5; **C17** (a, c) the shell gate answers to the frame's registered address, which lags the binding — R8 | two table spaces (`tables` + `stageTables`' `staged`, 183 B); two ref-resolution paths (`host.resolve(ref, frameId)` + the `resolve` parameter threaded through `preview` → `#refsUnresolved`/`#refArgsUnchanged`/`#resolveArgs`/`#resolveRef`); two dedupes (`argsEquivalent` 217 B at apply, `#refArgsUnchanged` 534 B at sync — the first exists because refs are response-scoped and the store is not); two shell gates (`boundaryComponent` + the adopted face in `adoptBoundary`, ≈ 150 B duplicated); `preview`'s data half (the `resolve` it threads). _No red, same question:_ the `_$SC` bootstrap twice — the document's t = 0 address record reaches the mount by `documentAddress` scanning `_$SC.a` (audit S9) | +| **2. Applied state per version** | what a version bump resets; when a reveal is an apply | **C7** (c) a byte-identical v2 root never re-applies, so v2's segment waits for a placeholder v1 removed — R2; **C2** (a2, b) and **C4** (d) a fragment reveal into adopted content is not a sync trigger — R3 | two version spaces (`FrameImpl.#version` beside `store.version`; `rebase`, ≈ 60–100 B); applied state in seven fields reset at three sites (`#resetStreamState` ×5, `rebind` ×2, the root apply ×1), one of them (`#appliedRootValue`) reset at only one; two reveal engines (`web.js`'s `$df`/`$dfl` and the frame's `#revealSegment`/`#showFallback`, ≈ 1.3 KB on one side — DR-4); the #2978 cascade (`claimRegionFragments` + the `fr.subscribe` body ≈ 250 B) as the document face's half of a sync | +| **3. Hydration-done accounting** | what `done` counts; what a claim owes | **C3** (a) hydration reports done while an adopted occurrence is still deferred — R1; **C12** (c) a rejected server `` the adoption claimed swaps to a blank, unsurfaced — R6; S1's **C3b** shape (the `prepareArgs` wait — held by S1's own pin as the *expected* order); S1's `.fails` (a keyed sibling after a document boundary misses its key) | the #2968 deferral (`recordsPending` + `#recordRefresh` arm + the drain hook ≈ 300 B), S1's `#argsRefresh` + `#heldRecords`, and the `{$ref}` wait's non-carrier: three hold kinds, two carriers, no shared accounting; `drainRecords` + `appliedRecords` (DR-4 row 20). _No red, same question:_ two late-boundary waiters (`boundaryWaiters` + `arrivals`, ≈ 150 B duplicated, resolved from one subscription — a hold already counted through the covering ``'s `_fr`; audit S9) | +| outside | — | **C13** (a, b) one sweep, two frames — R7 (no pin file yet) | the asset-loader mirror (audit S10), the three region-rename sites (audit S7) | + +Byte figures are the audit's (minified, page base, exact per function; +brotli ≈ 0.29× at this layer). Estimates below carry a sign per direction: +− for the collapse, + for the new carrier. + +--- + +## Seam 1 — Response identity + +The question every red here asks: a thing arrived — whose is it? Today the +answer is given by *where it landed* (the address's current table, the frame's +current id, whatever gate is armed), and the rotation that makes "current" mean +"newest" happens at different moments for different things: the table at the +header (`beginStream`), the record never (`slot:*` survives `clearStreamRecords`), +the frame's address at the commit (`rebind`, by the maintainer's ruling of +2026-10-04 — the switch is display, one reveal). Between those moments, a thing +from one response answers a question asked of another. + +### 1.1 A response owns what it delivered + +**A record, a `data` table and a `{$ref}` wait belong to the response that +carried them — never to the address, the frame or "whatever is current" — and a +later response for the address supersedes them wholesale: nothing from a +superseded response lands in the frame that shows the current one.** + +- **Mechanism today.** The table: `client.ts:tables` is a `Map`; + `beginStream(address)` rotates by `tables.set(address, undefined)` and + `ensureTable` creates lazily at *first use* — which `createFrameHost.apply`'s + `data` arm performs with no version read (the transport restamped + `chunk.version`; `applyData` never looks). The record: owned by the frame + store and versioned at the store (`store.version`, `#version`), not per + record; `clearStreamRecords` keeps every `slot:*`. The wait: a `continue` in + `FrameImpl.#syncSlots` with no carrier; its answer is `#resolveRef(ref)` → + `host.resolve(ref, this.#options.id)` → `tableFor(id)` — the frame's *current* + id, so a `rebind` re-routes every held record to the new address's data. +- **Lives twice in.** `tables` + `stageTables()`'s `staged` (the staged + response's table is the one case that already *is* response-owned — kept in a + second map because the first is address-owned); `host.resolve` + the `resolve` + parameter; `argsEquivalent` + `#refArgsUnchanged`. +- **Decides.** The frame of reference for 1.2–1.4; by itself it flips nothing. +- **Note.** The staged entry in `frame-transport.ts:createServerComponentHandler.stage` + is already this ruling built for one case: a per-response object owning the + response's chunks, data (`STAGED_DATA`'s `data`), and the moment they become + the address's (`commit`). The ruling says every response is that shape; only + the moment differs — the header for an unstaged response, the commit for a + staged one. + +### 1.2 A `data` chunk lands in its own response's table or nowhere + +**The table is opened at the response's header, not at its first chunk, so a +superseded response's late chunk has a table of its own to fall into and never +becomes the current one.** + +- **Mechanism today.** Lazy creation at first use (`ensureTable`); the version + guard in `createFrameHost.write` covers record writes only — `data` bypasses + the store and the guard (R4). +- **Decides.** **C5 (a)** v1's late `data` trailing v2's header; **C5 (b)** the + rotation observed through `host.resolve`; **C5 (e)** A → B → A → B through + `dynamic` while B's first body is open. +- **Carrier.** A per-response data cell `{ t: table | undefined }` created where + the response begins (the handler's `handle`, at `bump`), filled at the first + `data` chunk once the codec is resident (`prepareData` is awaited before it — + ruling §3 11 — so the cell's lazy fill is the codec's, not the response's); + installed as the address's current at the header (unstaged) or at `commit` + (staged). The response's chunks reach the host through a per-response target + whose `apply` routes `data` into *its* cell — the shape `stage`'s entry + already has. Nothing stamps or compares versions on the data path: a late + chunk fills a cell nothing reads. + +### 1.3 A record resolves its refs through its own response's table, wherever the frame is bound + +**A record held on an unresolved `{$ref}` is held on its response's data; an +address switch or a refetch does not re-route it, so it never resolves against +a later response's values, and the later response's own record replaces it.** + +- **Mechanism today.** `#resolveRef(ref, resolve?)` — the host path by frame id, + or the `resolve` the staged preview threads down. R5: after `rebind`, A's held + record resolves through `tableFor(B)`; at a staged commit, `commit` installs + v2's tables *before* replaying v2's chunks, and the replayed `start`'s flush + resolves v1's held record through them. +- **Lives twice in.** The two resolution paths (host-by-frame-id and the + threaded `resolve`): `createFrameHost.preview(chunk, resolve)`, + `FrameImpl.preview(records, resolve, inherited)`, `#refsUnresolved(args, resolve)`, + `#refArgsUnchanged(occurrence, record, resolve)`, `#resolveArgs(key, args, resolve)`. +- **Decides.** **C6 (a1)** switch during the wait, the new stream ordering + `data → html → slot`; **C6 (b2)** refetch during the wait, v1's data never + before the commit. (C6 a2, b1, c and the control already hold.) +- **Carrier.** The slot record carries its response's data cell (stamped by the + per-response target of 1.2 as the chunk becomes records: `record.data = cell`); + `#resolveRef(ref, record)` has one path — `record.data?.t?.resolve(ref)`, + `undefined` meaning "not delivered", which is already the held state. The + threaded `resolve` deletes with the second path; `host.resolve(ref, frameId)` + and `FrameHostOptions.resolve` have no caller left. A document-face record + (`drainRecords`, version 0) carries no cell: its args are inline literals, so + nothing resolves. +- **Consequence for (a1), traced.** A's record stays in the frame store after + the rebind, bound to A's cell; A's data never comes; `#refsUnresolved` stays + true; B's `slot` chunk writes B's record over it (a different object with + different refs — the write replaces); B's record resolves through B's cell and + mounts once. For (b2): the replayed `start` bumps and flushes; v1's record, + bound to v1's never-filled cell, stays held; v2's replayed `slot` replaces it; + one mount. + +### 1.4 A version bump drops what the previous version never applied + +**Slot records outlive a bump only as the dedupe for *mounted* occurrences; a +record no mount applied belongs to its superseded response and leaves with it.** + +Two forms; the maintainer picks. + +- **Narrow.** `#resetStreamState` (the bump arm and `rebind`) deletes every + `slot:*` record that is not some mounted occurrence's `#slotArgs` entry. + Hygiene after 1.3 (a held record of a dead response otherwise sits in the + store until the occurrence disappears) and belt-and-braces for C6: a dropped + record cannot resolve through anything. ≈ +50 B. +- **Full — the store is one response's.** Every record of the previous version + leaves at the bump (the host's `write` and the frame's `apply` alike); what + preserves occurrence state across versions is the *mount's* applied state + (`#slotArgs`, `#slotResolvedRefs`), which the sync's value compare + (`#refArgsUnchanged`) already consults. Then the apply-time dedupe + (`argsEquivalent` 217 B, the `slot:` arm of `FrameImpl.apply` ≈ 80 B) has + nothing to compare against and deletes; `clearStreamRecords`' key filter and + its `root` parameter go with it (≈ −60 B); the `seg::assets` accumulate stays + (it is within one version). **Rests on A5 as the sink implements it:** every + response carries the full record set for its content (the sink emits a slot + chunk per called occurrence per stream; `frames-live` "the re-sent slot record + is equivalent" pins a reconnect re-sending). If a producer may omit an + unchanged record, the full form misclassifies the occurrence on the next + non-adopt sync — recommend the maintainer confirm the sink's rule before + choosing it. ≈ −350 B. +- **Recommend:** the full form, confirmed against the sink; it is the audit's + S10 "one dedupe" falling out of the ruling rather than being chosen. +- **Open under either form:** a called occurrence (`prop#n`) found recordless + on a *non-adopt* sync is invoked argless today (the #2968 defer is adopt-only; + `#syncSlots`' comment calls the recordless-called case "the protocol's + invariant broken"). RFC 11 fixes no order between `slot` and `html`; the sink + emits the record "at the call, ahead of the markup". C6 (a1) orders + `data → html → slot` and the contract calls it legal. Either the sink's order + is the rule (pin it; the contract's (a1) ordering is then contrived and + (a2) is the real arm) or the defer generalizes to every sync as a hold + (3.2). Needs the maintainer; not decided here. + +### 1.5 The shell gate is the bound address's + +**A mount's gate is armed for one address and released only by that address's +first apply — content or error — through whatever is registered under it; while +a switch is delivered and not yet committed the frame is still the superseded +address's and its applies release nothing; the new address's first write +releases through the frameless waiter.** + +- **Mechanism today.** `client.ts:boundaryComponent` — `arm`/`release`/`settle`/ + `setGate`, `onApply: () => { applied = true; settle(); }`; `followAddress`'s + compute half re-arms and registers `{ apply: settle }` under the new address; + its effect half `drop()`s the waiter and `rebind`s at the commit (ruling + 2026-10-04: the switch is display). R8 (a): between the two halves the frame is + registered under A; A's late html fans to it; `onApply` → `settle()` + unconditionally; `release` is by then the re-armed gate's. The rebind's place + is **not** what this ruling changes — the 2026-10-04 ruling stands; what + changes is that an apply from the frame is a release only when the frame is + bound where the gate is armed. +- **Lives twice in.** `boundaryComponent` and `adoptBoundary` each build the + gate (`release`, `arm`, `settle`, `setGate`, the `ownedWrite` signal, the + memo) — ≈ 150 B duplicated; the waiter under the new address is a third + half-carrier of "who may release". +- **Decides.** **C17 (a)** switch delivered while A is open: A's late + html/complete leave the gate pending; B's first chunk releases it. (C17 b, d, + control hold.) +- **Carrier.** One `shellGate()` helper used by both faces, owning the signal, + `arm(address)`, and `settle(from)`; the switching state already exists — the + waiter (`followAddress`'s `waiter !== undefined` between its compute and its + effect) — so the frame's `onApply` releases iff no waiter stands, and the + waiter's `apply` releases unconditionally. `FrameOptions.onApply`'s detail is + unchanged (no `id` needs adding). + +### 1.6 A switch keeps on screen what was on screen + +**The gate's value is per bound address: a re-arm begins with no value, so the +boundary decides by its own state — content it had revealed stays through the +switch, a fallback it was showing stays until the new address's first write — +and the superseded address never reveals after the switch was delivered.** + +- **Mechanism today.** `createMemo(() => gatePromise())` is one memo across + addresses; R8 (c): A's html released the gate legitimately (A was the bound + address, no switch delivered yet), so the memo holds the element; B's delivery + re-arms it, and a memo pending *with* a value shows the value under + async-holds-latest — the `` drops its fallback for content that was + never on screen, `waiting → A → B`. +- **Two readings.** + - **(i) Per-address gate value** (this ruling). The memo over the gate is the + address's: a re-arm for B is a fresh pending read with no prior value, so a + boundary that has not revealed stays on its fallback (A29's boundary + exemption: an unrevealed boundary catches, a revealed one holds), and a + boundary that had revealed A keeps A through the hold — the normal switch + shows no flash, (c) shows no A. Consequence: `waiting → B` in (c); + `A → B` on a shown site, unchanged. Cost ≈ +80 B (the gate memo keyed by + the bound address — recreated per address rather than written across + addresses). + - **(ii) Holds-latest is the display rule.** A re-armed gate pending with a + value shows the value; (c) is re-pinned as display behaviour (A flashes + for one morph) and leaves C17 for a display invariant, as the contract's + R8 anticipated. 0 B. +- **Recommend (i).** The L2 model's own reason: A's release answers a question + ("show A") the site had already superseded ("show B" was in flight when A's + html landed — the source was pending); provenance (L2 ruling 5) says an + answer to an older question does not reveal. The frames layer has no + question stamps, but the per-address gate value is the same rule stated in + the boundary's terms: a value computed for A is not a value for B. +- **Decides.** **C17 (c)** under (i). + +### Fix shape — seam 1 + +| step | collapse (−) | carrier (+) | net (min B, est.) | pins that flip | touches | +| --- | --- | --- | --- | --- | --- | +| 1a — 1.2, data owned by response | `stageTables` 183; `beginStream` 32; `tableFor`/`ensureTable`'s lazy-create ≈ 84; `stage`'s `data ? … : streams` fallbacks ≈ 60 | per-response cell at `bump` + the unstaged per-response target (the staged entry's shape, smaller) ≈ 140 | **≈ −220** | C5 (a), (b), (e) | `ServerComponentHandlerOptions.onStream` (public: the rotation it signalled is now the cell's install — delete or redefine); `STAGED_DATA` (@internal) generalizes to every response | +| 1b — 1.3, record carries its resolver | `host.resolve` 53 + `FrameHostOptions.resolve` wiring ≈ 40; the `resolve` parameter across `preview` ×2, `#refsUnresolved`, `#refArgsUnchanged`, `#resolveArgs`, `#resolveRef` ≈ 70 | the stamp at chunk→records ≈ 40 | **≈ −120** | C6 (a1), (b2) | `FrameHost.resolve(ref, frameId)` / `FrameHostOptions.resolve` (public, experimental — no caller); `FrameHost.preview`/`Frame.preview` signatures (@internal) | +| 1c — 1.4 full, the store is one response's | `argsEquivalent` 217; the `slot:` arm of `FrameImpl.apply` ≈ 80; `clearStreamRecords`' filter + `root` ≈ 60 | — | **≈ −350** (narrow form: ≈ +50) | none directly; closes the C6 class | the sink's A5 rule must be confirmed (no wire change if it holds) | +| 1d — 1.5 + 1.6 (i), the gate | the second gate (`adoptBoundary`'s face) ≈ 150 | `shellGate()` helper's waiter check ≈ 20; per-address gate memo ≈ 80 | **≈ −50** (1.5 alone: ≈ −130) | C17 (a); C17 (c) under (i) | none public | +| **seam 1** | | | **≈ −740** (≈ −215 br) | 7 of the 13 | | + +The audit's **S8** ("one apply path for staged content", ≈ −0.7 KB) splits +here: 1a/1b are its *data* half (`stageTables` folds into response-owned +cells; `preview`'s `resolve` threading goes). Its *markup* half — a staged +version held in the one store under a not-shown bit, `preview` becoming the +ordinary args-update arm — is S8 proper, is a restatement of rulings §3 71–75, +and is not decided here (audit §7 Q2). + +--- + +## Seam 2 — Applied state per version + +The question: a frame applied something under version *n*; version *n*+1 +arrives — what does the frame still believe? Today "applied" is seven fields +reset at three sites, and one of them is reset at only one of the two sites +that bump the version. And "applied" is also asked of the wrong event: a record +applies when it *arrives* (the flush after a store write), not when the range +it names *appears* — so a reveal that brings no new record applies nothing. + +### 2.1 The store is the truth; the applied state is a cache of it, keyed by version + +**After every flush a frame shows the materialization of its store at the +store's version; what the frame remembers having applied — the root, the +reveals, the fallbacks, the holes, the assets, the error notice, the have-list +— is one record stamped with that version and replaced wholesale when the +version changes; nothing applied under a previous version is consulted under +the next.** + +- **Mechanism today.** `FrameImpl.#flush` (the repeat-until-no-progress segment + loop, the hole pass, the assets pass); the applied state in `#appliedRootValue`, + `#revealed`, `#fallbackShown`, `#appliedHoles`, `#processedAssets`, + `#errorNotified`, `#have`; reset by `#resetStreamState` (five of them), by + `rebind` (`#version`, `#appliedRootValue`), by the root apply (`#have`). + `#version` beside `store.version` is the second version space; `rebase()` + exists to reconcile them after a seed. (`clearStreamRecords` clears + `seg:`/`hole:`/`attr:` and `:error`; `:complete` is not in its set and + survives a bump — §3 6's "exact record set that clears" clause, unpinned. + Under the full form of 1.4 it leaves with everything else.) +- **Lives twice in.** The three reset sites; the two version spaces. +- **Decides.** The frame of reference for 2.2; by itself it flips nothing. + +### 2.2 A version bump re-applies the root even when byte-identical + +**The root is the version's: a shell that did not change still applies as the +new version's — it answers the gate and re-creates the placeholders the +version's segments reveal into.** + +- **Mechanism today.** `FrameImpl.apply`'s bump arm calls `#resetStreamState()` + but not `#appliedRootValue = undefined` — only `rebind` does, with a comment + that argues exactly this ruling for the rebind case ("the new address's html + may be byte-identical … the value-skip must not swallow the new stream's + morph"). `#flush` then value-skips the root (R2); `#segmentReady("a")` fails + `#findPlaceholder` forever. Staging commits through the same path and has the + same hole. +- **Decides.** **C7 (c)** a v2 root byte-identical to v1's resets the segment. + (C7 a — all 720 orders — and b hold; d is the differing-root control.) +- **Carrier.** One `#applied` record, `{ version, root, revealed, fallbacks, + holes, assets, errorNotified, have }`, created fresh at every bump + (`this.#applied = applied(v)`) and at `rebind` (`applied(undefined)`, plus + the root record dropped — the one thing `rebind` does beyond a bump); there + is no second site to forget. `rebase()` becomes `#applied.version = undefined` + — or deletes, if the frame trusts the host's guard (the host already drops + stale writes before fanning out, so the frame's `v < #version` arm is a repeat; + whether the seed's version can out-rank a live counter in one per-address + space is the audit's "two version spaces" question — flag, not decided). + +### 2.3 A reveal is an apply + +**Content that becomes shown under a version is synced as content that arrived +under it: a reveal into a frame's range — a frame segment or a document +fragment — re-walks the revealed range for occurrences and applies what the +store holds for them; "the record arrived" and "the range is shown" are one +event seen from two sides, and either one completes the pair.** + +- **Mechanism today.** The stream face already does this: `#revealSegment` + syncs its materialized content (`#syncSlots(materialized)` inside the reveal + seam's `content()`). The document face does not: a `$df` into adopted markup + notifies `adoptBoundary`'s `fr.subscribe`, which runs `claimRegionFragments` + (#2978) and `drainRecords` (#2968) — a sync happens only if the drain finds a + *new* record (`drainRecords` → `host.apply` → `#flush` → `#syncSlots`). R3: a + reveal with no new record syncs nothing (C2 b, C2 a2 at reveal time); a record + drained *before* the reveal ran its sync while the range was inside + `