Decision records
ADR 0097 — Blueprint IR 2.0.0: the IR grows instead of being worked around
- Status: Accepted — implemented (2026-09-22)
- Date: 2026-09-22
- Implements: the Blueprint IR 2.0.0 design (
loom/docs/requirements/2026-09-22-blueprint-ir-2-design.md). - Builds on: ADR 0096 (the version pin this bump uses),
ADR 0088 (
validateBlueprint/catalogCoverage, extended here with seven new codes), ADR 0095 (declared, not derived, meaning — the same discipline this ADR applies to a binding’s delivery state), ADR 0085 (the classify vocabularysensitivityreuses). - KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0).
BLUEPRINT_IR_VERSION1.0.0 → 2.0.0.
Context
ADR 0096 made the Blueprint a declared compile target:
a version constant, a shape manifest tsc checks exhaustively, and a digest a test pins per
version, so a consumer in another repository could pin the shape the way a storage backend pins
the MemoryStore SPI. The sibling framework loom-ui took it up as exactly that — its compiler
turns a semantic Experience Model into a Blueprint, and both of loom’s renderers, plus loom-ui’s
own Lit and terminal adapters, draw the result.
Five slices into that framework, three facts the Blueprint could not carry had each found a way around it instead of through it:
- A grid child’s width. loom-ui’s layout has a per-child
span; loom’sGridLayoutdoes not. It shipped asprops.span, a reserved key declared into every derived catalogue contract — ADR 0096 D4 named this “visible degradation” and deferred the field rather than pre-empt loom-ui’s own slice 2 decision. Lit read the prop and honoured it; React’s grid renderer did not know to look, so a nested container’s span was silently dropped, andvalidateBlueprintflaggedprops.spanasundeclared-propagainstbuiltinCatalog— the workaround and the guard disagreed with each other on the same artifact. - Hydration status.
pending | ready | stale | errorhas nowhere to live in aDataBinding, so loom-ui’s Lit and terminal adapters computedhandleFromPath(binding.path)and readstate.status[handle]out of the hydration projection directly — behind the Blueprint’s back, coupling every adapter to hydration’s internal keying instead of to the artifact each adapter is supposed to render from. loom’s own React renderer showed no status at all, because there was no channel to read one from. - Degrade provenance. loom-ui’s compiler resolves a semantic type to a concrete kind per
surface —
Trend@1.0.0 → Collection@1.0.0 → DataTablewhen a surface has no native trend widget — and then discarded the chain, because the Blueprint had no slot for it. No renderer could tell a degradedTrendfrom aDataTablethat was always aDataTable, so the “two people on a call see the same thing” property held for the value and not for what the surface was honestly showing.
Each leak was one adapter’s workaround, visible only to the adapters loom-ui wrote, invisible to loom’s own renderers and to any third consumer of the Blueprint. A whitepaper evaluated against loom-ui slices 1–5 in the same review (The Experience Runtime: Moving UI Frameworks Below the Semantic Boundary) predicted precisely this failure mode for a rendering IR that cannot grow: the meaning migrates to side channels the IR’s own guards cannot see, and every side channel is a fact one surface knows and the rest do not. The paper also draws the line this design keeps: a mutable experience model a planner edits, compiled to one validated IR every adapter consumes. loom-ui already holds that split — the Experience is content-addressed and planner- facing, the Blueprint is the IR — so closing these leaks is a property of the IR, not a rethink of the split.
The maintainer’s ruling, made in the same review that produced the design this ADR implements: the
IR grows, deliberately, under the version pin ADR 0096 built for exactly this. ADR 0096
D4 said a loom layout
field for span “becomes a loom layout field only if a React consumer of the framework needs it;
that would be a minor bump under D2” — loom-ui’s compiler is that consumer, and the shape of the
fix (a wrapped cell, not a bare optional) makes it a major bump instead, for the reason Decision
D1 gives.
Decisions
D1 — The grid child is wrapped, not decorated
GridLayout.children becomes GridCell[], where GridCell { span?: number; node: Widget | Layout }. A span on Widget or Layout directly was rejected: it would put the fact on two
shapes (a widget’s own props already carry binding and now source; a nested Layout has no
natural home for a column count that belongs to its parent, not to it) and it does not answer
what a non-grid container does with a field that means nothing outside a grid. A wrapped cell
puts the fact exactly where it is true — the edge from a grid to its child — and nowhere else.
Because GridCell replaces the element type of an existing array rather than adding an optional
field, this is ADR 0096 D2’s
major case: BLUEPRINT_IR_VERSION moves 1.0.0 → 2.0.0, BLUEPRINT_IR_SHAPE gains a
GRID_CELL manifest entry alongside the widened GridLayout one, and irVersion.test.ts pins a
second digest under 2.0.0 beside the untouched 1.0.0 row.
A version bump that broke every existing grid({ children: [...] }) call across this repo and
loom-examples would be the workaround problem again, one level up, so compatibility is symmetric:
- Readers accept 1.0.0.
asCell(child: GridCell | Widget | Layout): GridCellis the one place a bare legacy child becomes a cell ({ span: 1, node: child });traverseBlueprint,validateBlueprintand both renderers call it and branch on the union nowhere else (blueprintPinsreadsBlueprint.projectionVersionsand never walks the tree, so it has no grid child to normalise).Blueprint.irVersionnames the shape an artifact was written in; its absence reads as"1.0.0"(blueprintIrVersion,IR_VERSION_LEGACY). - Writers emit 2.0.0.
blueprint$stampsirVersion: BLUEPRINT_IR_VERSIONon every artifact it builds.grid({ columns, children })acceptsArray<GridCell | Widget | Layout>and normalises every element throughasCell, sogrid({ children: [MetricCard(...)] })— every existing call in this repo and its examples — keeps compiling and now produces a 2.0.0 artifact with implicitspan: 1cells.cell(span, node)is the new builder for an explicit span. - Explicit upgrade.
upgradeBlueprint(bp)returns a 2.0.0 artifact — cells wrapped recursively through nested layouts,irVersionset — and is identity (same reference) on already-2.0.0 input. It exists for a migration or agetExperience-shaped reader that wants to re-address a stored artifact; ordinary readers never need to call it, because they already accept 1.0.0 throughasCell. - Content addresses change. A 1.0.0 artifact re-emitted as 2.0.0 has a different content address, precisely because its shape changed. That is a new artifact with the old one as its parent, the same rule every other structural change follows; nothing rewrites what is already stored.
- A cell is an IR edge, not a DOM element. A renderer adds an element for a cell only when
it has something to say with it: on React, a grid child gains a wrapper element ONLY when
its span exceeds 1; span-1 children — and therefore every 1.0.0 grid, whose cells are all
implicitly span 1 — render DOM identical to 1.0.0. That is not only a cosmetic property. A
wrapper interposed on every cell would own the array position React diffs, and keying it by
index re-identifies every cell after a removed sibling: React then discards the survivors’
DOM (with their focus, scroll offset and uncontrolled input state) and rebuilds it. Emitting
the child directly keeps
key={widget.id}on the element React diffs, so a grid child reconciles by widget id exactly as astackchild always has; the span > 1 wrapper takes the child’s own key for the same reason.
D2 — loom owns the status contract; the matrix is canonical on every surface
DataBinding.status?: { path } names where, in the same projection state a binding’s value
already reads from, that binding’s delivery state lives. BindingStatus { state: BindingState; seq?: number; finding?: { code, message } } is loom’s own type — not an opaque re-export of
loom-ui’s hydration shape — because an opaque second path would leak loom-ui’s own keying into
every consumer of the Blueprint, which is the leak this ADR closes, not one to open on the way
past. BindingState is "pending" | "ready" | "stale" | "error". A binding without status has
no channel at all: nothing is inferred, nothing defaults to a state, and the field means “not
declared,” not “always ready.”
traverseBlueprint resolves status beside value on every call: resolveWidgetStatus(widget, stateMap) reads binding.status.path out of stateMap.get(binding.projectionId) — the same
map, the same projection, one extra path — and validates the result structurally
(isBindingStatus). A value at that path that is not a well-formed BindingStatus resolves to
undefined; it is never a throw, because a malformed write to a projection must not take down
every widget that happens to read near it. WidgetRendererProps gains status?: BindingStatus, and ContainerContext gains cells?: Array<{ span, child }> so a grid renderer
can read spans without re-deriving them.
The canonical status matrix, identical on every renderer, and engaged only when a binding declares a status channel at all:
error→ the finding replaces the value entirely; the widget’s own renderer never runs.stale→ a marker plus the value when one is present, a marker plus “no value” when it is not.pending→ a marker plus the value when one is present, a loading state when it is not.statusundefined (never written, or malformed) orstate: "ready", withvalue === undefined→ loading. On the terminal that row is⋯ <title>, the same line as pending-with-no-value: “it has not arrived”, not<title>: —, which reads as “the answer is nothing.”- Otherwise → ready.
Between error and stale the matrix leaves one documented slot: an optional check(widget, value) supplied by the caller — TerminalRenderOptions.check and the check prop on
BlueprintRenderer and WidgetFrame. loom validates nothing about values itself; a framework’s
own kind contract (loom-ui’s checkKindValue) plugs in here, and a returned { code, message }
renders exactly as the error row does, suppressing the widget’s renderer. It runs only for a
widget whose binding DECLARES a status channel, and only when delivery has not already failed —
status.state === "error" outranks it, because there is no value to check when the fetch
itself did. Returning undefined leaves the rest of the matrix untouched.
The rule that keeps this from being a spinner trap: a present value renders ready even when its
status has not been written yet. status arriving is not a precondition for showing value —
a value is never hidden behind a loading marker solely because its status write has not landed,
which would make every fast, successful read wait on a second write that exists only to describe
it. And a widget without a declared status channel renders byte-identically to 1.0.0 — no
wrapper, no attribute, no marker. On React this is structural, not a branch to remember:
WidgetFrame returns children unchanged when widget.binding?.status === undefined and
widget.source === undefined, so a 1.0.0 artifact adds zero DOM nodes by construction, and the
eight built-in kind renderers receive status as a prop and ignore it, because the wrapper — not
the kind renderer — owns the matrix. On the terminal renderer the same rule holds text-only: no
declared channel, no glyph, output unchanged from before this ADR.
D3 — Widget.source names what a widget stands for; it never rides in props
WidgetSource { type: string; version: string; degradedFrom?: Array<{ type, version }> } is a
new optional field on Widget, sibling to binding, not a key inside props. That placement is
the fix for leak 3: props is validated against a catalogue’s declared prop set
(undeclared-prop), so a degrade chain living there would either have to be declared into every
catalogue that carries a degradable widget or fail validation — the same trap span was in
before D1. source is structural, like binding, so it needs no catalogue entry and no
consumer to opt in before it can be read.
degradedFrom is ordered outermost-first: Trend@1.0.0 → Collection@1.0.0 reads left to right as
“resolved from Trend, then from Collection, to reach the kind actually rendered.” React
renders it as data-source-type, data-source-version, and — only when degradedFrom is
present — data-degraded plus a tooltip title; the terminal renderer appends
(degraded from Trend@1.0.0) to the widget’s line. Neither renderer computes a degrade chain;
both only display one a compiler already resolved and attached.
D4 — DataBinding.sensitivity is a field, not an enforcement point
DataBinding.sensitivity?: DataClass is a type-only import from @loom/classify — a new
dependency of @loom/blueprint-runtime, erased at build time, adding no runtime coupling to the
classification package. The field lets a compiler declare a binding’s confidentiality class on
the artifact a downstream audience check reads; it does not perform that check. validateBlueprint
confirms the value is a real DataClass, nothing more, and no renderer branches on it — reading
sensitivity and deciding what an audience may see is loom-ui’s audience surface, roadmap
slice 7 (loom/docs/requirements/2026-09-22-blueprint-ir-2-design.md),
not part of this ADR. Shipping the field ahead of its enforcer is deliberate: the field is what
makes a later audience check able to read a Blueprint that already carries the fact, rather than
retrofitting a fourth structural field onto artifacts that predate it.
D5 — What loom does not do here
- No semantic kinds.
Trend,Insight,Investigationand the rest stay a framework catalogue’s vocabulary, resolved to one of loom’s eightWidgetKinds per surface at compile time.WidgetKindis unchanged by this ADR. - No span on
flexorstack. Both takeArray<Widget | Layout>unchanged; neither has columns for a span to occupy, and giving every layout a field only grids use would recreate the union-shape problem D1 rejects a different way. - No audience on the
Blueprint. Audience is a property of the rendered surface, resolved by a compiler per viewer or channel, not a fact about the artifact’s shape; putting it onBlueprintwould make one artifact answer a question (“who may see this”) that varies by who is asking. That resolution is roadmap slice 7 (loom/docs/requirements/2026-09-22-blueprint-ir-2-design.md). - No status for a widget without a binding.
statuslives underbinding.status; a widget with nobindinghas no value to be pending, stale or in error about, so it has no status channel to declare.
Consequences
BLUEPRINT_IR_VERSIONis"2.0.0". Readers written against 1.0.0 keep working:asCellnormalises every grid child, a missingbinding.statusorsourceresolves to no channel and no attribute, and an absentirVersionreads as"1.0.0". Writers —blueprint$,grid,cell— always emit 2.0.0 artifacts from here on.- Content addresses of re-emitted artifacts change. Calling
upgradeBlueprinton a stored 1.0.0 artifact, or simply re-composing it throughblueprint$, produces a new artifact whose address differs from the old one; the old artifact is that new one’s parent, exactly as any other structural edit is. Nothing already written to a store is rewritten in place. - loom-ui’s follow-up PR deletes its workarounds.
SPAN_PROPand theprops.spanemission,handleFromPath’s use as a status side channel (ahydrationStatusPath/handleFromStatusPathpair keeps hydration’s own internal use, unexported), and the terminal adapter’swithStatuswrapper are all retired once loom-ui’s compiler emitsGridCell,binding.statusandbinding.sensitivitydirectly and its adapters read the Blueprint alone. That PR is out of this ADR’s scope; this ADR is what makes it possible. - Seven new
validateBlueprintcodes, findings rather than throws, matching ADR 0088’s “a composer must be able to degrade and record a gap rather than lose the surface”:span-out-of-range,columns-invalid,malformed-node,status-path-invalid,sensitivity-unknown,source-invalid,ir-version-unknown.props.spancontinues to fireundeclared-prop— the field is retired fromprops, not made legal there. Two of them keep the report honest about a defect’s SIZE and the walk’s totality: a grid whosecolumnsis not a positive integer reportscolumns-invalidonce and its cells’ spans are then not checked (otherwise one badcolumnsinvents aspan-out-of-rangeper child), and a layout child that is neither aWidgetnor aLayout— a straynull, a string, aGridCellstranded outside a grid — ismalformed-nodeand the walk continues, so a model-composed artifact can never makevalidateBlueprintthrow. @loom/plugin-renderer-reactexportsWidgetFrame(the matrix wrapper) anduseWidgetSlice(a fiber selector over{ value, status }together, so a status-only change re-renders a widget without forcing a value-only render to pay for it, and vice versa). A grid child gains a wrapper element ONLY when its span exceeds 1, where it carriesgridColumn: span N; span-1 children — and therefore every 1.0.0 grid — are DOM-identical to 1.0.0 and still reconcile by widget id.- Both renderers take an optional
check(widget, value)—TerminalRenderOptions.check, and thecheckprop onBlueprintRenderer(both render paths) andWidgetFrame. It is the value-contract slot betweenerrorandstale; loom supplies no check of its own. @loom/plugin-renderer-terminal’sTerminalWidgetRendererbecomes(widget, value, status?) => string, an additive optional third parameter — every existing two-argument renderer in a caller’s own registry keeps compiling and keeps its old output for widgets with no status channel.renderBlueprintToStringapplies the matrix with⋯(pending, and the loading row — a declared channel whose value has not arrived renders⋯ <title>rather than<title>: —),~(stale) and✗(error, finding appended) glyphs, and appends the degraded-from suffix fromsourceto the first line of a widget’s render when present.KERNEL_ABI_VERSIONstays 1.1.0,EVENT_SCHEMA_VERSIONstays 1.18.0: no event, no fold, no syscall changed.@loom/spec’s capability registry lists this ADR inNOT_A_CAPABILITYfor the same reason ADR 0096 is there — a declared IR version and two renderer options are a contract and an adapter surface, not a runtime subsystem the registry grades.
Amendment 2026-10-11 — WidgetSource.redacted (IR 2.5.0): the Blueprint records the RESULT of an audience decision
Why. D4 shipped DataBinding.sensitivity as a declared, unenforced fact and D5 kept audience
off the Blueprint. loom-ui’s roadmap slice 7 (audience: "viewer" | "broadcast") is the
enforcer: its compile withholds or redacts a widget whose binding sensitivity exceeds what the
surface’s audience may see. Without a mark on the artifact, a renderer or downstream consumer
cannot tell “withheld for this audience” from an ordinary widget, and the standing rule applies:
grow the IR (no reserved prop, no side channel). loom-ui slice 7 is the producer; this change is
the IR it needs, and nothing in loom produces it.
The field. WidgetSource.redacted?: true (additive; BLUEPRINT_IR_VERSION 2.4.0 to 2.5.0,
a MINOR; WIDGET_SOURCE gains redacted: "optional"; 2.4.0 stays in IR_VERSIONS_READ_AS_IS, so
upgradeBlueprint is identity on it).
What redacted: true asserts. The compiler withheld this widget’s content for the audience of
the surface it rendered. It is a statement about the artifact’s content, not about who is asking.
- Renderers MUST draw no value and no label derived from the widget: not
title,props,binding, the resolved value, any status finding, orkind. They SHOULD draw the redaction marker instead. All three first-party renderers do:⊘ redacted(React and DOM:data-binding-state="redacted"plus arole="status"data-binding-marker="redacted"element; terminal: the line⊘ redacted). The marker text is a constant, never data from the widget, and nodata-source-*,data-degraded,data-widget-kind(thekindmay itself be sensitive) ortitleattribute is emitted for it, on first draw or after an in-place update. describeWidgetStatusgains theredactedtag and puts it first in the precedence (redacted > error > empty > contract > stale > pending/loading > ready). It is decided before the value, the status, thecheck, thebinding, or a malformeddegradedFromis read, so none of them can reach a renderer’s output;checkis not called.- Only
trueis legal. Absent means not redacted;false(or anything else) isredacted-invalid, and renderers treat anything buttrueas not redacted.
Relation to degradedFrom and binding: mutually exclusive. A redacted widget has no
binding and no source.degradedFrom. A binding leaves the projection id and path (what was
withheld) on the artifact; a degrade chain leaves the original semantic types. validateBlueprint
enforces this with the new error redacted-invalid (also raised for a non-true value; an explicit
redacted: undefined is absent). The same rule keeps a redacted widget out of the renderers’
projection subscriptions: the React fiber path skips the subscription and the state read for it even
when an unvalidated widget still carries a binding.
What a redacted placeholder carries. source.type and source.version stay required (the
shape contract is unchanged), but the original type may itself be sensitive (SalaryTrend says a
great deal), so the compiler sets the generic constants { type: "redacted", version: "1", redacted: true }. The rest is exactly today’s loom-ui placeholder: an opaque r_ alias id, the
same grid cell, no binding, no label, no original props. Which kind a placeholder uses is the
compiler’s (loom-ui uses its Note); renderers never draw it, and a catalogue must declare it for
validateBlueprint to walk it (an unknown kind is unknown-kind, unchanged).
D5 holds. The Blueprint has no audience field and gains none (the manifests are asserted
free of it). redacted records the RESULT of resolving audience for one rendered surface; the
question (“who may see this”) still varies by viewer and channel and lives in the compiler. Two
artifacts compiled for different audiences differ in content, hence in content address, as any
two different compositions do.
Still not enforced by loom. sensitivity remains declared-not-enforced (D4): loom does not
decide what to redact, it carries and draws the decision. A renderer cannot tell a legitimately
redacted widget from a forged one; the guarantee that the withheld content is absent from the
artifact is the compiler’s, and redacted-invalid checks the leaks a well-formed placeholder
cannot have: a binding, a degradedFrom, a title, any props, or a source.type/version
other than the constants "redacted"/"1". The check runs before the unknown-kind return, so a
redacted widget of an unknown kind that still carries data is caught too.
Source: docs/adr/0097-blueprint-ir-2.md in the loom repository.