Spec 004C — Root identity and mount — the descriptor/manifest contract¶
Status: v1-required. Root identity, Root Descriptor v1, element locators, render-time props, the hydrating-root boot sequence, and fail-loud conflict detection. Owns the descriptor/manifest schema family (
:rf.root/*); the Stage-5 Root Manifest is its additive extension (co-owned with 011). The identity model is exact — 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.What realises this contract.
re-frame.ssr.manifestvalidates, assembles, emits and discovers Root Manifests (§2, §4, §5);re-frame.ssr.installruns a hydrating root's preflight and keeps the payload-install ledger (§3, §6, §7); andre-frame.ssr/hydrate!runs the boot sequence of §10. This Spec owns no mount verb: the client-root grammar is 006 §The client root, which every React view adapter publishes,re-frame.frescoincluded. No shipped substrate derives a Root Descriptor from a mount site, so a descriptor reachesre-frame.ssr.manifest/manifestfrom its caller.
1. Root identity — required, host-authored, derivable¶
Every root has a root-id, and it is the root's identity. The root descriptor and
manifest carry it as :root-id; a hydrating root reads it from its manifest's content
(§3), and the payload-install ledger attributes each claim to it (§6). A root-id is a
qualified keyword (canonical: :page/shop) or a vector of a qualified keyword plus
scalar disambiguators — keyword, string, or integer ([:shop/product-panel :left]).
The dev-only :root-id-provenance records how the root-id was arrived at, and never
rides a shipped manifest (§2).
Root-id slug (one deterministic, injective function, used by synthesised
locators (§4) — 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 (a lossy "normalise every disallowed char to
-" transform would: :a/b-c and :a-b/c would both flatten to a-b-c).
2. The Root Descriptor v1 — the named, versioned S1 subset¶
The Stage-1 "root descriptor" is Root Descriptor v1, key family
:rf.root/*, versioned by :rf.root/schema-version 1. It is the static subset of
the Stage-5 Root Manifest — same schema family, same version field, one
compatibility rule (below). Every field is a per-root static fact about the root's
source: no server, no render, no reactivity.
{:rf.root/schema-version 1
:root-id :page/shop ; canonical (§1)
:root-id-provenance :authored ; dev only; never in shipped manifests
:view-id :shop/app ; the mounted view's registered id (§5)
:props-shape :literal ; :literal | :dynamic
:static-props {:promo :spring} ; present iff :props-shape :literal (§5)
:frame-plans [{:frame-id :shop
:config-fingerprint "…"}] ; static ENSURE plans (§6)
:template-fingerprint "…"} ; over the root template
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 :config-fingerprint
algorithms belong to the descriptor's producer, not to this Spec;
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):
- 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.
- Readers MUST ignore unknown keys. S5 tooling reads S1 descriptors; S1 tooling reads S5 manifests and simply sees no extension keys.
- Additive keys do not bump
:rf.root/schema-version. Only a breaking change to an existing key bumps the integer; the S5 manifest is additive by construction. - One version field governs the family: a manifest declares the same
:rf.root/schema-versionas the descriptor it extends. Version incompatibility at hydration is:rf.error/root-manifest-invalid(§7).
The split lets the descriptor stand without a server: S1 is the descriptor, and S5 ships the manifest as its extension.
3. The mount grammar and the host signature set¶
This Spec owns no mount verb. The client-root grammar — client-root, render!,
unmount! — is 006 §The client root,
and re-frame.fresco realises it as h/client-root, h/render! and h/unmount!:
the first render! through a handle creates its root, or hydrates one under
{:hydrate? true}. What this section owns is the two rules a root keeps about identity
and ordering.
- A hydrating root hydrates as the server rendered it. Its root-id is the
:root-idin the manifest adjacent to its container (§4), whichre-frame.ssr.install/preflight!reads from the manifest's content. A container with no discoverable manifest fails loud with:rf.error/root-manifest-invalid, data{:missing :manifest}: a hydrating root never guesses its identity. ItsidentifierPrefixmust be the one the server rendered under — the manifest's:identifier-prefix, where an omitted one is React's empty prefix""— or everyuseIdresolves differently from the server's bytes. - Frame preflight runs before React. A root door that ensures its frame does so
before
createRoot: ENSURE creates the frame if absent and drains its:initial-eventssynchronously, so the first paint is the seeded one, and a frame already live is joined as it stands — no re-seed, no config refresh.re-frame.fresco.impl.mount/root!keeps this order throughensure-frame!; Fresco's public door names no frame, because its tree does, onh/frame-rootorh/frame-provider. A hydrating root's frame state arrives throughre-frame.ssr/hydrate!, which seeds it before the host mounts (§10). Whatmake-frameconstruction and the synchronous, ordered:initial-eventsdrain do is Spec 002's. Theframe-roottwo-pass contract (empty first render → commit-phaseuseLayoutEffectENSURE → populated second render) runs the opposite order and scopes a component subtree, not a host root.
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 a page's root-ids are required to be unique (§7): two synthesised locators collide only on a page that breaks that requirement. The registry that refuses such a page is §7's Layer 2, whose status is specified, not yet implemented, so until it ships nothing refuses a page that renders one root-id twice. - Manifest placement: the manifest rides a script element adjacent (immediately
following sibling) to the root's container, EDN-safe-encoded per Spec 011.
Discovery (
re-frame.ssr.manifest/discover) finds the manifest positionally (adjacent to its container) and takes identity from its content. The script element's convention —type="application/edn"and the baredata-rf-rootmarker — is 011 §The wire form. - 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.
5. Extracting view-id and serialised props from a root form¶
:view-id= the registered id of the root's mounted view. Nested 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-propsin the descriptor. - Any non-literal expression →
:props-shape :dynamic; no static props are recorded (no guessing). - Either way, the manifest
:propsrecords 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 :dynamictells tools why descriptor and manifest may differ.
- Every value a literal EDN datum →
6. Frame-plan extraction and payload references¶
:frame-plansrecords the root's static frame-ENSURE plans in the descriptor, one{:frame-id … :config-fingerprint …}per plan: the plan's literal frame id, and a fingerprint of its static config source.frame-providerreferences are dynamic:frame-providerscopes a live frame handle — 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 is idempotent and order-independent: 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 a view layer's hydrating 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 the hydrating root.
7. Duplicate and conflict detection — fail-loud, three layers¶
All ids below follow the one-catalogue :rf.error/* scheme; each carries a data map
naming both parties.
Layer 2 — server render time (S5).
Status: specified, not yet implemented. No shipped code keeps this registry or raises its two conflicts.
re-frame.ssr.manifestsupplies the manifest shape, wire and finder the registry will use.
Page assembly registers each root's manifest 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). The registry is what catches independently
rendered page fragments composed into one response, whose roots no single render
sees together. 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 useId output. Uniqueness is over each
root's effective prefix: its authored :identifier-prefix, or React's empty prefix ""
where none is authored (§3). Two roots that both omit it therefore conflict exactly as
two equal authored prefixes do.
| Id | When |
|---|---|
:rf.error/duplicate-root-id |
equal root-id in one response's page registry (Layer 2) |
:rf.error/root-manifest-invalid |
manifest missing or unreadable at hydrate, not a Root Manifest v1 (schema-version incompatible), a host-authored container without an id, unserialisable props at emit, prefix conflict (Layer 2) |
:rf.error/frame-payload-conflict |
below |
:rf.ssr/hydration-mismatch |
server↔client render-tree fingerprint/digest disagreement at hydration (a Spec 009 catalogue row this Spec does not own) |
Payload conflict — fail-loud at hydration preflight. At a hydrating root's
preflight, before any install, a referenced payload id already installed with a
different content digest fails that root with :rf.error/frame-payload-conflict
(re-frame.ssr.install/payload-install-decision!). Its data names both parties — the
:payload-id, the :installed record (:digest, :installed-by), and the
:arriving {:digest … :root-id …} — with recovery
:render-the-page-from-one-response. The installed payload 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; an equal digest is the idempotent no-op of §6.
10. Stage placement¶
| Surface | Stage |
|---|---|
| Root Descriptor v1, the root-id and its slug | 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) — re-frame.ssr/hydrate! runs re-frame.ssr.install/preflight! (manifest discovery/validation and the install decision) and then :rf/hydrate, before the view layer's hydrating root adopts the DOM — locator generation, Layer-2 registry, payload-content-digest conflict preflight (:rf.error/frame-payload-conflict) |
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) → no Spec:
there is no
ui.test/render(008 §Theui.testcontract). - 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 the
:frame-plansrecord; no Spec owns extraction itself — the top-region syntactic grammar (which wrapper forms are legal and their compile diagnostics) — see README on the vacant 004 slot.