# Read this first

> Contract 2.0.0 · package sha256 `a857eca95b35921109faf997094bb4192f12527d9d0fcd4be7e97f06425bad6e` · generated from `configurator-runtime.d.ts` (its header) by `services/onsite-scripter/scripts/build-contract-docs.mjs`; edit the source, not this file.

Holostep configurator runtime: tenant contract 2.0.
Vendored into @holostep-configurators/runtime-client by `runtime:types` from an immutable
release manifest. Never edit by hand.

Read this first. You want to… → use … → not …

<a id="one-obvious-way"></a>
## One obvious way

| You want to… | Use | Not |
| -------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ |
| change a configured material (priced, saved) | select(group, material) | a rule with { materialNodeId }: look only, never priced |
| change a variant, an option's visibility or a position | setSceneModel, or applyPatch with 'replace' ops | find().set({ visible }) or { pose }: a rule, never saved |
| change a connection | assembly.attach / assembly.detach | moving nodes by pose |
| address a placed model as a whole (pose, hide, follow) | node(nodeId) | find({ node }): that matches every part of the model |
| restyle authored parts | find(q).set(…), reset with null | writing the opposite value; writing it every frame |
| build the whole look from app state | one apply(spec); an omitted key stays unchanged | re-applying rules after every sync |
| keep standing rules (hidden planes, ghosts) | find(q).set(…): its block comes after apply()'s | in apply({ rules }): the next apply({ rules }) replaces them |
| show no map on a slot | maps: { slot: 'none' } | null: it removes your value, and the GLB's map shows again |
| preview a flat colour over a textured finish | { baseColor, maps: { baseColor: 'none' } } | baseColor alone: it tints the map (dark wood goes black) |
| switch a whole look, keeping no field of the old one | a Material from materials.create(spec), fadeMs | a MaterialSpec rule write: however complete, it merges |
| switch between partial looks over the GLB's material | apply({ rules }): its block is replaced whole | a second find().set: fields only the old look set stay |
| let the newest write to a rule win | an AbortController per write, abort the previous | call order alone: an older write still shows first |
| retile a texture whose image select() may change | a texture without url: { scale }, { sizeM } | repeating the url: it pins the image |
| generate geometry | collection(name).set(spec) | load() |
| set the page's lighting, background or exposure | the page document's stage; a layer: stage.set | stage.set(s) with no { owner }: it rejects |
| show a GLB you have, with no saved design | setSceneModel({ root: [{ …, enabled: true }] }) | a ModelNode without enabled: true: it is never drawn |
| know a GLB's bounds before placing it | collection(name).load(url, { key }) | mixing load() keys with set() items in one collection |
| make several changes all or nothing, across awaits | transaction(tx => …), writing through tx only | session or its handles inside work |
| make one undo step over time (a drag, an assistant turn) | history.group(), with expectGroup on each write | a transaction: it holds the display |
| undo or redo the last step (a toolbar button) | session.undo() / session.redo() | session.history.undo(): History only reads and groups |
| sync a store into the runtime | setSceneModel with origin, supersede and a label | undo: 'rebase': it erases every earlier step |
| start history clean after a later load (a saved design) | undo: 'rebase' on it; the first model needs none | undo: 'rebase' on a sync that runs after shopper edits |
| show a value you just wrote | the value you wrote, corrected from the Report | reading it back while it is queued (it can take seconds) |
| read the scene model, null before the first one | const m = getSceneModel(); if (!m) … | getSceneModel()!: it hides a page with no model yet |
| capture what the shopper just configured | await idle(); capture(); on HoloBusyError, again | capture() alone: a pick still loading isn't in it |
| move something or the camera every frame | onFrame(f => f.pose(…) / f.camera(…)) | part.set or camera.set in a hook |
| compute layout inside every commit | beforeCommit, through has(); reset its rules | on('committed'): that shows one frame stale |
| use an experimental member or option value | has(session, id); an option value: supports(id) | calling the optional member unguarded |
| check what a frame costs (triangles, draw calls) | if supports('info.scene'): idle(); info().scene | timing frames in onFrame: that measures the device |

<a id="traps"></a>
## Traps

The traps agents fell into, with only the declaration:

- A [`Pose`](api/Pose.md) read is not a [`PoseInput`](api/PoseInput.md) (it has both rotations): copy `{ position, quaternion, scale }`
(a mirror reads its −1 on `scale.x`); leave `scale` out only when it holds a 0, which no write takes.
A partial write keeps the omitted fields as that read shows them, so a positive absolute `scale`
on a mirrored part names its rotation too.
- `undo: 'rebase'` erases every earlier undo step, the shopper's included. Label a sync instead.
- [`capture()`](api/Session.capture.md) draws committed state only: a pick the shopper just made may still be loading, and
it is not in the image unless you `await session.idle()` first. It rejects
HoloBusyError('capture-held') while a transaction holds the display: [`idle()`](api/Session.idle.md), then again.
- A write queued behind a transaction settles when it closes: a stalled one at its `timeoutMs`
(10 s by default), one holding the display within 14 s of its hold, later while it still
loads. Show what you wrote.
- Rules a [`beforeCommit`](api/Session.beforeCommit.md) hook wrote outlive the hook: reset them when you unregister it.
- [`getSceneModel()`](api/Writer.getSceneModel.md) is null until the first model is placed, and its type says so: guard it
(`const m = session.getSceneModel(); if (!m) return;`), never `getSceneModel()!`, which
compiles and then fails on a page whose model has not arrived yet.
- One scene-model writer (spec section 11.2). In an app with its own store, an add, removal or
swap goes into the store, and the store's one sync writes the model. A second writer that adds
nodes (`tx.setSceneModel(withNode(…))`) is rolled back by a `supersede: true` sync, or the
store never receives its node (placement merges only nodes it has) and the next sync drops it.
- `commit: 'app'`: an equal store write releases nothing. Call [`input.revert(part)`](api/Input.revert.md) when the
drop resolves to where the node already is.
- A second [`find(q).set({ material })`](api/PartActions.set.md) merges into the first: a field only the earlier look set
stays where the GLB's should show. Switch partial looks with [`apply({ rules })`](api/Writer.apply.md), or null the
old look's other fields in the same write ([`MaterialPatch`](api/MaterialPatch.md)).
- Never wait with `setTimeout` or a debounce: await the write's Promise, the event that follows
its commit, or [`idle()`](api/Session.idle.md), and abort an older write's `signal` for newest wins ([`Session.idle`](api/Session.idle.md)
lists what to wait on).

<a id="conventions"></a>
## Conventions

They hold for every member; members repeat the ones that matter.

- References: "spec section N" and the short ids in parentheses (O6, R4, ID8, K47, …) name the
design record a rule comes from. The record is not shipped, and the package is complete
without it: read them as provenance, never as something to look up.
- Units: lengths in metres; angles in degrees (names end in `Deg`), except the stored `rotation`
of a scene-model node ([`IModelNode.rotation`](api/IModelNode.rotation.md)), which is Euler XYZ in radians, the stored
format: `rotation: { x: 90 }` turns a part 90 rad, so convert with `angle.degToRad` (`/math`);
durations in milliseconds (names end in `Ms`); exposure in stops (`Ev`); screen points in CSS
px from the viewer element's top-left corner.
- Space: right-handed, +Y up. `rotationDeg` is intrinsic X, then Y, then Z. The contract assumes
no product front: read it from [`describe(node).manifest.front`](api/AssetManifest.front.md).
- Colours: '#rgb' or '#rrggbb' (sRGB, type [`HexColor`](api/HexColor.md)), or `{ linear: { r, g, b } }` with
components 0..1.
- URLs: the scene model's resolve against `assetBaseUrl` (a `.holo`'s bare twin name beside the
`.holo`), and the viewer document's must be absolute; every other URL (collection items,
[`Collection.load`](api/Collection.load.md), textures in rules, materials and [`apply`](api/Writer.apply.md), stage images and HDRs) resolves
as `fetch()` resolves it, against the page's `document.baseURI`. A scene-model URL without a
scheme or host resolves beneath `assetBaseUrl`, leading '/' dropped ([`SceneModelOptions`](api/SceneModelOptions.md)).
- Kinds of call. A write (an operation) returns Promise<Report> and has a commit point; it can
be queued, staged, superseded or rolled back. A lifecycle call ([`collection()`](api/Writer.collection.md),
[`materials.create`](api/Materials.create.md), `on`, [`onFrame`](api/Session.onFrame.md), [`beforeCommit`](api/Session.beforeCommit.md), `follow`, [`input.drag`](api/Input.drag.md),
[`labs.lightLinking`](api/Labs.lightLinking.md)) is immediate and returns a handle or a disposer, never a Report; it is
never queued, and a rollback empties its owner of staged content but never removes the handle,
which ends only through its disposer, its owner or its `signal` (spec section 6.5). An engine
query ([`Collection.load`](api/Collection.load.md), [`labs.geometry.read`](api/Labs.geometry.read.md), [`input.pick`](api/Input.pick.md), [`input.raycast`](api/Input.raycast.md)) is asynchronous
and resolves with data; [`Collection.load`](api/Collection.load.md) also adds its item, which through `session` commits
on its own once loaded. Enlisted means started through a `tx` (a write, [`Collection.load`](api/Collection.load.md),
[`labs.geometry.read`](api/Labs.geometry.read.md); [`input`](api/Session.input.md) is on the session only), awaited or not: a rejection that `work`
never observed rolls the transaction back ('enlisted-failed', spec section 6.1, O6). A
configuration call ([`applyDocument`](api/Session.applyDocument.md)) replaces a layer; it is not an operation and carries no
`op` or `cause`.
- Writes take effect at the call, or when the open transaction closes if they are queued behind
it, and return Promise<Report>. The Promise resolves at the write's commit point (irrevocable),
not when it is shown. A write with `durationMs` or `fadeMs`, a camera move and a clip resolve
when they finish, and open-ended motion once it starts (spec section 6.3). No other write
waits for a frame.
- On-demand code: [`capture`](api/Session.capture.md), [`export`](api/Session.export.md), [`activateAR`](api/Session.activateAR.md), [`assembly.attach`](api/Assembly.attach.md), the overlays, the drag
body, `labs` and the patch apply of `undo` and [`redo`](api/Session.redo.md) load their code after the first
complete frame. A call before then waits for it, a synchronous one taking effect at the first
frame after it loads; an AR tap is routed at once (spec section 5.11). A failed load fails the
call as an internal failure does (a write rolls back; another Promise rejects
HoloBusyError('code-unavailable')), and a later call retries it (spec section 6.3).
`candidates()` is start-up code: it never waits and never throws for code.
- Order: outside a transaction, recorded writes ([`setSceneModel`](api/Writer.setSceneModel.md), [`select`](api/Writer.select.md), [`applyPatch`](api/Writer.applyPatch.md),
`attach`, `detach`, `undo`, [`redo`](api/Session.redo.md)), [`apply`](api/Writer.apply.md), [`Collection.set`](api/Collection.set.md), [`stage.set`](api/Stage.set.md) and [`transaction`](api/Session.transaction.md)
form one first-in, first-out queue per session and commit in call order. Two pending replacing
writes to the same target coalesce only when they are adjacent in that queue (spec section
6.10, R4); a merging write or a dry run is never coalesced. Other writes (rules, camera, clips)
commit as soon as they are ready, and queue only behind an open transaction; two writes to
one rule both commit, in call order (spec section 6.1, O2b): the older one
shows until the newer commits. For newest wins, give each write its own AbortController and
abort the previous one when a new write starts (it rejects AbortError; catch it). A
transaction opens only after every write called before it, queued or not, has passed its
commit point.
- Reads are synchronous and see your own writes (an engine query, above, returns a Promise),
with one exception: a write queued behind an open transaction. Until it applies, a read of
its target is unspecified (old or queued value).
Await that write's Promise before reading it back. That Promise settles only when the
transaction closes: within timeoutMs + readyTimeoutMs (14 s by default) of its hold, and later
while it still loads (timeoutMs re-arms on progress until the hold). For UI that must change
at once, show the value you wrote and correct it from the Report ('superseded' means a newer
write won).
- Read classes (spec section 6.7): authored (fixed per loaded asset), intent (the latest accepted
state, staged and queued writes included), derived (computed on read, never last-rendered),
observed (stamped `frame`: a sync read is as of the last drawn frame, while a capture renders
committed state anew). Each read names its class. Intent is
not what is shown: for that, keep the latest 'scene-model:changed' payload.
- A write whose result equals the current state commits nothing: it resolves 'committed' with
`changed: false` and no seq, revision, undo step or event.
- Events fire after the frame that shows a commit. Payloads are frozen snapshots of that commit.
State events (those a commit causes) carry `cause`.
- What to wait on: a write's Promise (its commit point), the event after a commit (once shown),
a [`transaction`](api/Session.transaction.md)'s Promise (all or nothing), [`idle()`](api/Session.idle.md) (everything loaded, eased and drawn) and
an [`onFrame`](api/Session.onFrame.md) hook (each frame). Never a timer ([`Session.idle`](api/Session.idle.md)).
- Output unions are open. A value newer than your declared contract arrives as 'other'.
- Stability: every member is tagged `@stability stable` or `@stability labs`. A field inherits
the tag of its interface unless it carries its own. A labs member at a stable path is also
tagged `@experimental`; an experimental method is optional here, and app code reaches it
through [`has(session, id)`](api/has.md). A labs member may change in a minor release after one minor of
'deprecated' problems; a stable member only grows.
- Since: every declaration is tagged `@since 2.0.0`. A member inherits its declaration's version
unless it carries its own.
- Errors: a Promise-returning member reports invalid input by rejecting with HoloInputError,
created at the call before anything runs. Three transaction-scope misuses throw at the call
instead: HoloScopeError, HoloTransactionClosedError and
HoloInputError('option-in-transaction'). A lifecycle call returns no Promise, so it throws
HoloInputError at the call ([`collection()`](api/Writer.collection.md), [`materials.create`](api/Materials.create.md), `on`, [`onFrame`](api/Session.onFrame.md),
[`beforeCommit`](api/Session.beforeCommit.md), `follow`, [`input.drag`](api/Input.drag.md)): `session.collection('c', bad).set(…).catch(…)` never
reaches its `.catch`, so wrap such a call in `try`. A query that matches nothing resolves with
`matched: 0` and records 'target-unmatched', deferred while a scene write called before it
is pending ([`Report.matched`](api/ReportFields.matched.md), [`Report.pending`](api/ReportFields.pending.md)). Asset failures in
declarative writes are reported in [`Report.problems`](api/ReportFields.problems.md), never thrown. `instanceof` against this
package's Holo*Error classes holds for errors the loaded runtime build throws, although the
two are different class objects: each class checks a brand (`Symbol.for('holo.error')` and
its `name`) that the runtime sets on every error it throws.
- Look writes (rules, collections, materials, stage, camera, overlays) are presentation only.
They are never priced and never part of a saved configuration.
- Two documents. The viewer document ([`session.document`](api/Session.document.md)) holds page defaults: stage, camera,
limits and lens; "the document's" value means its merged value. The stored configurator
([`IConfigurator`](api/IConfigurator.md); the runtime's copy is the scene model, [`getSceneModel()`](api/Writer.getSceneModel.md)) holds nodes, their
poses and the selected materials; "the scene model's" means that.
- Examples compile under strict TypeScript with `noUncheckedIndexedAccess` (an indexed read may
be undefined: check it before use) and run on the fake (`/testing`). They assume
`session: Session` and `signal: AbortSignal` in scope and this package's exports imported;
the app functions they call are written as `declare` lines. The imports are left out for
brevity only: app code imports every name it uses, values ([`has`](api/has.md), [`validate`](api/validate.md), the Holo*Error
classes, the pure helpers) from the package root and types from `/contract`:
`import { has, HoloAssetError } from '@holostep-configurators/runtime-client';` and
`import type { Session, CameraView } from '@holostep-configurators/runtime-client/contract';`.
`/math` and `/testing` names come from those entry points. Besides the root's app-side boot
helpers (`loadRuntimeAtFloor`, the channel constants, `bootFailureAdvice`) and `/server` (spec
section 9.1), the package exports only what this file declares: a type your page defines (a
store value, an option of your own UI) is your app's even when a runtime call takes it, so
declare it in your app and never import it from the package.
- [`boot`](api/boot.md), [`RUNTIME_CONTRACT`](api/RUNTIME_CONTRACT.md) and [`RUNTIME_VERSION`](api/RUNTIME_VERSION.md) are not exported by the package root: they
are the runtime module's, which the root's `loadRuntimeAtFloor(runtimeUrl, required)`
resolves to once it has checked your floor (`src/runtime/REQUIRED_CONTRACT.json`). Write
`const runtime = await loadRuntimeAtFloor(runtimeUrl, required)`, then `runtime.boot(…)`.
