Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Decision records

ADR 0094 — Node identity across a structural change

  • Status: Accepted — implemented (2026-09-10)
  • Date: 2026-09-10
  • Implements: the item ADR 0092 D5 gated on a second consumer. The gate is satisfied, not waived — see D3.
  • Builds on: ADR 0088 (findings-not-throws, and the denominator), ADR 0083 (the outward gate), ADR 0070 / the a2ui bridge’s recompose.
  • KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0). Pure functions over JSON-serializable values, in an existing package.

Context

A widget’s id is derived by default — stableId hashes kind + props — so a node keeps its identity only while its props stay byte-identical. widgets.ts has said so since it was written, and docs/composed-surfaces.md tells a composer’s author to supply explicit ids. Neither is a check, and the failure is silent in both directions that matter:

  • Revision. A composer re-emits a view with one prop edited. To every consumer the edited node is a different node: it remounts, and any pin, annotation, or agent reference keyed on the old id dangles.
  • Variants. Two trees generated separately are meant to share a set of nodes — the floor a cohort’s blueprint must contain, the panel a runbook step names. Vary a title between them and the two trees quietly stop agreeing about what the node is.

validateBlueprint checks one tree, including ids within it (duplicate-node-id). Nothing compared one tree to another, which is where identity is actually lost.

Decisions

D1 — One question, asked over id sets

preservesNodes(before: Iterable<string>, after: Iterable<string>): IdentityReport

Both failures above reduce to which ids did this change drop?, so this is one function, and it takes id sets rather than blueprints — which is what lets the second consumer be A2UI, whose components are a flat list and not a Blueprint. blueprintNodeIds and preservesBlueprintNodes spell the blueprint case.

The same call answers the floor question with the arguments swapped in meaning: pass the floor’s ids as before, and any finding is a floor violation while everything above the floor lands in added — a variant may add, never subtract.

D2 — It reports, and added is data rather than a defect

Dropping a node is legitimate (a subtree genuinely being replaced) and illegitimate (a re-identification nobody intended), and neither the blueprint nor the bridge can tell them apart. So: findings, never a throw — ADR 0088 D3’s reasoning, unchanged.

added is reported as data because a dropped finding beside an added id is what a re-identification looks like; the caller has the context to judge. And compared is the denominator, for the same reason BlueprintValidation.widgets is: ok: true over an empty before is not evidence that identity held, and a caller whose floor computation returned nothing would otherwise read a clean report. That case has its own test.

D3 — The second consumer is real, and it is what shaped the API

ADR 0092 D5 gated this on the second-consumer rule, naming the a2ui revision path as the candidate. That path exists (A2UISurfaceBridge.recompose), so the gate is satisfied by wiring it, not by waiving the rule:

bridge.identityDiff(next) reports which component ids a proposed recompose would drop, as a query — it neither commits the tree nor emits, so “look before you leap” is not itself the leap. A client keys rendered components by id; a dropped id is a component it unmounts and rebuilds, discarding whatever state lived inside it.

Being a genuine second consumer changed the design rather than ratifying it: a blueprint-shaped preservesNodes(before: Blueprint, after: Blueprint) — the obvious first API, and the one a single consumer would have accepted — cannot serve a flat component list at all. The rule earned its keep here.

plugins/a2ui therefore gains a dependency on @loom/blueprint-runtime, the edge renderer-react and renderer-terminal already have.

Consequences

  • A composer that must survive revision has a check, not just a paragraph telling it to supply ids.
  • A floor — every variant contains this set of nodes — is expressible without new vocabulary.
  • Nothing is enforced automatically. Both consumers hand back a report; refusing on a legitimate subtree replacement would be worse than the silence this replaces.

Source: docs/adr/0094-blueprint-node-identity.md in the loom repository.