Decision records
ADR 0100 — Blueprint IR 2.3.0: the empty binding state
- Status: Accepted — implemented (2026-09-23)
- Date: 2026-09-23
- Implements: the loom half of loom-ui gap G5 (a dependent widget over an empty upstream shows “no data”, never an error) — genUI migrating onto loom-ui, phase 2.
- Builds on: ADR 0097 (D2: the loom-owned binding-status channel and
the canonical status matrix this ADR adds a row to), ADR 0096
(the version pin, and D2’s major/minor rule this bump follows),
ADR 0099 (the previous minor bump, and the
statically-decidable rule its
role-invalidfinding used). - KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0).
BLUEPRINT_IR_VERSION2.2.0 → 2.3.0.
Context
A widget can be bound to data derived from an upstream step: loom-ui’s hydration templates an
argument such as {{steps.latest_orders.output.orders[0].id}}. When the upstream answers with
NO rows — the customer has no orders — the index walks off the end of an empty array, and today
that fails exactly like a misspelt path: the provider fails, and the binding’s status is
error. The app cannot tell its user “there is nothing here” for a question whose subject is
absent; it can only say “something broke”, and a repair loop may try to fix a path that was
never wrong.
The distinction is loom-ui’s to DETECT (its template walker knows an index past the end of a
present array from a missing key), but it is loom’s to CARRY: the status a renderer reads is
loom’s BindingStatus, and ADR 0097 D2 made its matrix identical on every renderer. None of
pending/ready/stale/error means “delivered, and empty”: ready with no value is the
LOADING row (ADR 0097 D2: “it has not arrived”), error is a failure, and stale is an old
value. So the state set grows by one.
Decisions
D1 — BindingState gains "empty", carried by the existing status channel
BindingState becomes "pending" | "ready" | "stale" | "error" | "empty". empty means:
delivery succeeded, and the upstream answered with no subject to show. It is not an error,
not a staleness and not a wait.
It lives exactly where every other state lives — BindingStatus.state, written at
DataBinding.status.path in the same projection state the value resolves from (ADR 0097 D2).
No new field, no second channel: isBindingStatus admits empty under the same structural
rules as the other four (optional numeric seq, optional { code, message } finding), and
resolveWidgetStatus resolves it unchanged. The literal is exact and case-sensitive ("Empty",
"no-data" are malformed, i.e. no status).
A producer MUST clear the bound value when it publishes empty (loom-ui’s hydration fold drops
it, so an earlier ready value never shows under “no data”). This is not optional: a pre-2.3.0
reader cannot see empty and would show a leftover value as READY (D4). A 2.3.0 reader ignores
a value beside empty anyway (D2), as defence in depth, not as licence. The optional finding carries the producer’s reason —
loom-ui’s is upstream-empty — and its message is shown as the row’s text.
D2 — The matrix gains an empty row, ranked right after error
The canonical matrix, engaged only for a binding that declares a status channel, is now:
error→ the finding replaces the value (unchanged).empty→ the “no data” row replaces the value. The widget’s own renderer never runs, and a value beside the status is ignored.- The caller’s
check(widget, value)slot (unchanged) — skipped underemptyas undererror: there is no value to check. stale,pending,ready/absent (unchanged).
Rendering:
- React (
WidgetFrame):data-binding-state="empty"on the frame, and a marker<div role="status" data-binding-marker="empty">○ {title}: {finding.message ?? "no data"}</div>. - Terminal (
renderBlueprintToString):○ ${title}: ${finding.message ?? "no data"}, on the registry and kind-blind paths alike.
○ (an empty circle) is the glyph beside ⋯/~/✗; title is widget.title ?? widget.kind
as in every other row. A channel-less widget still ignores any status in the state (ADR 0097 D2).
D3 — Accessibility: announced politely, never as an alert
The React marker is a role="status" live region: a screen reader announces “no data” when it
appears, politely, without interrupting. It is deliberately not role="alert", which the
error row uses — an empty answer is a result, not a failure. An explicit role on a div is
used rather than <output> (Biome’s useSemanticElements suggestion), because <output> means
a calculation result and its implicit live-region support varies between screen readers. The
terminal row is plain text; the glyph and the words “no data” (or the finding’s message) are the
whole signal.
D4 — An older reader degrades to its loading/ready fallback, never to its error row
A reader built against 2.2.0 or earlier does not know empty: its isBindingStatus rejects
{ state: "empty" }, so resolveWidgetStatus returns undefined — ADR 0097 D2’s “never
written, or malformed” case. Its matrix then shows:
- the loading row when there is no value — which is what a producer following D1 leaves
(React
…, terminal⋯ <title>); - the value as ready if a producer broke D1’s MUST and left one — the reason D1 is a MUST.
Neither is the error row, and nothing throws. This assumes a caller-supplied check accepts an
undefined value (the older reader runs it, since it sees no status); any working check already
must, because the ordinary loading row passes it undefined too. That is the safe direction: an older reader shows
“not arrived yet” for “arrived, and empty” — incomplete, never alarming, never a repair trigger.
Pinned by a terminal test that feeds a state this build does not know through the same path.
D5 — empty is not an error, and triggers nothing
loom attaches no behaviour to empty: no finding in validateBlueprint, no event, no retry, no
drift. Neither renderer calls anything on it. Whatever a producer does on its side (loom-ui
excludes empty from caching, drift and repair) is that producer’s contract; the IR only names
the state.
No new validateBlueprint finding. ADR 0099 D4 added role-invalid because a malformed role
is in the ARTIFACT and validateBlueprint is the only gate it passes. A binding status is not in
the artifact at all — the Blueprint carries only WHERE it lives (binding.status.path); the
state itself is written into projection state at run time, which validateBlueprint never sees.
There is nothing statically decidable to report. A malformed status at run time is already
handled: it resolves to undefined (ADR 0097 D2), never a throw.
D6 — A minor bump, and 2.2.0 artifacts are read as they are
A new literal in a closed union that readers switch over is a shape change a consumer can
observe, so the manifest records it: BLUEPRINT_IR_SHAPE.bindingStates gains empty, and
irVersion.test.ts pins a fifth digest beside the frozen 1.0.0, 2.0.0, 2.1.0 and 2.2.0
rows. It is ADR 0096 D2’s
minor case — no field is added, removed or flipped, and D4 makes the new value safe for an
older reader — so BLUEPRINT_IR_VERSION becomes 2.3.0. IR_VERSIONS_READ_AS_IS gains 2.2.0,
so validateBlueprint knows 2.0.0–2.3.0 and upgradeBlueprint is identity on all four. Writers
(blueprint$) stamp 2.3.0 from here on. A new test asserts isBindingStatus admits exactly the
manifested states, so the runtime set cannot drift from the IR.
Consequences
BLUEPRINT_IR_VERSIONis"2.3.0". Every artifact written under 2.x is read as it is; only a PRODUCER that writesemptyneeds a 2.3.0-aware reader to get the “no data” row (D4 otherwise).- Newly authored blueprints are stamped
2.3.0, so re-composing content identical to an earlier 2.2.0 emission yields a different content address; stored 2.x artifacts keep theirs. - A consumer that switches exhaustively over
BindingStatesees a new case (atscerror in aRecord<BindingState, …>or anevercheck) — the intended signal. - loom-ui’s
packages/core/src/blueprintIr.test.tspins the IR version it compiles against (2.2.0 once its G2 work lands) and goes red when loom-ui bumps itsLOOM_REFpast this ADR. That is the intended signal: the same PR teaches loom-ui’s hydration to publishempty(upstream-empty) and its Lit base to draw it. KERNEL_ABI_VERSIONstays 1.1.0 andEVENT_SCHEMA_VERSIONstays 1.18.0: a binding status is projection STATE, not an event.@loom/speclists this ADR inNOT_A_CAPABILITY, for the same reason as 0096–0099: an IR contract and renderer behaviour, not a runtime subsystem to grade.- The next minor bump adds
2.3.0toIR_VERSIONS_READ_AS_IS.
Source: docs/adr/0100-blueprint-ir-empty-binding-state.md in the loom repository.