Skip to content

Spec 004C — Root identity and mount — the descriptor/manifest contract

Status: v1-required. The Stage-1 mount grammar, root identity, Root Descriptor v1, and the three-layer fail-loud duplicate/conflict detection that 004D-Freehand-Compiled-Grammar.md §Roots and mounting references and never restates. Owns the descriptor/manifest schema family (:rf.root/*); the Stage-5 Root Manifest is its additive extension (co-owned with 011). The ratified identity model is preserved exactly — a root is one React DOM render/hydration unit, a frame is one re-frame2 state world, roots ↔ frames are many-to-many, and mount position is never identity. [S1-CONFIRM] marks a conservative contract adopted where no other Spec ruled the combination — confirm before the surface hardens; not an open hole. Discharged entries are struck from the register below as their stage lands.

1. Root identity — required, host-authored, derivable

Every root has a root-id. It is REQUIRED identity — the root descriptor, the root manifest, instance records (004D §View identity and the instrumentation surface), and duplicate detection all key on it. It is host-authored (:root-id in the root opts map, §3), with a derivation default so the single-root common case stays one-liner clean:

  1. Authored wins. A :root-id in the opts map is the root-id, verbatim. Legal shapes: a qualified keyword (canonical: :page/shop) or a vector of a qualified keyword plus scalar disambiguators. Anything else is a compile error (identity opts are literals, §3, so the shape is always statically checkable — the diagnostic joins the compile-error roster, not the runtime catalogue).
  2. Derived otherwise. When :root-id is absent, the root-id derives from the mounted view's registered id:
  3. no :disambiguator → root-id = the view id itself (e.g. :shop/app). A single-root page may omit the disambiguator — this is the guide-01 counter path, zero ceremony.
  4. :disambiguator d (scalar: keyword, string, or integer) → root-id = [view-id d] (e.g. [:shop/product-panel :left]). Required whenever the same view mounts twice on one page and neither site authors :root-id.
  5. The mounted view (derivation source) is defined precisely: the extractor walks the root form's static top region (§6) and requires exactly one internal view there. Zero internal views (bare DOM root, foreign-component root) or more than one (fragment of two views) make derivation impossible → compile error with the didactic message "root form has no single mounted view — author :root-id".
  6. Provenance is recorded (dev only): the descriptor carries :root-id-provenance :authored | :derived | :manifest so duplicate diagnostics can say "both ids derived from :shop/app — add :disambiguator or author :root-id". :manifest is a hydrating root, whose id came off the wire in the server's Root Manifest rather than out of any client-side rule (011 §Root Manifest v1) — a third case, and reporting it as :derived would be a false statement about where a colliding id came from. Stripped from shipped manifests.

Root-id slug (one deterministic, injective function, used by the identifier-prefix default and synthesised locators — distinct valid root-ids ALWAYS yield distinct slugs). It is a decodable canonical form over the DOM-safe alphabet [A-Za-z0-9_-]: _ is the sole metacharacter — every character outside [A-Za-z0-9-] (including _ itself) is reversibly escaped _<lowercase-hex-code-unit>_, and each structural boundary carries an uppercase _-tag the escape never emits (_S keyword namespace/name separator; _V vector lead-in; _K/_T/_I a vector element's keyword/string/integer type). A keyword root encodes to enc(namespace) _S enc(name) (namespace absent → enc(name)); a vector root to _V followed by its type-tagged, escaped elements. :page/shop"page_Sshop"; [:shop/app :left]"_V_Kshop_Sapp_Kleft". Because every boundary is marked rather than inferred from a data character, the mapping is losslessly decodable and thus injective — no two distinct root-ids can alias to one slug (the earlier lossy "normalise every disallowed char to -" transform could: :a/b-c and :a-b/c both flattened to a-b-c).

2. The Root Descriptor v1 — the named, versioned S1 subset

The Stage-1 "root descriptor" is hereby named: Root Descriptor v1, key family :rf.root/*, versioned by :rf.root/schema-version 1. It is the compile-time static subset of the Stage-5 Root Manifest — same schema family, same version field, one compatibility rule (below). The compiler can emit it from a mount site alone; no server, no render, no reactivity.

{:rf.root/schema-version 1
 :root-id              :page/shop                 ; canonical, post-derivation (§1)
 :root-id-provenance   :authored                  ; dev only; never in shipped manifests
 :view-id              :shop/app                  ; the mounted view's registered id (§1.3)
 :props-shape          :literal                   ; :literal | :dynamic
 :static-props         {:promo :spring}           ; present iff :props-shape :literal (§5)
 :frame-plans          [{:frame-id :shop
                         :config-fingerprint "…"}] ; extracted ENSURE plans (§6)
 :template-fingerprint "…"}                       ; over the compiled root template

Every field is a per-root static fact the compiler bakes at the mount site's own expansion — see §2.1.

Fingerprint/digest algorithms — ownership. :render-fingerprint and the semantic normalization N it hashes are owned by 004B-UI-Tree-and-Conversion.md (§Semantic normalization). The :template-fingerprint and hook-signature-hash algorithms are defined by the S1 compiler-slice PR (rf2-vxgfnd.2); this Spec pins only the fields and their comparison semantics.

Root Manifest v1 (Stage 5) = Root Descriptor v1 (minus dev-only :root-id-provenance) plus the render-time extension keys, exactly the hydration-salient fields carried by 011 §Root Manifest v1:

Extension key Meaning When produced
:element-locator {:id "shop-root"} — §4 server render
:props actual serialised props values (Spec 011 EDN-safe encoder) server render
:frame-payload-ids full referenced payload set observed at render — plans ∪ provider-scoped frames (§6) server render
:render-fingerprint over the rendered structural output server render
:identifier-prefix resolved prefix the server actually used (§3) server render
:phase :server (the only v1 value; the field exists so a future phase is additive) server render

The compatibility rule (no churn):

  1. The manifest is a strict superset of the descriptor: every descriptor key appears in the manifest with identical name, type, and meaning. No key is renamed, retyped, or re-semanticised between S1 and S5.
  2. Readers MUST ignore unknown keys. S5 tooling reads S1 descriptors; S1-era tooling reads S5 manifests and simply sees no extension keys.
  3. Additive keys do not bump :rf.root/schema-version. Only a breaking change to an existing key bumps the integer; none is planned between S1 and S5 — the S5 manifest is additive by construction.
  4. One version field governs the family: a manifest declares the same :rf.root/schema-version as the descriptor it extends. Version incompatibility at hydration is :rf.error/root-manifest-invalid (§7).

This split resolves the 12-§3-postpones-S5 / 08-§2-requires-S1 tension without moving either stage: S1 ships the descriptor (compiler artefact, ui.test/Xray consumer); S5 ships the manifest as its extension.

2.1 The descriptor is per-root static facts

Every field of Root Descriptor v1 is a per-root static fact resolvable at the mount site's own expansion. The compiler bakes the descriptor at expansion into the emitted client code, and it rides each live-root registry entry (its :descriptor) and the compiler's build-emitted descriptor index. The descriptor carries no whole-build aggregate and is assembled by no read-time projection: the descriptor a consumer reads is exactly the static descriptor the compiler baked, :rf.root/schema-version 1 and schema-valid.

The two hosts read the same per-root descriptor:

  • Compiler / JVMre-frame.ui.compiler.root/descriptor-index returns the build's per-root descriptors. JVM tooling passes the explicit retained build-state/compiler-env; there is no process-global "latest build". An open, failed, or interleaved build cannot change a retained accepted snapshot.
  • Client / dev runtimere-frame.ui.client/descriptor-index (and per-root re-frame.ui.client/descriptor) reads the stored per-root descriptor off the live-root registry in O(1). It never recomputes identity from runtime-registered or currently loaded views, so runtime module-loading order cannot change it. A successful view-only hot reload updates a view's body without re-expanding its mount site; the stored descriptor is unchanged.

Direct unsaved no-pass REPL evaluation may replace a live view body and exercise the same generation/remount machinery as HMR, but it does not re-derive descriptors; only the next successful configured build/watch pass re-derives them from the recompiled sources. Macroexpand-only, never-evaluated and runtime-failed REPL forms therefore cannot pollute a later build.

2.1.1 The accepted-build transaction

The build adapter uses the build tool's retained functional build-state as the transaction boundary:

  1. At compile prepare, disposable scratch is seeded from the incoming accepted snapshot; isolated no-pass REPL bookkeeping and abandoned scratch are cleared. Every source the build tool will actually compile is pre-touched: removing a source's final UI declaration therefore evicts its prior rows, while output-present warm cache hits retain their accepted rows.
  2. At compile finish, the adapter harvests every authoritative member's descriptor — restored from the build tool's disk cache for a cache-hit member and freshly stamped for a compiled one — into the whole-build registries, and carries the candidate snapshot in the returned compiler-env. Because the registries are a pure aggregation over cache-durable per-namespace descriptors rather than a reconstruction of macro-expansion side effects, a warm daemon start reuses the disk cache instead of re-expanding the UI-consuming namespace set.
  3. The build tool retains that returned state only if all later configured optimize/check/flush/watch work succeeds. A downstream failure discards the candidate; the next attempt seeds from the prior accepted snapshot. No external commit/rollback atom and no private build-tool completion callback are part of the contract.

The accepted build-state and the active HMR runtime are last-known-good. A build tool may have partially rewritten its raw output directory before reporting a late failure; this contract does not claim filesystem rollback for that directory. Consumers activate only successful build output.

For Shadow the build hook is the one load-bearing top-level setting: it supplies the transaction and harvests the whole-build registries from the per-namespace analyzer descriptors the build tool persists to its disk cache and restores on a cache hit. Because those descriptors are present identically for a cache-hit member and a freshly compiled one, all members are present after a warm daemon start without disabling the cache — the earlier :cache-blockers #{re-frame.ui} requirement is removed (rf2-u53yy.1). The descriptor carrier is tested across shadow-cljs 3.4.0 through 3.4.11. A dev build that omits the hook publishes no registries; the failure surfaces at runtime, when a compiled view or root cannot be resolved.

Dev-only: the descriptor projections (re-frame.ui.client/descriptor and descriptor-index) are goog.DEBUG-guarded and absent from advanced production output, so the mechanism adds no production bytes or runtime work.

3. The mount grammar and the host signature set

This section states the contract on the re-frame.ui door — the compiled substrate, whose mount entry points are macros over literal root forms. The interpreted Freehand door realises the same grammar and identity model through ordinary runtime functions (v/mount, v/hydrate-root, v/unmount!); its paved-path spelling is The Freehand paved path below, and it ships no create-root and no render!.

The literal mount grammar:

(ui/mount root-form dom-node)         ; opts {}
(ui/mount root-form dom-node opts)

On this door, mount is a macro over a literal root form — the compiler must see the root to keep the AST closed and extract frame plans; a runtime-assembled vector is a compile error pointing at ui/view/ui/element. The third argument is the root opts map — this is where root identity rides; the "nowhere to provide a root id" gap is closed here:

Opt Tier Contract
:root-id identity authored root-id (§1.1); immutable for the root's lifetime
:disambiguator identity scalar; only meaningful when :root-id is absent (§1.2)
:identifier-prefix rendering string fed to React's identifierPrefix (use-id). Default: "rf2-" + root-id-slug + "-" (§1) — :page/shop"rf2-page_Sshop-". A shorter prefix such as "rf2-shop-" is only ever an authored value — the derivation never elides the namespace segment.
:on-uncaught-error :on-caught-error :on-recoverable-error host plain CLJS fns passed to the React root options. Host-tier option maps are not template positions — the 004D §Handlers are data — the callback law boundary law does not apply here. Invoked by React outside the re-frame2 commit path; to dispatch they must go through a live frame handle.

Identity opts must be compile-time literals at mount/create-root sites — they feed the descriptor and build-time duplicate detection; host-behaviour opts may be runtime values. :disambiguator is a mount-only derivation opt — it modifies how the root-id is derived from the mounted view. create-root fixes identity with no root form to derive from, so its identity set is :root-id / :identifier-prefix only; supplying :disambiguator there is a compile error (:rf.ui.compile/bad-root-opts).

The full host-tier signature set:

(ui/create-root dom-node opts)        ; ⇒ Root. Identity fixed here for the Root's lifetime; authored :root-id REQUIRED (no root form to derive from).
(ui/render! root root-form)           ; render/re-render the literal root form into the Root.
(ui/hydrate-root dom-node root-form)  ; ⇒ Root. Hydrating mount; identity comes FROM the manifest (§4).
(ui/hydrate-root dom-node root-form opts)  ; opts: host-behaviour tier only (error callbacks).
(ui/unmount! root)                    ; total teardown; releases the root claim at settlement (§7).
(ui/render-static root-form)          ; S5; proves + emits inert HTML; participates in identity (§7), emits no manifest/payload.
  • mountcreate-root + frame preflight + render!, one-shot, and is idempotent per root: calling it again with the same root-id and the same container re-renders the existing Root (the guide-01 reload path — frames found live, no re-seed). Same root-id on a different container, or a container already owned by a different live root, fail loud (§7).
  • For hydrating mounts, identity is manifest-authored: hydrate-root reads root-id and identifier-prefix from the manifest (§4); client opts MUST NOT carry identity fields — supplying :root-id/:identifier-prefix to hydrate-root is an error (:rf.error/root-manifest-invalid data names the conflicting key). The client must use the server's prefix or use-id hydration breaks.
  • Every root-form-accepting entry point requires the literal root form at the call sitemount, render!, hydrate-root, render-static, and ui.test/render (§8) alike; the same compile error rejects runtime-assembled vectors everywhere.
  • Verb kinds on the re-frame.ui door (ratified, shipped S1). On this door, mount, create-root, render!, and hydrate-root are macros; unmount! is a plain function. The four macros must see their argument shape at compile time — mount/render!/hydrate-root keep the literal root form's AST closed and extract the static frame plans, and create-root fixes literal identity opts (feeding the descriptor and build-time duplicate detection). unmount! takes a live Root value, has no form to compile, and stays a function. This is the shipped surface — re-frame.ui's var metadata and the generated public manifest classify each verb exactly so — and it settles the earlier [S1-CONFIRM] kind-label question; the stale "all fns" labelling and its route-to-Mike delta are retired. The compile-time contract is locked by the frozen roster: an unauthored create-root identity is :rf.ui.compile/missing-root-id, and an out-of-grammar opt (a stray :disambiguator among them) is :rf.ui.compile/bad-root-opts. The interpreted Freehand door ships a different roster: v/mount, v/hydrate-root and v/unmount! are ordinary runtime functions — documented as Fn — it publishes no create-root and no render!, and its one macro is render-static, the only Freehand verb whose site the build indexes (§7, Layer 1). A Freehand client mount carries a live runtime, so its duplicate-, container- and prefix-detection is the runtime claim-before-render of Layer 3 (§7), not a build-time index.
  • Frame preflight (ENSURE + :initial-events drain, exactly once, before React) runs before the first render! on a Root and before hydrate-root's hydration. This compiled host-root sequencing — preflight completing before createRoot/render! and before hydration ever touches the container — is owned here. Spec 002 owns only what each preflight step does: make-frame construction, its idempotent-replacement ENSURE (create-if-absent, reuse-no-reseed), and the synchronous, ordered :initial-events drain those sections define. That is the whole Spec-002 debt — pointedly not its frozen/transitional frame-root two-pass contract (empty first render → commit-phase useLayoutEffect ENSURE → populated second render), which runs the opposite order and scopes a component subtree, not a host root. This draft only pins what is extracted (§6).

4. Element locators

Locator vocabulary v1 is closed: {:id string}. No CSS selectors, no XPath, no positional locators — an id is stable under fragment reordering, which is the point (mount position is never identity).

  • SSR, host-authored container (the guide-08 shape — [:div#shop-root] in the page skeleton): the server render captures the container's id → :element-locator {:id "shop-root"}. A host-authored container without an id fails the server render (:rf.error/root-manifest-invalid, data {:missing :container-id}) — never a synthesised locator on a host-owned element.
  • SSR, emitter-synthesised container (the server emitter is asked to produce the container itself): id is generated deterministically as "rf2-root-" + root-id-slug — unique per page because the slug is injective (§1), so distinct root-ids yield distinct slugs, and root-ids are unique per page (§7): two synthesised locators can therefore never collide.
  • Manifest placement: the manifest rides a script element adjacent (immediately following sibling) to the root's container, EDN-safe-encoded per Spec 011. hydrate-root discovers the manifest positionally (adjacent to its dom-node) and takes identity from its content. [S1-CONFIRM] — the concrete script-element convention (type/data-rf-root attribute names) must be pinned in one place with the Spec 011 payload-encoding rows; this Spec requires only adjacent-sibling discovery + content-borne identity.
  • Client-only mounts have no element-locator — the host passes the DOM node directly; the descriptor never contains a locator (it is a manifest extension key, §2). Identity is root-id alone.
  • Hydration with a locator that resolves to no element (manifest present, container gone — fragment composition bugs) is :rf.error/root-container-missing (§7), scoped to that root per the failure-isolation contract of §7 below.

5. Extracting view-id and serialised props from a root form

  • :view-id = the registered id of the mounted view (§1.3). One mounted view per root form is the invariant the extractor enforces; nested internal views inside it are ordinary template content, not root identity.
  • Props: the mounted view's props map in the root form.
  • Every value a literal EDN datum → :props-shape :literal, recorded verbatim as :static-props in the descriptor.
  • Any non-literal expression → :props-shape :dynamic; no static props are recorded (no guessing).
  • Either way, the manifest :props records the render-time values, serialised through the Spec 011 EDN-safe encoder at server render. A value the encoder cannot carry fails the server render for that root: :rf.error/root-manifest-invalid, data {:unserialisable-prop :chart-fn} — fail-loud, never a silently truncated manifest. Hydration then applies the manifest's props (the server-rendered truth), and :props-shape :dynamic tells tools why descriptor and manifest may differ.

6. Frame-plan extraction and payload references

What the extractor consumes (the top-region grammar — which forms are legal wrappers and their diagnostics — is owned by the Spec-004 rewrite): the static top region of a root form is every node reachable from its root without crossing a control form (if/when/cond/case/for/…), a dynamic expression, an internal view boundary, a foreign component, presence, client-only, or portal. The walk descends through DOM elements, fragments, frame-root, frame-provider, and error-boundary — all unconditional, compile-extractable positions. ("Transitive" frame extraction means exactly this: plans are collected through arbitrarily nested top-region wrappers, in document order.)

  • frame-root plans: each top-region frame-root contributes {:frame-id … :config-fingerprint …} to :frame-plans. Its :id MUST be a compile-time literal (compile error otherwise — plans are static identity). Its :initial-events/config expressions evaluate at preflight (runtime values are legal); the :config-fingerprint hashes the plan's static source form (id + config forms), which is what conflict detection compares (§7). A frame-root anywhere outside a root form's top region is already a compile error (004D §Roots and mounting); this Spec adds nothing there.
  • frame-provider references are dynamic: frame-provider scopes a live frame handle (per the rf2-nyea0r split) — handles are runtime values, so provider-scoped frames are not statically extractable and do not appear in :frame-plans. Instead, the manifest's :frame-payload-ids (render-time) records the full referenced set the server render actually scoped: plan ids ∪ provider-scoped frame ids. That is how a manifest can list a provider-scoped frame — say :frame/session — that no static plan declares. Descriptor = static plans; manifest = full render-time reference set; additive per §2's compatibility rule.
  • Payload install remains idempotent and order-independent (ratified): the first hydrating root referencing a payload installs it; later roots find it live and do not re-seed. Conflict is the exception, and it is fail-loud (§7). Install is the state-boot call's work, not the DOM-adoption call's: it is re-frame.ssr/hydrate! that reads the payload and installs it, and ui/hydrate-root never does (011 §Client-side hydration boot helper). "The first hydrating root installs it" therefore describes the ORDER the two-call boot runs in across N roots on a page, not a step hidden inside hydrate-root.

7. Duplicate and conflict detection — fail-loud, three layers

All ids below follow the one-catalogue :rf.error/* scheme (Spec 009 rows land with their stage); each carries a data map naming both parties with source coordinates in dev.

Layer 1 — build time (S1). On the re-frame.ui macro door, the compiler indexes every mount/render!/hydrate-root/render-static macro site's statically resolved root-id. On the interpreted Freehand door only render-static reaches this layer — its mount/hydrate-root are runtime functions with no build-time site to index, and render-static alone has no client runtime, so it has no Layer 3. Layer 1 therefore catches only the cross-namespace, build-visible class of render-static duplicate — a same-namespace re-registration is REPLACED (watch/HMR tolerance, so a same-namespace double-render-static passes Layer 1), and independently rendered fragments one build never sees together are the response-local Layer 2 registry's to catch (below). A Freehand client mount, by contrast, is caught at Layer 3's claim-before-render (below). Either way, two indexed sites with equal root-ids reachable from one entry point's module closure = build error :rf.error/duplicate-root-id (build tier), data {:root-id … :provenance [:derived :derived] :sites [coord coord]} with the didactic fix ("same view mounts twice — add :disambiguator or author :root-id" when both are derived). The entry-point closure is the build-time projection of "one page": sites in disjoint entry closures never co-occur and may legally reuse a root-id. [S1-CONFIRM] — confirm entry-closure scoping (vs. whole-build strictness) when the first multi-entry consumer lands; entry-closure is the conservative reading that does not break multi-page builds. The macro door and its build-time root indexing are revisited only if a named consumer for mount-site descriptors or static frame plans materialises — not for door symmetry.

Layer 2 — server render time (S5). Page assembly registers each root (manifest and render-static root — static roots hold identity too, so a static and a live root can never claim one id) in a per-response registry. A second registration with an equal root-id fails the render: :rf.error/duplicate-root-id (server tier, projected per Spec 011). This is the layer that catches independently rendered page fragments composed into one response — the case Layer 1 cannot see. The same registry asserts identifier-prefix uniqueness across the page's roots (:rf.error/root-manifest-invalid, data {:conflict :identifier-prefix}) — two roots sharing a prefix would collide use-id output. The default prefix "rf2-" + root-id-slug + "-" is already collision-free for distinct root-ids because the slug is injective (§1); this check therefore backstops authored :identifier-prefix opts, which can still collide.

Layer 3 — client runtime (S1). Before this layer admits any public Root, the CLJS host must provide WeakRef, probed/captured once for bounded ViewCell ownership. Absence fails before preflight/React/registry mutation with :rf.error/ui-platform-incompatible carrying :platform :javascript, :capability :js/WeakRef, and :recovery :use-a-weakref-capable-javascript-runtime. FinalizationRegistry is optional: synchronous WeakRef compaction is the correctness path when it is absent; there is no strong fallback, polling, or per-render probe (see Spec 006's JavaScript host capability boundary).

A per-document live-root registry: create-root, hydrate-root, and mount register their root-id before any render; unmount! holds the exact root-id/container/identifier-prefix claim through the three-state lifecycle :live → :tearing-down → :released, releasing it only at the host settlement boundary. Registering an id already live in the document throws :rf.error/duplicate-root-id (client tier) before any render — the existing root is untouched (failure isolation). This is the last line, catching fragments composed client-side by independently shipped bundles. It also asserts identifier-prefix uniqueness across the document's live roots — the client-tier mirror of the Layer-2 server check — so two live roots that share an effective identifierPrefix fail loud on the second claim rather than colliding use-id output; release frees the prefix. A shared effective prefix arises two ways: an authored :identifier-prefix that aliases (the derived mount-path default "rf2-" + root-id-slug + "-" is injective over root-id, §1, so it never collides on its own); or a hydrating root whose Root Manifest OMITS :identifier-prefix — the server rendered under React's effective empty prefix "" (React's hydrateRoot has no distinct "no prefix" state: it canonicalizes an omitted identifierPrefix to "", and useId derives from it), which discovery canonicalizes so two such omitted-prefix roots are seen to share "" and the second is rejected. Discovery canonicalizes only the resolved identity; the manifest wire form keeps :identifier-prefix an optional extension key (§2), and no root-id- derived prefix is ever synthesized client-side (that would disagree with the server the manifest speaks for and break hydration).

A failed first mount that already allocated/registered its React Root is an exact-incarnation teardown transaction, not an inline registry delete: mark the whole claim :tearing-down before host cleanup; preserve the mount error as primary; attach a cleanup throw as rfUiRollbackCleanupError; force-dead that exact incarnation's framework owners on cleanup throw; and quarantine the claim fail-closed because the host container is unproven. A normally returning failed-first cleanup has no committed settlement reporter, so the exact predecessor claim releases at the next FIFO microtask. Same-stack same-id and different-id/same-container retries are rejected; a late predecessor release is root-identity-guarded and cannot evict a successor incarnation.

During :tearing-down, the existing three root diagnostics retain their IDs and expose the state: :rf.error/duplicate-root-id carries owner :provenance/:site plus :tearing-down? true under :existing; :rf.error/root-container-in-use carries :owner-root-id plus :existing {:tearing-down? true}; and :rf.error/root-not-live carries :existing {:tearing-down? true}. Two adjacent container/ownership faults and the prefix-uniqueness backstop share the roster:

Id When
:rf.error/ui-platform-incompatible required CLJS WeakRef absent at Root/ViewCell ownership admission
:rf.error/duplicate-root-id equal root-id at any layer above; client :existing includes :tearing-down? true while settlement is fenced
:rf.error/root-container-missing hydration locator resolves to no element (§4)
:rf.error/root-container-in-use create-root/mount on a node already owned by a different live or tearing-down root
:rf.error/duplicate-identifier-prefix a fresh create-root/mount/hydrate-root whose effective identifierPrefix is already claimed by a different live root — backstops authored :identifier-prefix aliasing, and hydrating roots that share React's effective empty prefix "" when the manifest omits :identifier-prefix (the derived mount default is injective over root-id, §1). For a same-root/same-container incumbent, the immutable-prefix row below takes precedence over this one
:rf.error/root-identifier-prefix-immutable a same-root/same-container re-mount — the idempotent reload path, which RE-RENDERS the live host root — whose effective identifierPrefix differs from the one that root was created under. Host root options are fixed at creation, so the running root cannot adopt the new value; the drift is refused before preflight and before any render, and the live root keeps its prefix claim. Takes precedence over :rf.error/duplicate-identifier-prefix once the incumbent is known: drift toward a value another root owns is still immutable-prefix drift (recovery: unmount first), because every prefix but the incumbent's is equally forbidden for that reused root
:rf.error/root-not-live render! on a Root whose id is no longer live — unmount!ed, tearing-down, or superseded by a newer root claiming the same id (guarded like unmount!, but fails loud rather than no-op, before any side effect)
:rf.error/root-manifest-invalid manifest missing/unreadable at hydrate, schema-version incompatible, identity opts passed client-side, unserialisable props at emit, prefix conflict
:rf.error/frame-payload-conflict below
:rf.ssr/hydration-mismatch server↔client render-tree fingerprint/digest disagreement at hydration (an existing Spec 009 catalogue row — unchanged by this Spec)

Payload/frame-config conflict — fail-loud at preflight. At any root's preflight (hydration or client mount), before install/hydrate:

  • a referenced payload id already installed with a different content digest, or
  • a frame plan whose :config-fingerprint differs from the installed frame's recorded plan fingerprint and was recorded by a different root,

fails that root with :rf.error/frame-payload-conflict. The runtime plan-conflict arm carries data {:frame-id … :installed {…} :arriving {:config-fingerprint … :root-id …}}. :installed is the external projection of the recorded install/adopt record — not the record itself: it carries :config-fingerprint plus either :installed-by root-id (a root-OWNED record) or :adopted-by root-id + :adopted true (a boot-authoritative frame this root only scopes), optionally followed by the attempt-evidence flags of §7.1. The record's internal :rev is stripped from the projection. The installed frame and the roots already using it are untouched — failure scoping is precise: a bad frame payload affects exactly the roots referencing it. There is no first-wins silent merge and no last-wins overwrite. A same-root re-declaration whose fingerprint differs is a surgical refresh (an HMR config edit — durable state survives, :initial-events re-recorded not replayed), not a conflict; a matching fingerprint is the ratified idempotent no-op (no re-seed) — but §7.1 qualifies BOTH: the surgical refresh and the ratified no-op are admitted only while the arriving plan can PROVE it still owns the incarnation live under the id, so a same-root plan meeting a same-id successor (the installed incarnation torn down and re-created under the id) or a token-less legacy row does NOT refresh or no-op — it fails loud under :scope-config-less-or-own-the-lifetime. Layer 1 additionally rejects at build time two plans for one frame-id with differing config fingerprints inside one entry closure (compile error — the didactic message points at boot/event infrastructure, per 004D §Roots and mounting). (The S5 hydrate arm — a referenced payload id already installed with a different content digest — carries its own content-:digest slot when server rendering lands: the same error id, a distinct conflict trigger.)

7.1 Root-attempt evidence — authority, committed scope, and settlement

An install record proves plan authority. It does not prove a committed React root. These are independent axes, and preflight keeps them independent: no prose here may claim a root or its DOM committed merely because root.render returned, a host update was scheduled, or a frame remained live.

Authority — who may write the record. An :installed-by record names the root that ENSUREd the frame, but that name alone does not prove the incarnation live under the id NOW is the one that root installed. Ownership is proven against the LIVE incarnation token: the value the install recorded carries the frame's :rf.frame/incarnation-token, and the record owns the current live frame only while that recorded token is identically the live frame's token. On that proof — and only on it — that root, and only that root, may refresh the record or take the ratified no-op (a same-root fingerprint change is the surgical HMR refresh above, not a conflict). A recorded token that names a torn-down incarnation — the installed frame was destroyed and re-created under the same id, so the row now names a same-id SUCCESSOR it never installed — proves nothing, and neither does a token-less legacy row a defonce ledger carried across a reload; a config-bearing plan that meets such a live-but-unprovable frame fails under the SAME :scope-config-less-or-own-the-lifetime recovery as the boot-authoritative case below. An :adopted-by + :adopted true record merely SCOPES a boot/external frame — adoption is create-if-absent scoping and never transfers ownership. A config-BEARING plan that meets a boot-authoritative frame (plan-less and live, or already adopted) therefore fails the arriving root with :rf.error/frame-payload-conflict under recovery :scope-config-less-or-own-the-lifetime rather than discarding its config over the boot config: either boot the frame config-less and scope it with a config-less frame-root, or drop the boot rf/make-frame and let the frame-root own the lifetime. This is a distinct conflict trigger from the differing-fingerprint arm, which recovers with :align-frame-plan-config.

Committed root scope — :committed true. Set ONLY at the client's host-commit boundary, after a host render has actually committed; never by plan execution. It is carried FORWARD across a same-owner refresh, so a refresh of an already-committed record stays committed. It distinguishes a genuinely committed live root from a fresh install or a retry of an incomplete mount.

Exact-write binding — the internal :rev. Every install, refresh, and adopt write mints a globally monotone install-record revision. Evidence is marked or cleared only while the current record still carries the exact :rev of the write being settled and that write's own root still owns the record. An overtaking overwrite, a destroyed-then-recreated frame, or an unrelated equal-plan foreign root can therefore never mark, clear, or settle another attempt's evidence. A rejected settlement never mutates the record; it reports :rf.error/frame-preflight-evidence-mismatch and lets independently authorized siblings settle. :rev is internal: it appears in no error payload and in no tool/test read.

Attempt evidence — failure is labelled by committed scope, not by action. When an attempt fails, each record it already wrote is labelled by provenance:

Provenance The write was On failure
:fresh an install, or a refresh of a never-committed record :mount-incomplete true — the mount never completed and no root scopes it
:live a refresh of an already-committed record, or an adopt of a live boot frame :preflight-attempt-failed true — neutral attempt evidence; the committed scope or boot frame PERSISTS (Q49), only the attempt failed
:found-live the ratified same-fingerprint no-op never marked; only committed or cleared at a successful host boundary

Failure reaches the records by two complementary paths. A plan that throws mid-run (the plan phase) labels the siblings it already wrote on the way out. When every plan succeeds but the client's post-preflight ownership fence, element thunk, or first host render then throws (the host phase), the client aborts the attempt under the same provenance rule. A successful host boundary instead finalizes: it sets :committed and clears any stale :mount-incomplete / :preflight-attempt-failed from that attempt's records.

The settlement matrix.

Scenario Record after Evidence
Fresh install, host commits :installed-by :committed true
Fresh install, plan or host phase fails :installed-by :mount-incomplete true
Same-root refresh of a committed record, host commits :installed-by, fingerprint advanced :committed true (carried forward)
Same-root refresh of a committed record, attempt fails :installed-by, prior render intact :preflight-attempt-failed true
Same-root retry of an incomplete mount :installed-by :fresh again — still :mount-incomplete until a host commit
Equal-plan foreign root unchanged — foreign root scopes but writes nothing none; it gains no settlement rights
Boot adoption, host commits :adopted-by + :adopted true :committed true (the adopting root's scope)
Boot adoption, attempt fails :adopted-by + :adopted true :preflight-attempt-failed true; boot frame stays live and authoritative
Stale / overtaken attempt settles unchanged rejected: :rf.error/frame-preflight-evidence-mismatch, no mutation
Destroy, then re-create the same id record PRUNED on destroy none — a genuinely new lifetime, never a resurrected dead-lifetime record

Internal versus published. Published in the :installed projection and in the tool/test read: :config-fingerprint, :installed-by / :adopted-by + :adopted, :committed, :mount-incomplete, :preflight-attempt-failed. Internal, never published: :rev. The diagnostic projection and the internal record are distinct values; promoting an internal field into the projection is a separate contract decision, not a side effect of making a sentence true.

Losing the exact authority mid-preflight fails CLOSED with :rf.error/frame-preflight-lifecycle-loss rather than mounting over an absent frame, binding a stale refresh to a replacement, or settling from a stale no-op — see that id's row in 009 §Error event catalogue for the three :kind arms.

8. Client-only non-hydrating mounts — identity and defaults

The complete default story for (ui/mount root-form dom-node) with no opts and no server in sight (guide 01):

Aspect Default
root-id derived: the mounted view's id; single-root page needs no disambiguator (§1)
descriptor emitted at compile time regardless — Xray/instance records key on it from S1; S5 is additive (§2)
element-locator none — the host-supplied DOM node is the container; locator is a manifest-only key (§4)
identifier-prefix "rf2-" + root-id-slug + "-" (§3)
manifest / digests / fingerprint validation none — nothing to validate against; hydration errors cannot occur by construction
frame plans extracted and preflighted identically to the SSR path (§6) — ENSURE semantics do not fork on mount kind
duplicate detection Layers 1 and 3 (§7); Layer 2 does not exist without a server
phase no :phase anywhere — phase is a manifest key; the client-only root phase flip (004D §Interop and boundaries) does not apply (there is no fallback pass)

9. ui.test/render — accepted root-or-view forms

(ui.test/render root-or-view opts) accepts exactly two forms:

  1. A view reference (compile-resolved Var/symbol of a defview). Props ride {:props p}. A frame rides {:frame f}, minted with rf/make-frame + :initial-events and caller-ownedrender binds it for the render but never creates or destroys it, so release it with rf/with-new-frame (eval-bind-run-destroy) or an explicit rf/destroy-frame! teardown per Spec 008 §with-frame and with-new-frame, or the make-frame value leaks. With no frame, structural rendering proceeds and any sub raises :rf.error/no-frame-context — honest, not defaulted.
  2. A literal root form — the same literal top-region grammar mount takes, wrappers included, tightened to exactly one mounted view per test root (§1.1's authored-:root-id multi-view allowance does not apply here; two views → :rf.ui.compile/bad-test-root, remedy: wrap the composition in one defview). Then:
  3. {:props p} is rejected (didactic: props live in the form);
  4. the form's frame-root plans run preflight ENSURE against the test registrar, minting fresh test frames from the plans;
  5. {:frame f} alongside a plan-bearing root form is rejected ("the root form owns its frames — pass a bare view to control the frame"); with a plan-free form it combines. No other Spec ruled this combination; rejection is the conservative contract (no ambiguity about which frame is ambient), and it ships as the :rf.error/ui-test-bad-opts :frame-branch.

A runtime-assembled vector is the same compile error as at mount (§3). In both forms, {:sub-overrides {query value}} combines freely — it is the explicit JVM override door (006 §The static override handle), with :owned? false honesty unchanged. Registrations come from the loaded namespaces; ui.test has no frame constructor — mint one with rf/make-frame (002 §make-frame), per 008 §The ui.test contract.

Test-scope identity: each ui.test/render call is its own document scope — root identity derives normally (so descriptor-shaped assertions work) but the duplicate registry never spans two render calls. Tier-3 with-root mounts participate in the real per-document registry of the jsdom/browser document, and its total teardown unregisters (a leaked registration failing a later mount is a test-harness bug, fixture-pinned).

The Freehand paved path

The interpreted Freehand substrate realises this contract under Freehand names, through its single public door (re-frame.freehand, conventionally aliased v). The mount grammar and the identity model above are unchanged — this section adds the paved-path spelling and the two guarantees the interpreted one-root mount ships first, and nothing here restates the numbered contract it points into.

The minimal one-root mount

The minimal one-root spelling is a bare declared view at the head, no opts:

(v/mount [app {:label "hello"}] (js/document.getElementById "app"))

Its identity is derived, not authored (§1.2): with no :root-id and no :disambiguator, the root-id is the mounted view's own registered id — the single-root page authors nothing. The mount derives the minimal Root Descriptor (§2) from the site alone,

{:rf.root/schema-version 1
 :root-id            :app.shop/app     ; the mounted view's id
 :view-id            :app.shop/app
 :root-id-provenance :derived}

and returns a live root handle carrying it.

The same form renders structurally. The identical [app {…}] spelling is what the JVM renders through the interpreted tree emitter — there is no second mount verb for the host with no DOM, only the structural render the door does not need to publish. Mounting and structural rendering are one spelling on both hosts, which is what lets one conformance row bind the browser DOM and the JVM tree to the same declaration. The mounted view is a real boundary node in that tree — its qualified id, its props and its expansion — so the root's occurrence is addressable structurally, not flattened into anonymous markup.

Everything below adds to that spelling without changing it: a page of one root, with no opts, still authors nothing.

Hot reload keeps the root identity and the mounted occurrence

A declared view is a descriptor value, so a hot reload mints a new descriptor object for the redefined view. But the value it carries holds the same qualified :view-id, and the root-id derives from that id (§1.2), so the reload's root-id is unchanged — a redefinition does not move a qualified keyword. The descriptor object's body and generation churn is an internal fact of the reload, never part of the identity.

That is what makes mount idempotent per root (§3) the reload path: re-running the mount with the same root-id into the same container finds the root live and RE-RENDERS the existing host root rather than allocating a second one. A second host root on one container tears the whole tree down and re-seeds it; reusing the live one is the first half of preserving the occurrence across the redefinition. When frame binding lands, the frame is found live at the same fingerprint on that same re-render (§6), so durable state survives the edit too.

The reused root is only half the claim, and the weaker half. Identity of the host root object says nothing about what hangs beneath it. The boundary under that root is a real component of the host renderer, and a host reconciles boundaries on component identity: hand it a different one for the same position and it unmounts the subtree and mounts a fresh one — the reloaded body appears on screen, correctly, on top of an occurrence that has been thrown away. Everything the occurrence was holding goes with it: an uncontrolled input's text, a scroll offset, focus, and the ViewCell that owned the view's dependencies. A reload that does that has reseeded the page as surely as a second root would have.

So the second half: a compatible redefinition renders through the boundary the host already mounted. The emitter caches one boundary per qualified view id — the same id the root's identity derives from — and a redefinition publishes its new body through that boundary rather than replacing it. The reloaded body renders and the occurrence beneath it survives.

Reuse is exactly why the identifierPrefix cannot move. A host root's options are fixed when it is created, so the running root a reload re-renders has no way to adopt a new effective prefix. A re-mount that authors a different one is therefore refused — :rf.error/root-identifier-prefix-immutable, recovery :unmount-before-changing-identifier-prefix — at the same admission gate the three claims of §7 are asserted at: before preflight and before any render, with the live root's prefix claim, its frame and its committed DOM untouched. Silently accepting it would be the worst of the three outcomes available: the registry would record a prefix use-id never emits, and would treat the old one as free for a second root the live root is in fact still rendering under — manufacturing the very cross-root use-id collision the prefix claim exists to prevent. What is refused is drift, never the re-mount: a re-mount offering the same effective prefix — authored verbatim, or derived because neither site authored one — is the ordinary idempotent reload.

This drift check takes precedence over the cross-root prefix-uniqueness check. Once the same-root/same-container incumbent is known, a requested prefix that differs from its own is reported as :rf.error/root-identifier-prefix-immutable — even when that requested value is a prefix some OTHER live root already owns. The alternative, reporting :rf.error/duplicate-identifier-prefix because the requested value collides, sends the author chasing a distinct prefix; but every prefix other than the incumbent's is equally forbidden for that reused root, so the honest recovery is :unmount-before-changing-identifier-prefix, not a different value. A fresh root (no same-root incumbent) requesting a prefix another root owns still reports :rf.error/duplicate-identifier-prefix — the drift check is a no-op with no incumbent, and the uniqueness backstop is exactly right there. Both checks are read-only and run before preflight and any render.

The host error callbacks move the opposite way, and honestly. The same createRoot-fixes-the-options fact governs :on-uncaught-error, :on-caught-error and :on-recoverable-error, but their correct lifetime is the inverse of the prefix's. Identity must not move, so a drifting prefix is refused; a callback is meant to move — a hot reload's fresh closure is the ordinary case, and refusing it would make HMR needlessly brittle. So the callbacks are late-bound: rather than pass the opts' closures straight to React — where the first mount's would be fixed and every later one silently ignored — the root installs one stable delegate per key that reads the live callback off a per-root cell, and an accepted re-mount advances that cell. React holds the delegate for the root's lifetime; the delegate's target is what the reload moves. The effective callback for each key is the one from the most recent accepted mount; a re-mount that omits a key it earlier supplied restores React's own default reporting for that error kind (an uncaught or recoverable error to reportError, a boundary-caught one to console.error), so a stale closure is never silently retained and a newly supplied one is never dropped. The delegate is installed for all three keys regardless of the first mount's opts, so a callback a reload adds takes effect too. A rejected re-mount — a prefix drift, a superseded incumbent — advances nothing: the cell moves only on the accepted reload's own re-render.

Compatible shell versus clean remount

A redefinition is compatible when it does not move the boundary's hook skeleton — the ordered set of host hooks the emitter's shell owns for that declaration. Reusing a boundary across a moved skeleton is not conservative, it is wrong: the host's hook state is positional, so the new shell would read the old occurrence's slots.

In the interpreted mode the skeleton is not a function of the body at all. An interpreted body is unrestricted Clojure that produces markup and calls no host hooks; every hook a Freehand boundary owns belongs to its atomic shell, in a fixed order. So every interpreted body edit is compatible, however large. What moves the skeleton is a change of lowering — the compiled tier renders through its own shell, and its capability-elision verdict omits the ViewCell, and with it every hook above, for a view with no reactive site. Promotion between modes (adding {:compiled true} and reloading) is therefore the incompatible edit, and it earns exactly one clean remount: a new boundary, the old occurrence disconnected, no attempt to carry state that the new shell has nowhere to put. What is never permitted is the third outcome — serving the promoted declaration the stale interpreted boundary, which would render the pre-promotion body indefinitely with nothing to say so.

The publication seam carries a body revision, and it advances when a new body is published — not when an unchanged tree is walked again. A render that began against the previous body and reaches its commit afterwards is stale at that revision and publishes nothing: no dependencies, no event sites, no evidence (see 006 §The Freehand atomic shell). The host simply renders again at the new body.

Conformance: FH-ROOT-002.

Several roots on one page

A page is N roots, and the whole reason to build it that way is that the roots are independent. Independence is not a hope: it is three claims each root makes in the per-document registry, and each of them is asserted before the root renders anything.

The claims are the root's id, its container, and its effective identifierPrefix, and the diagnostics are the ones §7 already names — :rf.error/duplicate-root-id, :rf.error/root-container-in-use, :rf.error/duplicate-identifier-prefix. Every one of them is raised with the roots already on the page untouched. That is what failure isolation means at admission time, and it is cheaper than it sounds: a mount that has written nothing has nothing to roll back.

Two roots of the SAME view are the case the derivation default cannot answer on its own, because both would derive one id. The author says which fact distinguishes them, in one of two spellings:

(v/mount [panel {:side :left}]  left-node  {:disambiguator :left})   ; ⇒ [:shop/panel :left]
(v/mount [panel {:side :right}] right-node {:root-id :shop/right})   ; ⇒ :shop/right

:disambiguator appends a scalar to the derived id; :root-id names the id outright. The descriptor records which happened (:root-id-provenance), so a later duplicate diagnostic can say both ids derived from the same view rather than leaving the reader to work out why two mounts collided.

Distinct ids give distinct identifierPrefix values for free, because the slug is injective (§1) — so the prefix check is never tripped by the framework's own derivation, only by an authored prefix that aliases.

Conformance: FH-ROOT-003.

Preflight runs before React

A root's frame is settled before createRoot, not by an effect that runs after the first paint. The ordering is the whole point: a view body that reads a subscription on its first render must find a frame that is already there, and a root that cannot get one must fail before it has put anything on the page.

The interpreted spelling is the :frame opt, and its two shapes are two different lifetimes:

(v/mount [app {}] node {:frame {:id :shop/main :initial-events [[:shop/boot]]}})  ; ENSURE — the root owns it
(v/mount [app {}] node {:frame :shop/main})                                       ; SCOPE  — something else owns it

The ENSURE shape creates the frame if it is absent and drains its :initial-events; meeting the same plan again is the ratified idempotent no-op, and re-seeding is precisely what it must not do — but the no-op is admitted only while the root can PROVE it still owns the incarnation live under the id (§7.1): the value its install recorded carries the live frame's current :rf.frame/incarnation-token. An equal fingerprint over a frame the root can no longer prove it owns — the installed incarnation was destroyed and re-created under the same id by external code, or a token-less legacy row survived a reload — is NOT a no-op; it fails loud under :scope-config-less-or-own-the-lifetime, before make-frame and before React, rather than silently adopting a successor the root never installed. The SCOPE shape creates nothing, and a target naming no live frame fails loud rather than scoping every read below the root to a frame that is not there.

Plans meeting one frame are reconciled by the §7 rule, unchanged: an equal config fingerprint over a PROVEN-owned incarnation is the no-op, and a DIFFERENT fingerprint recorded by a DIFFERENT root fails that root with :rf.error/frame-payload-conflict, before install and before React. The installed frame and the roots already using it are untouched — a bad plan affects exactly the roots carrying it.

Conformance: FH-ROOT-004.

Total teardown

(v/unmount! root) is total, and "total" is a claim about what is left rather than about what was called. Afterwards the root holds no id, container or prefix claim; the host root is unmounted, so every ViewCell beneath it has disconnected and released every dependency it owned and retired every callback it published; and the root's reference to its frame is gone. A frame the root ENSUREd is DESTROYED once no live root still references it — and destroyed by the EXACT incarnation value the install recorded, never by the bare frame-id. The recorded value carries the installed incarnation's token, so teardown consumes precisely that incarnation: a stale installer whose frame was already destroyed and re-created under the same id no-ops rather than reaching through to kill the successor, and a token-less legacy row — one a defonce ledger carried across a reload without a recorded value — likewise leaves the live frame untouched rather than destroying an incarnation it cannot prove it owns. A frame the root merely SCOPED is left exactly as it was found, because the root borrowed it.

The count that matters is zero, and it is asserted as zero — not as "small", and not as the absence of a visible symptom. A leak here is a long dev session's leak: a released root whose subscriptions still recompute on every write is invisible until the page is slow for reasons nobody can attribute.

unmount! is guarded and idempotent: a root already unmounted, or one superseded by a newer root that claimed its id, is a no-op rather than a throw. Tearing down on a stale handle's behalf would tear down the successor, which is a worse answer than doing nothing.

Conformance: FH-ROOT-005.

10. Stage placement

Surface Stage
Root Descriptor v1, mount grammar + identity opts, derivation + slug, Layer-1 + Layer-3 duplicate detection, client mount/create-root/render!/unmount!, ui.test/render forms, frame-plan-conflict preflight (the :config-fingerprint ENSURE arm — build tier S1c, client-mount/render! runtime tier S2c) S1 (the S1 "root descriptor" deliverable, defined by §2 above; stage roster per EP-0030 §Stages S1–S7)
Root Manifest v1 extension keys, the hydrating-root boot sequence (manifest discovery/validation → payload install → hydrate) — ui/hydrate-root owns manifest discovery/validation and DOM adoption, while payload install and :rf/hydrate belong to re-frame.ssr/hydrate!, the state-boot call that runs first — locator generation, Layer-2 registry, payload-content-digest conflict preflight (the hydrate arm of :rf.error/frame-payload-conflict only — the plan-fingerprint arm ships at S1c/S2c, row above), render-static identity participation S5 (the S5 "root manifests" deliverable — the additive extension of S1; see 011 §Root Manifest v1)

Q24–Q28 coverage

  • Q24 (render root-or-view forms; props/frames/registrations/overrides) → §9.
  • Q25 (where root-id is authored/derived; the full signature set) → §1, §3.
  • Q26 (S1 descriptor schema; churn-free evolution to the S5 manifest) → §2.
  • Q27 (locator generation SSR/client; duplicate detection across compilation units/page fragments) → §4, §7.
  • Q28 (frame-plan extraction) → §6 pins what the extractor consumes and the conflict ordering/diagnosis; the top-region syntactic grammar (which wrapper forms are legal and their compile diagnostics) is owned by the Spec-004 rewrite per the disposition's Q-ownership map.

[S1-CONFIRM] register

  1. §4 — the manifest script element's concrete attribute/type convention; pin alongside the Spec 011 payload-encoding rows.
  2. §7 — entry-point-closure scoping as the build-time projection of "one page" for duplicate root-id detection (vs. whole-build strictness).
  3. ~~§9 — rejection of {:frame …} combined with a plan-bearing root form in ui.test/render.~~ DISCHARGED at S1. The rejection ships: re-frame.ui.test raises :rf.error/ui-test-bad-opts when a plan-bearing root form carries :frame (a plan-bearing form owns its frames — a frame plan and an explicit frame are two ways to say one thing). The conservative contract was confirmed, not revised.