Lolly Beta
Launch App

Capabilities, trust and extensions

This chapter is a draft for review, dated 2026-09-24.

A document declares what it needs. A host declares what it can do. This chapter specifies how those two declarations meet, what an independent publisher may add to the model and what a host must do with a part it cannot read. It restates plan section 12 with resolutions R10, R11 and the review correction C7. It also carries the parts of R3 that decide where code runs. Lolly Work is a separate repository, and a lolly-work/ path in this chapter is a file in that checkout rather than in this tree.

Independent authors may publish feature namespaces and conformance fixtures (D10). Spatial experiences, fabrication and unfamiliar media are extension checks rather than roadmap items, and they exist here to keep the boundary coherent (D5). The suites those fixtures belong to are specified in conformance and fidelity. The operation that a missing extension refuses is specified in operations and outcomes. The execution classes named below are defined in values, expressions, code and time.

Capabilities are data with limits

A host must report its capabilities as data with limits rather than as booleans (plan section 14, gotcha 4, against lolly-work/server/src/render/capabilities.ts). A boolean answers whether a thing is possible in general. A caller needs to know whether this output target, at this size, for this duration, is possible here.

Both current precedents are too coarse. The model takes them as the starting point, not as the form to copy.

The tree already holds the better pattern on one path. pickWebCodecsVideo in shells/web/src/bridge/video-shared.ts probes the real encoder with width, height, bitrate and framerate. It walks a ladder of codecs and returns the one that answered, or nothing when none did. The caller in shells/web/src/lib/video-jobs.ts then fails the job rather than guessing, though its message gives no limit. That is a capability answered against the work being asked for.

Rules for 0.1:

This chapter states no number. Where a limit is a number, that number belongs to a host and to a release, never to the model.

draft shape

  CapabilityReportV1
    host          shell, engine version, the execution classes it can provide
    suites[]      { suite, releasedVersion, features: { core[], extended[] } }
    outputs[]     one entry per output target it can produce:
                    { format, colour, alpha, audio, maxGeometry, maxDuration, paging }
    extensions[]  { namespace, schemaVersion range, stages }
    stated        which fields are measured and which are declared by the host

This is a draft shape for review. Nothing is added to packages/core or to schemas/ until the counterexamples in plan section 17 pass as contract fixtures (plan status line and section 18, first pull requests). The shape is drawn to meet MotionCapabilitiesV1 and its still-image sibling, not to replace them (plan section 12, F2).

Requiredness is scoped to the operation

A requirement is always a requirement for something. A host must scope requiredness to the affected operation and output target, never to the document as a whole (plan section 12, third compatibility gate of the review).

The consequence is the rule the review asked for. A host must inspect declarations first and refuse at the operation boundary. Missing motion support must never prevent a safe inspection, and it must never block a still export that has no motion dependency (third compatibility gate of the review, plan section 12). Evaluation and receipts places that refusal at step 2 of the pipeline, after the operation and the output target are chosen.

When the refusal comes, it must name what is missing. A blocked termination in operations and outcomes carries the unmet requirement, so a caller can tell an unmet extension from an error.

Where a document declares its extensions

A document declares its extensions in one place. An extensions map keyed by namespace holds each extension's own record with its schemaVersion (R10). Two lists name what the document uses and what it cannot do without: extensionsUsed and extensionsRequired (R10, and glTF's precedent below). The root manifest stays closed, because tool.json refuses unknown keys and that is right for a definition (plan section 14, gotcha 14). An extension adds nothing to the root.

What an extension declares

An extension must identify itself completely before a host decides anything about it (R10, plan section 12).

draft shape

  ExtensionDeclarationV1
    namespace       permanent and publisher owned; never renamed, never reused
    publisher       who published it, for attribution and never for trust
    version         immutable; a released version is never re-cut
    schemas[]       the records and fields it adds, each with its own schemaVersion
    dependencies    the complete scope it reads; see the next section
    stages[]        which evaluation steps it takes part in
    execution       the execution classes its code may run in, when it has code
    powers[]        the effects its code asks to commit, authored not generated
    outputs[]       what it adds to an output target, and what that addition means
    compatibility   the reader and engine versions that can read it

The outputs[] entry is where the fabrication thought experiment is answered. A manufacturability check is an output-target validator contributed by an extension, not a new operation (plan section 14, gotchas 9 and 11). That is the test of whether the boundary holds for a medium nobody here has built (D5).

Publication, a signature and a passing fixture grant no code trust and no host authority (D10, plan section 12). A signature says who published the bytes. It never says what those bytes may do. Validators and comparators that execute code are themselves subject to an execution class, because a comparator is code like any other (R3, plan section 12).

The chrome slot contract is the nearest in-tree model for the identity half of this. packages/core/src/extension-v1.ts gives a slot a permanent id and requires a namespaced extension id. It carries a channel that is recorded for governance and for a provenance chip, and never as a security boundary. Its own header makes control-plane extensions org-trusted and community ones opt-in at the deployer's risk, and it states that a hydrated component runs in the shell realm with no sandbox either way. Ordering inside a channel is a tiebreak under the channel governance rank, so an author cannot place a community component ahead of a control-plane one. Its namespace and channel model is reused here. Its runtime is not (plan section 13).

A namespace must never reach into a reserved name. RESERVED in engine/src/url-mode.ts is the closed set of URL parameters that are not inputs, and two prefixes are reserved: any name starting with _, which is where future reserved params are minted from, and any name starting with pkg., an in-use namespace for Linux-package export metadata. This chapter adds one rule that the plan does not state, for the status chapter to confirm: an extension that needs a URL parameter takes it from its own namespace, never from either reserved prefix and never from the reserved set itself.

Dependency scopes, and what a host without the extension must do

Preserving bytes does not preserve meaning (R10). A host that carries an unknown part through a save has kept the bytes. It has not kept the part correct, because an edit elsewhere in the document can invalidate what those bytes describe.

An extension must therefore declare a complete dependency scope (R10, C7). Three kinds of scope exist, and a declaration that lists only the first is incomplete.

Scope kindWhat it coversWhich edits break it
Rows and valuesThe rows the extension reads and the fields it reads on themChanging a value, deleting a row or replacing a typed payload record
Collection membership and orderThe collections whose membership or order the extension depends onAdding a row, removing a row or reordering a collection
Global settingsDocument-wide settings such as units, DPI and colour handlingOne edit at the document level, touching no row the extension named

A list of currently read row ids alone cannot protect the second and third kinds (C7). That is why the declaration is scoped rather than enumerated.

A host that lacks an extension must do all of the following (R10, C7):

The counterexample is stated in the plan: glTF declares extensionsUsed and extensionsRequired, and some loaders only warn on an unmet required extension rather than refusing (R10). One import path in this tree does part of what this model asks for. shells/web/src/lib/studio3d/source.ts reads extensionsRequired out of a GLB manifest and refuses the import when the model requires Draco, Meshopt or KTX2 compression. That refusal comes at the import boundary rather than later in the decode. It reports the three compression formats rather than the extension that triggered it, so it is a precedent for refusing early and only a partial one for naming the unmet item. It also tests a fixed list, so a required extension outside that list is not refused. The rule here is the general case that list is a start on.

An extension's content travels as package parts under the integrity map, never as manifest keys (R10). The reason is in shells/web/src/lib/lolly-pack.ts: the writer rebuilds the manifest from typed fields on every save, so an unknown manifest key is dropped. A part is the safer carrier because the integrity map already covers and verifies it. The carry itself does not exist yet: LollyBuildInput takes only typed fields, so a part a reader did not interpret is not written back today. Packaging, identity and migration specifies the collision, ownership and collection rules for those parts, and the retained package state that has to join the read to the next write (C7).

The four gates

Four independent gates decide whether a document can be read and run here. Each already exists or is proposed, and each refuses at its own boundary.

GateWhat it comparesWhere it runs todayWhat it does when unmet
Engine rangeThe manifest engineVersion range against the running engineengine/src/loader.ts with engine/src/semver-range.tsRefuses to load the tool. A range it cannot parse is treated as unsatisfiable, so an unrecognised range fails closed
Host APIsThe manifest requires list against the optional APIs the host providesengine/src/runtime.ts with packages/core/src/host-v1/apis.tsRefuses the mount before any hook runs. An unknown name counts as missing, so a typo fails loudly
ExtensionsextensionsRequired against what the host implementsProposed. The nearest precedent is the GLB import in shells/web/src/lib/studio3d/source.tsRefuses the affected operation by name, at the operation boundary
Reader versionThe container minReader against the reader's own versionpackages/node-shell/src/lolly-file.tsRefuses to open the package and says a newer reader is needed

The public requires field keeps exactly the meaning it has now: the optional HostV1 APIs a tool's hooks call without feature-detecting them (schemas/tool.schema.json). It must never be repurposed to carry engine, reader or extension requirements (R11, first compatibility gate of the review). One internal evaluator may normalise the four gates so their diagnostics read the same way. The four fields stay separate on the wire.

missingRequires in packages/core/src/host-v1/apis.ts is the current implementation of the second gate, and HOST_V1_OPTIONAL_APIS is the enumerable list it compares against. A tool that guards an API (host.text?.) must not list it, and scripts/tool-requires.ts computes the list from the hooks, so a manifest that disagrees with what the analyser can see fails validate:catalog. The analyser is a regular expression and not a program analysis, so agreement with it is not proof: the limits are stated under the legacy execution boundary below (C6).

Each record carries its own schemaVersion (R11). A reader upgrades on read and may downgrade on write. A container carries minReader, and a definition carries engineVersion. Preservation-capable readers and writers must ship before any new part is emitted, behind an effective reader gate for earlier clients. An additive field is not safe merely because an old reader ignores it (C7).

These versions move independently and their relationships are written down rather than assumed (R11). The practice already exists: lolly-work/engine-pin.json records the engine and SDK versions that checkout vendors, with a content hash per pinned package and per schema file. The document model version and the suite versions join that same pin file rather than gaining a mechanism of their own (plan section 13, adoption sequence).

The legacy execution boundary

HostV1 is the portable API contract (packages/core/src/host-v1/host.ts). It is not a proof of isolation (plan section 12.2).

This is already written down where the code lives. docs/constraints.md states that trusted compatibility hooks run through new Function('host', ...) in the shell's realm, and engine/src/runtime.ts says the same at the site (plan E8). That is closure-scope injection and not a security sandbox, so a hook in a browser shell can still reach window, document and fetch. packages/core/src/extension-v1.ts says the same about a hydrated chrome component. The model does not correct either statement. It records which context actually enforced a run.

Rules:

The last rule has evidence behind it. analyseRequires in scripts/tool-requires.ts is a regular expression over host.<api> member access. The review probed it: host.text.toPath() is detected, host["text"].toPath() is not, and a local alias assigned from host.text is not (C6). A generated list is therefore lint, and an authored effect envelope is the contract.

The enforcement pattern a strict class needs already exists. strictHostShape in shells/web/src/bridge/hook-worker.ts reduces an untrusted mount's host proxy to the capabilities the manifest declared, and it omits a namespace entirely rather than exposing a stub that rejects after sensitive arguments have crossed the channel. Its own comment states the rule as absence being the policy. engine/src/hook-worker-core.ts carries the wire protocol both worker executors share, and it lists beforeExport, afterExport and exportStill as the hooks that stay in the realm. shells/web/src/bridge/hook-worker.ts states the reason: they receive a live DOM element, which cannot cross the worker boundary.

Which tools run isolated is decided from evidence rather than from an author's assertion. scripts/tool-isolation.ts sets the manifest isolate flag only when a static read finds no realm-bound global and a render at defaults is byte-identical in-realm and in a worker. The model keeps that arrangement (R3). A manifest hint is a request. The receiving shell assigns trust independently, and schemas/tool.schema.json already says so.

Open points

Q1 is carried in conformance and fidelity. Q2 and Q3 are carried in constraints, authority and local choice. Q4 is carried in operations and outcomes.

Precedents