Decision records
ADR 0088 — Blueprint conformance: enforcing a catalogue that was already declared
- Status: Accepted — implemented (2026-09-01)
- Date: 2026-09-01
- Resolves: W1a–W1e of the
product portfolio evaluation (
loom/docs/requirements/2026-09-01-product-portfolio-evaluation.md) §9 - Builds on: the second-consumer rule (ADR 0083), effect classes (ADR 0084) for the app-supplies-the-vocabulary pattern, the A2UI egress adapter (ADR 0069) whose catalogue this mirrors
- KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0). No syscall, no event type, no fold change. Two pure functions and a declaration type in an existing package.
Context
A blueprint a person wrote is checked by the type system: the widget builders are functions and a typo is a compile error. A blueprint a model composed at runtime has no such check — it is data, assembled from a catalogue, and the only thing between an off-catalogue component and a viewer is whatever validates it on the way out.
Nothing did, on either path:
@loom/blueprint-runtimewidensWidget.kindtostringso custom renderers can register any kind.customWidget$andRendererRegistry.registeraccept anything. There was no validator.plugins/a2uihas a real, schema-typed catalogue (defineCatalog) andA2UISurfaceBridgestampscatalogIdon everycreateSurfacewithout checking a component against it.
Neither @loom/verify nor the compiler knows the word “blueprint”. A declared vocabulary that
enforces nothing reads as coverage while checking nothing — the failure mode
ADR 0083 and the classification-reachability work both exist to
prevent — and it fails per viewer, at render time, once the surface is already open.
The reference GenUI example had to hand-roll the closure: db-observability’s catalogue.ts
makes the model name a catalogue entry id rather than a kind, and catalogueEntry(id)
throws on an unknown one. That indirection is the right instinct, but as app code it is
re-invented per app, unavailable on the egress path, and cannot express “degrade to a plainer
component and record the gap” — a throw is not a finding — nor produce a first-attempt
validation rate, which is what a composer is actually measured on.
Decisions
D1 — Two checks in two directions, and the second one is the easy one to forget
| Question | Function | |
|---|---|---|
| blueprint → catalogue | Does this composition name only audited components? | validateBlueprint |
| catalogue → renderer | Can this surface actually render everything the catalogue defines? | catalogCoverage |
The first is worth nothing alone. If a renderer does not implement everything the catalogue
defines, the composition validates and the viewer still gets a placeholder. The second check
failed on shipped code with no product involved: WidgetKind declared eight built-in widgets,
the React defaultRegistry registered five, and BlueprintRenderer.test.tsx asserted the
string [No renderer for DataTable] — pinning the gap as intended behaviour. ProgressBar,
DataTable and AlertCard now have renderers, and the fallback test points at a kind that is
genuinely outside the vocabulary.
D2 — The catalogue is the app’s; both of its fields are required
CatalogEntry is { kind, props, binding } and props and binding are not optional. This
is the same reasoning as ADR 0084’s app-supplied effectClasses: loom checks the vocabulary it is
given and does not define one. builtinCatalog ships as a catalogue of loom’s own eight
widgets, not as the vocabulary every app must use.
Requiring both fields is deliberate. An entry that declined to say which props it accepts would
pass the payload check vacuously, and an absent input that silently means “pass” is precisely the
defect this ADR closes. props: [] is a perfectly good answer; declining to answer is not one.
D3 — validateBlueprint returns findings and never throws
CON §4.7 requires “degrade to a typed table with an explicit statement of what was degraded, and
file a gap”, and TRD KC-2.2 requires ≥ 95% of synthesised blueprints to validate on first
attempt. Neither is expressible with an exception: a caller cannot degrade on a throw, and a rate
cannot be computed from one. The report therefore carries every finding from a single pass, plus
a widgets count — because ok: true over zero widgets is not evidence that a composer
works, and a rate needs its denominator.
One unknown kind produces exactly one finding. Without a catalogue entry there is no
allow-list and no binding requirement, so any follow-on finding would be invented, and inflating
the count for one real defect makes the rate lie.
D4 — Five checks, and two of them are not about the catalogue at all
unknown-kind and undeclared-prop are the closure and exfiltration properties the evaluation
called for. Reading the code turned up three more that belong in the same pass because the
traversal already visits every node:
missing-binding— a data-bearing component with noDataBindingrenders blank, with no error anywhere.undeclared-projection— a binding to a projection the blueprint does not declare. Renderers hydrate onlyblueprint.projections(useBlueprintProjectionValuesiterates exactly that list), so such a widget resolves toundefinedand renders blank. This is silent omission in its purest form and nothing else in the system reports it.duplicate-node-id—stableIdderives a widget’s id from kind + props, so two structurally identical widgets collide, and the id is also the renderer’s React key.
undeclared-prop is an error, not a style warning. A blueprint declares what to fetch, never
what was fetched; that is what lets one blueprint be shared and hydrated separately against each
viewer’s permissions. A composer that inlines a fetched row into props turns a shareable
artifact into a copy of one viewer’s data. The allow-list is exact rather than heuristic for the
same reason a heuristic would be worse than nothing: a rule that flags DataTable.columns as a
payload gets switched off, and a switched-off rule protects nobody.
D5 — The same hole existed on the egress path, twice (W1c)
A2UISurfaceBridge took a catalogId: string, stamped it on every createSurface, and checked
nothing against it. And catalogues declare the functions an agent may invoke on the client
(ADR 0070) — nothing compared a callFunction to
them either.
Three decisions:
- Naming a catalogue without supplying it is refused at construction.
catalog?: A2UICatalogis the option that matters;catalogIdalone now throws unless it is the default (whichdefaultCatalogbacks). Leaving a path where an app can name a catalogue and skip the check would rebuild the hole this ADR closes, and construction is the earliest point at which the failure can be attributed to the code that caused it. - A catalogue that declares no
functionsis a refusal, not a permission. Treating “no declaration” as “unverifiable, therefore allowed” is exactly how a declared vocabulary ends up enforcing nothing. This immediately caught three of the plugin’s own tests callingcallFunctionagainstdefaultCatalog, which declares none. recompose()throws;validateComponents()reports. A bridge that silently emitted nothing for a bad tree would turn an authoring bug into a surface that never updates — the silent omission the whole seam exists to prevent. Callers that want to degrade run the validator first.
updateComponents now has a producer. Its payload type has been declared, exported and in the
server-message union since the adapter shipped, with the comment “defined for completeness” and
nothing emitting one — so a session’s component tree was fixed for its lifetime and no panel could
be added to a surface already open. recompose(components) emits it. Before the session opens it
emits nothing and the new tree becomes what the pending createSurface carries; there is no client
to update yet, and sending an update for a surface the client has never seen is the wrong message,
not a missing one. That is also why requireRoot is checked at construction and not on recompose:
an updateComponents batch may legitimately replace a subtree, and a check that rejects correct
usage gets turned off.
Component props are checked as an allow-list of declared keys only when the entry declares
properties; an entry that declares none yields an explicit unchecked-props warn rather than
a silent pass. A2UI property schemas are JSON-Schema $refs into a shared types document, so this
module checks keys and makes no claim about values — a partial check announcing itself as complete
would be worse than none.
The third instance was in the example that exists to demonstrate the feature.
a2ui-callfunction authors CHECKOUT_CATALOG with both its functions declared, exports it, and
never passes it to the bridge. Its own suite passed while pnpm start threw, because the suite
drives the flow and the transport and never touches the egress seam. Fixed in
loom-examples#23, with the coverage that
would have caught it.
D6 — Stable node identity, and a place to record who placed a node (W1d, W1e)
W1d. All eight widget builders now accept an optional id (StreamingText always did; the
other seven did not). The derived stableId hashes kind + props, so editing any prop
re-identifies the node — remounting it and breaking anything keyed on it. That is correct for a
view authored once and wrong for one a composer revises. id is destructured out of props, so
it never trips undeclared-prop. The helper’s comment now also states plainly that the derived id
is a 32-bit identity, not a content address, and must not be used as an audit anchor.
W1e. SurfacePatchPayload gains an optional by: PatchProvenance. “Why is this panel here” is
answerable only if something recorded the answer when it was placed, and there was nowhere to
record it: ctx.emit takes no metadata, so provenance could only go inside the patched value,
polluting the data model the client binds to.
It rides the payload instead — SURFACE_PATCH is a userspace domain event that the fold skips, so
this is additive by construction and EVENT_SCHEMA is untouched. surfaceProvenanceProjection()
folds it into path → author, because patches are ordered events and “who last wrote this path” is
therefore a projection, not a second store. provenanceOf() falls back to the nearest recorded
ancestor: a composer that places a whole panel in one patch records the panel, so asking who placed
a leaf has to walk up to the placement that actually happened. An unattributed patch records
nothing — synthesising an author would be worse than leaving the question open.
Provenance is not forwarded to clients. The bridge reads patches only, so an internal agent
id or model version never reaches a viewer; there is a test asserting the wire bytes.
SurfaceEmitter.as(by) returns an attributed view rather than adding an argument to six methods
that most callers would leave off.
D7 — What this does NOT do
-
It does not close
Widget.kind. The type staysstringandcustomWidget$still works; closure is a property of validating against a catalogue, not of the type. An app that never callsvalidateBlueprintis exactly as unchecked as before (G2). -
It does not make the two 1P renderers agree.
renderBlueprintToStringignoreswidget.kindentirely and flattens every widget totitle: value; that is legitimate for a text surface but it means “two people looking at one blueprint see the same thing” is false across surfaces. Its doc comment now says so rather than leaving the claim implied.Update (ADR 0096): the terminal renderer is kind-aware when given a registry;
defaultTerminalRegistrycovers the catalogue and unregistered kinds are marked[kind].
Consequences
@loom/blueprint-runtimegainsvalidateBlueprint,catalogCoverage,defineWidgetCatalog,builtinCatalog,BUILTIN_CATALOG_IDand their types. No existing signature changed.@loom/plugin-renderer-reactgains three renderers and a coverage test that fails the build if a kind is added tobuiltinCatalogwithout one.@loom/plugin-a2uigainsvalidateComponents,validateFunctionCall,describeFindings,A2UISurfaceBridge.recompose()and acatalogoption. Two breaking changes, both loud: passingcatalogIdfor a catalogue you do not supply now throws at construction, andcallFunctionthrows for a name the catalogue does not declare. Neither can fail silently.@loom/agent-surfacegainsPatchProvenance,surfaceProvenanceProjection,provenanceOfandSurfaceEmitter.as. All additive; a log written withoutbyfolds identically.- Every check is verified by its negative case — each was individually disabled and the suite went red for it — because a declared check and an enforced one look identical in a green suite.
- Second-consumer rule: satisfied three times before a line was written (CON P3/§5.1/§9.1, TRD KP-4/BP-2/KC-2.2, and AET as the case for why runtime synthesis is refused).
Amendment (2026-09-27) — dangling ids, cycles, malformed catalogues; the second transport binding
D5 closed the catalogue hole but only for props: column("root", ["ghost"]) (a child id no
component defines) and column("root", ["root"]) (a component that is its own ancestor) both
passed validateComponents with ok: true. Both are exactly the class of defect an automated
composer — the recompose path this ADR added — gets wrong, and both are checkable without JSON
Schema: they are pure adjacency-list properties of the children references already being
iterated, not a claim about a prop value. A defineCatalog entry keyed differently from its own
component discriminator const (declared "Text", discriminator { const: "Txt" }) was also
unchecked — routing and rendering would disagree on the same catalogue.
Four finding codes, all in validateComponents:
dangling-child(error) — achildren: string[]entry (Column/Row) naming an id no component in scope defines.dangling-template(error) — achildren: { componentId }entry (a List’s template) naming an id no component in scope defines.child-cycle(error) — a component reachable from itself throughchildrenreferences.unreachable-component(warn) — no path fromrootreaches this component. Reported only whenrequireRootand a root actually exists: with no root,missing-rootalready says everything there is to say, and flagging every other component as unreachable on top of it would be noise about the same defect, not a second one.
Algorithm. One iterative DFS, white/grey/black colouring, starting at root when present and
then continuing over any node the first pass left unvisited (an orphaned subtree can still loop on
itself) — O(V+E), no recursion. Untrusted, composer-generated payloads are exactly where a
thousands-of-nodes accidental cycle would show up; a recursive walk would blow the call stack
before a naive one even finished. A negative test builds a 50,000-node chain to prove this is
iterative rather than merely correct on small inputs.
The correction that made this checkable at all — A2UISurfaceBridge.recompose vs. the client.
recompose REPLACES the bridge’s own stored components with each batch. An A2UI client, per the
protocol, MERGES an updateComponents batch into what it already holds, by id. Validating a
batch against only itself (or against the bridge’s previous batch) would reject the correct,
intended usage of recompose — a subtree update naming an already-delivered sibling it did not
re-send — and a check that rejects correct usage gets turned off (this ADR’s own D5 rule, applied
to itself). The bridge now keeps clientView: Map<id, ComponentInstance>, seeded in full when the
session opens (a createSurface is a full replace, matching what a client does on receiving one)
and merged incrementally on every subsequent recompose — in O(batch), not O(tree). recompose
validates each batch’s children references, and walks cycle detection, against components
overlaid on clientView, so a batch may correctly reference a sibling from any earlier batch,
and a batch that closes a cycle back through an already-sent component is still caught.
Catalogue well-formedness. validateCatalog(catalog) checks that every components entry’s
key equals its own component.const, and every functions entry’s key equals its own name
(codes catalog-entry-key-mismatch / catalog-function-key-mismatch). Run once, in the
A2UISurfaceBridge constructor (defaultCatalog included, though it happens to be well-formed) —
every later check indexes catalog.components by key, so a mis-keyed entry would silently never
match the instances it exists to validate.
@loom/plugin-mcp-app never validated at all. surfacePayload reused ComponentInstance with
no validateComponents call, and the template’s renderer (template.ts) returns null for an
unknown component type — a silent hole in the rendered view, one transport binding away from the
defect D5 closed on the A2UI egress path. SurfacePayloadOptions gains catalog?: A2UICatalog
(default defaultCatalog — the same default the A2UI bridge uses, since this is a second transport
binding for one component-tree vocabulary, not a second UI model); surfacePayload validates with
requireRoot: true (its payload is always a complete model, never a delta — see adopt() in the
template) and throws exactly as A2UISurfaceBridge does.
What this does NOT do. Prop values remain unchecked — a component’s properties are still
JSON-Schema documents with $refs into A2UI’s common_types.json, and closing that needs a
JSON-Schema engine plus $ref resolution, deferred until a consumer ships a catalogue with typed
values (loom-ui is the likelier home). callFunction argument validation is deferred with it, for
the same reason.
@loom/plugin-a2uigainsvalidateCatalogand the four finding codes above;ValidateComponentsOptionsgainsknownComponents?: ReadonlyMap<string, ComponentInstance>. Additive — no existing signature changed, and every existing conformant tree still validates.@loom/plugin-mcp-appgainsSurfacePayloadOptions.catalog. One breaking change, loud:surfacePayloadnow throws for a tree it previously passed through unexamined.- No new event type, no syscall, no fold change. KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0).
Source: docs/adr/0088-blueprint-conformance.md in the loom repository.