A companion to ADR 0008. Where the ADR defines the entity taxonomy and component decomposition, this document examines the three questions the ADR defers — questions that turn out to be facets of a single deeper insight.
For the structural problems motivating this work, see Entity Problems. For the target architecture, see ECS Target Architecture. For the phased migration roadmap, see ECS Migration Plan.
Consider a subgraph. It contains nodes, links, reroutes, groups. It has inputs and outputs. It can be serialized, deserialized, copied, pasted. It has an execution order. It has a version counter.
Now consider a graph. It contains nodes, links, reroutes, groups. It has inputs and outputs (to the execution backend). It can be serialized, deserialized, copied, pasted. It has an execution order. It has a version counter.
These are the same thing.
The current codebase almost knows this. Subgraph extends LGraph — the
inheritance hierarchy encodes the identity. But it encodes it as a special case
of a general case, when in truth there is no special case. A subgraph is not a
kind of graph. A subgraph is a graph. The root workflow is not a privileged
container — it is simply a graph that happens to have no parent.
This is the pattern that appears everywhere in nature and mathematics: the Mandelbrot set, the branching of rivers, the structure of lungs. At every scale, the same shape. A graph that contains a node that contains a graph that contains a node that contains a graph. The part is isomorphic to the whole.
Three symptoms reveal the false distinction:
-
Subgraphlives insideLGraph.ts(line 2761). It cannot be extracted to its own file because the circular dependency betweenSubgraphandLGraphis unresolvable under the current inheritance model. The architecture is telling us, in the only language it has, that these two classes want to be one thing. -
Subgraphoverridesstateto delegate torootGraph.state(line 2790). A subgraph does not own its own ID counters — it borrows them from the root. It is not independent. It never was. -
Execution flattens the hierarchy anyway.
SubgraphNode.getInnerNodes()dissolves the nesting boundary to produce a flat execution order. The runtime already treats the hierarchy as transparent. Only the data model pretends otherwise.
graph TD
subgraph Current["Current: Inheritance Hierarchy"]
direction TB
LG["LGraph (base)"]
SG["Subgraph (extends LGraph)"]
LGN["LGraphNode (base)"]
SGN["SubgraphNode (extends LGraphNode)"]
LG -.->|"_subgraphs Map"| SG
SG ==>|"extends"| LG
SGN ==>|"extends"| LGN
SGN -.->|".subgraph"| SG
end
subgraph Unified["Unified: Composition"]
direction TB
W["Dedicated stores (flat)"]
N1["Node A
graphScope: root"]
N2["Node B (subgraph carrier)
graphScope: root
+ SubgraphStructure component"]
N3["Node C
graphScope: graph-2"]
N4["Node D
graphScope: graph-2"]
W --- N1
W --- N2
W --- N3
W --- N4
N2 -.->|"SubgraphStructure.graphId"| GS2["graph-2"]
end
style Current fill:#2a1a1a,stroke:#4a2a2a,color:#e0e0e0
style Unified fill:#1a2a1a,stroke:#2a4a2a,color:#e0e0e0
Across the dedicated stores:
- Every graph is a graph. The "root" graph is simply the one whose
graphScopehas no parent. - Nesting is a component. A node can carry a
SubgraphStructurecomponent, which references another graph scope. That scope contains its own entities — nodes, links, widgets, reroutes, groups — and each store entry tags the scope it belongs to. - Scope tagging over container nesting. Entries across all nesting levels
live in the same stores, each tagged with a
graphScopeidentifier. The stores stay flat; the fractal structure is encoded in the keys and scope tags, with no recursive containers. - Entity taxonomy: six kinds. A subgraph is a node carrying a
SubgraphStructurecomponent. The taxonomy is: Node, Link, Widget, Slot, Reroute, Group.SubgraphEntityIdis replaced by aGraphIdscope identifier. - Numeric ID counters stay shared. Node/link/reroute IDs are allocated from
a single counter space across nesting levels, preserving the current
rootGraph.statebehavior and guaranteeing uniqueness. Widget keys embed their scope directly (WidgetId = graphId:nodeId:name). - Graph scope parentage is tracked. A scope registry maps each
graphIdto its parentgraphId(or null for the root). This enables the ancestor walk required by the acyclicity invariant and supports queries like "all entities transitively contained by this graph."
Self-similarity is beautiful, but recursion without a base case is catastrophe. A subgraph node must not contain a graph that contains itself, directly or through any chain of nesting.
The current code handles this with a blunt instrument:
Subgraph.MAX_NESTED_SUBGRAPHS = 1000. This limits depth but does not prevent
cycles. A graph that references itself at depth 1 is just as broken as one that
does so at depth 1001.
The proper invariant is structural:
The
graphScopereference graph induced bySubgraphStructure.graphIdmust form a directed acyclic graph (DAG). Before creating or modifying aSubgraphStructure, the system must verify that the targetgraphIdis not an ancestor of the containing graph's scope.
This is a simple ancestor walk — from the proposed target graph, follow parent
references upward. If the containing graph appears in the chain, the operation
is rejected. The check runs on every mutation that creates or modifies a
SubgraphStructure component, not as an offline validation pass. A cycle in the
live graph is unrecoverable; prevention must be synchronous.
When a link crosses from a parent graph into a subgraph, the current system interposes three layers of indirection:
graph LR
subgraph Parent["Parent Graph"]
EN["External Node"]
SGN["SubgraphNode"]
end
subgraph Magic["Boundary Infrastructure"]
SI["SubgraphInput
(slot-like object)"]
SIN["SubgraphInputNode
(virtual node, ID = -10)"]
end
subgraph Interior["Subgraph Interior"]
IN["Internal Node"]
end
EN -->|"Link 1
(parent graph)"| SGN
SGN -.->|"maps to"| SI
SI -->|"owns"| SIN
SIN -->|"Link 2
(subgraph, magic ID)"| IN
style Magic fill:#3a2a1a,stroke:#5a3a2a,color:#e0e0e0
Two separate links. A virtual node with a magic sentinel ID
(SUBGRAPH_INPUT_ID = -10). A slot-like object that is neither a slot nor a
node. Every link in the system carries a latent special case: am I a boundary
link? Every pack/unpack operation must remap both links and reconcile both ID
spaces.
This complexity exists because the boundary was never designed — it accreted. The virtual nodes are wiring infrastructure with no domain semantics. The magic IDs are an escape hatch from a type system that offers no legitimate way to express "this connection crosses a scope boundary."
A subgraph boundary is, mathematically, a function signature. A subgraph takes
typed inputs and produces typed outputs. The types come from the same vocabulary
used by all nodes: "INT", "FLOAT", "MODEL", "IMAGE", "CONDITIONING",
and any custom type registered by extensions.
The boundary model should say exactly this:
SubgraphStructure {
graphId: GraphId
interface: {
inputs: Array<{ name: string, type: ISlotType, slotId: SlotEntityId }>
outputs: Array<{ name: string, type: ISlotType, slotId: SlotEntityId }>
}
}
The interface is the contract. Each entry declares a name, a type, and a
reference to the corresponding slot on the SubgraphNode. Inside the subgraph,
internal nodes connect to boundary slots through ordinary links — no virtual
nodes, no magic IDs, no special cases. A link is a link. A slot is a slot.
graph LR
subgraph Parent["Parent Graph"]
EN["External Node"]
SGN["SubgraphNode
+ SubgraphStructure"]
EN -->|"ordinary link"| SGN
end
subgraph Interface["Typed Interface"]
I1["input: 'seed'
type: INT
slotId: S-42"]
O1["output: 'image'
type: IMAGE
slotId: S-43"]
end
subgraph Interior["Subgraph (graphScope: G-2)"]
IN1["KSampler"]
IN2["VAEDecode"]
IN1 -->|"ordinary link"| IN2
end
SGN -.->|"interface.inputs[0]"| I1
I1 -.->|"boundary link"| IN1
IN2 -.->|"boundary link"| O1
O1 -.->|"interface.outputs[0]"| SGN
style Interface fill:#1a2a3a,stroke:#2a3a5a,color:#e0e0e0
The existing type system already knows which types get widgets. When a node
definition declares an input of type "INT", the widget registry
(widgetStore.widgets) maps that type to a number widget constructor. When the
type is "MODEL", no widget constructor exists — the input is socket-only.
This mechanism applies identically to subgraph interface inputs. If a subgraph
declares an input of type "INT", the SubgraphNode gets an INT input slot, and
the type → widget mapping gives that slot a number widget. If the input is type
"MODEL", the SubgraphNode gets a socket-only input. No special promotion
machinery is needed. The type system already does the work.
This is the key insight that connects graph boundaries to widget promotion, and it is the subject of Section 3.
Under graph unification, packing and unpacking become operations on graphScope
tags rather than on class hierarchies:
Pack (convert selection to subgraph):
- Create a new
graphId - Move selected entities: change their
graphScopeto the new graph - For links that crossed the selection boundary: create boundary slot mappings
in
SubgraphStructure.interface, infer types from the connected slots - Create a SubgraphNode in the parent scope with the
SubgraphStructurecomponent
Unpack (dissolve subgraph):
- Move entities back: change their
graphScopeto the parent - Reconnect boundary links directly (remove the SubgraphNode intermediary)
- Delete the SubgraphNode
The critical simplification: no ID remapping. Entities keep their IDs
throughout. Only the graphScope tag changes. The current system's
clone-remap-configure dance — separate logic for node IDs, link IDs, reroute
IDs, subgraph UUIDs — is eliminated entirely.
Widget promotion is the mechanism by which an interior widget surfaces on the SubgraphNode in the parent graph. A user right-clicks a widget inside a subgraph and selects "Promote to parent." The widget's value becomes controllable from the outside.
This is where the document presents two candidates for the ECS model. The team must choose before Phase 3 of the migration.
Historical note: the legacy three-layer mechanism described below (PromotionStore, PromotedWidgetViewManager, PromotedWidgetView) has been removed by ADR 0009. Promoted value widgets are now standard linked
SubgraphInputwidgets. This section is retained for archival context.
The legacy system had three layers:
-
PromotionStore (removed; formerly
src/stores/promotionStore.ts): A ref-counted Pinia store mappinggraphId → subgraphNodeId → PromotedWidgetSource[]. Tracked which interior widgets were promoted and provided O(1)isPromotedByAny()queries. -
PromotedWidgetViewManager (removed): A reconciliation layer that maintained stable
PromotedWidgetViewproxy widget objects, diffing against the store on each update — a pattern analogous to virtual DOM reconciliation. -
PromotedWidgetView (removed): A proxy widget on the SubgraphNode that mirrored the interior widget's type, value, and options. Reads and writes delegated to the original widget's entry in
WidgetValueStore.
Serialized as properties.proxyWidgets on the SubgraphNode (now consumed only
during legacy load repair).
Promotion is not a separate mechanism. It is adding a typed input to the subgraph's interface.
When a user "promotes" widget X (type INT) on interior node N:
- A new entry is added to
SubgraphStructure.interface.inputs:{ name: "seed", type: "INT", slotId: <new slot> } - The SubgraphNode gains a new input slot of type
INT. The type → widget mapping (widgetStore) creates an INT widget on that slot automatically. - Inside the subgraph, the interior node's widget input is replaced by a
connection from the boundary input — the same transformation that occurs
today when a user drags a link to a widget input (
forceInputbehavior).
"Demoting" is the reverse: remove the interface input, restore the interior widget to its standalone state.
The PromotionStore, PromotedWidgetViewManager, and PromotedWidgetView are eliminated entirely. The existing slot, link, and widget infrastructure handles everything. Promotion becomes an operation on the subgraph's function signature, not a parallel state management system.
Value flow under Candidate A:
sequenceDiagram
participant User
participant SW as SubgraphNode Widget (INT)
participant BS as Boundary Slot
participant IW as Interior Node Input
participant Exec as Execution
User->>SW: sets value = 42
Note over SW: normal widget, normal WidgetValueStore entry
SW->>BS: value carried by boundary link
BS->>IW: arrives as input connection value
Exec->>IW: reads input value (42)
ADR 0009 chose Candidate A. Candidate B is retained here as the rejected alternative; it relied on a source-widget lookup model that no longer exists.
Promotion remains a first-class concept, simplified from three layers to one:
- A
WidgetPromotioncomponent on a widget entity referencing the host node and source widget - The SubgraphNode's widget list includes promoted widget references directly
- Value reads/writes delegate to the source widget's value via a store lookup
- Serialized as
properties.proxyWidgets(unchanged)
This removes the ViewManager and proxy widget reconciliation but preserves the concept of promotion as distinct from connection.
| Dimension | A: Connections-Only | B: Simplified Promotion |
|---|---|---|
| New concepts | None — reuses slots, links, widgets | WidgetPromotion component |
| Code removed | PromotionStore, ViewManager, PromotedWidgetView, _syncPromotions |
ViewManager, proxy reconciliation |
| Shared subgraph compat | ✅ Each instance has independent interface inputs with independent values | |
| Dynamic widgets | ✅ Input type drives widget creation via existing registry | |
| Serialization | Interface inputs serialized as SubgraphIO entries |
Separate proxyWidgets property |
| Backward-compatible loading | Migration: old proxyWidgets → interface inputs + boundary links |
Direct — same serialization shape |
| UX consistency | Promoted widgets look like normal input widgets | Promoted widgets look like proxy widgets (distinct) |
| Widget ordering | Slot ordering (reorderable like any input) | Explicit promotion order (movePromotion) |
| Nested promotion | Adding interface inputs at each nesting level — simple mechanically, but N levels = N manual promote operations for the user | disambiguatingSourceNodeId complexity persists |
Whichever candidate is chosen:
- Internal identity is the
WidgetIdstring. Serialization uses widget name- parent node reference, while runtime state keys on
WidgetId(graphId:nodeId:name). This is settled (see Section 4).
- parent node reference, while runtime state keys on
- The type → widget mapping is authoritative. The widget registry
(
widgetStore.widgets) is the single source of truth for which types produce widgets. No parallel mechanism should duplicate this. - Backward-compatible loading is non-negotiable. Existing workflows with
proxyWidgetsmust load correctly, indefinitely (see Section 4). - The design must not foreclose functional subgraphs. A future where subgraphs behave as pure functions — same inputs, same outputs, no instance-specific state beyond inputs — must remain reachable. This is a constraint, not a current requirement.
ADR 0009 chooses Candidate A for promoted value widgets. It eliminates an entire subsystem by recognizing a structural truth: promotion is adding a typed input to a function signature. The type system already handles widget creation for typed inputs. Building a parallel mechanism for "promoted widgets" is building a second, narrower version of something the system already does.
The cost of A is a migration path for existing proxyWidgets serialization. On
load, the SerializationSystem converts value-widget proxyWidgets entries
into interface inputs and boundary links. Once loaded and re-saved, the workflow
uses the new format. ADR 0009 separates display-only preview exposures from
promoted value widgets; those previews use their own host-scoped serialized
representation instead of linked SubgraphInput widgets.
The internal model and the serialization format are different things, designed for different purposes.
The World is optimized for runtime queries: branded IDs for type safety, flat entity maps for O(1) lookup, component composition for flexible querying. The serialization format is optimized for interchange: named keys for human readability, nested structure for self-containment, positional widget value arrays for compactness, backward compatibility across years of workflow files.
These are not the same optimization targets. Conflating them — forcing the
internal model to mirror the wire format, or vice versa — creates a system that
is mediocre at both jobs. The SerializationSystem is the membrane between
these two worlds. It translates in both directions, and it is the only component
that knows about legacy format quirks.
ISerialisedGraph {
nodes: ISerialisedNode[] // widgets_values[], inputs[], outputs[]
links: SerialisedLLinkArray[] // [id, origin_id, origin_slot, target_id, target_slot, type]
reroutes: SerialisableReroute[]
groups: ISerialisedGroup[]
subgraphs: ExportedSubgraph[] // recursive: each contains its own nodes, links, etc.
}
Subgraphs nest recursively. Widget values are positional arrays on each node. Link data uses tuple arrays for compactness.
Internal model: Flat. All entities across all nesting levels live in one
World, tagged with graphScope. No recursive containers.
Serialized format: Nested. The SerializationSystem walks the scope tree
and produces the recursive ExportedSubgraph structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
| Direction | Format | Notes |
|---|---|---|
| Save/export | Nested (current shape) | SerializationSystem walks scope tree |
| Load/import | Nested (current) or future flat | Migration: normalize to store-backed state on load |
The migration pattern: load any supported format and normalize to the internal model. The system accepts old formats indefinitely but produces the current format on save.
| Context | Identity | Example |
|---|---|---|
| Internal (store) | WidgetId composite string |
'graphId:42:seed' as WidgetId |
| Serialized | Position in widgets_values[] + name from node definition |
widgets_values[2] → third widget |
On save: the SerializationSystem queries WidgetIdentity.name and
WidgetValue.value, produces the positional array ordered by widget creation
order.
On load: widget values are matched by name against the node definition's input
specs, then registered in WidgetValueStore under their WidgetId.
This is the existing contract, preserved exactly.
Under graph unification, the subgraph's typed interface
(SubgraphStructure.interface) is serialized as the existing SubgraphIO
format:
SubgraphIO {
id: UUID
name: string
type: string // slot type (e.g. "INT", "MODEL")
linkIds?: LinkId[]
}
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
widgets: they become interface inputs, serialized as additional SubgraphIO
entries. On load, legacy value-widget proxyWidgets data is converted to
interface inputs and boundary links. On save, repaired proxyWidgets entries
are no longer written. Display-only preview exposures use separate
host-scoped previewExposures serialization.
This is a hard constraint with no expiration:
-
Any workflow saved by any prior version of ComfyUI must load successfully. The
SerializationSystemis the sole custodian of legacy format knowledge — positional widget arrays, magic link IDs,SubgraphIOshapes,proxyWidgets, tuple-encoded links. -
The rest of the system never sees legacy formats. On load, all data is normalized to ECS components. No other system, component, or query path needs to know that
SUBGRAPH_INPUT_ID = -10ever existed. -
New format features are additive. Old fields are never removed from the accepted schema. They may be deprecated in documentation, but the parser accepts them indefinitely.
-
Save format may evolve. The output format can change (e.g., dropping
proxyWidgetsin favor of interface inputs under Candidate A), but only when the corresponding load-path migration is in place and validated.
This document proposes or surfaces the following changes to ADR 0008:
| Area | Current ADR 0008 | Proposed Change |
|---|---|---|
| Entity taxonomy | 7 kinds including SubgraphEntityId |
6 kinds — subgraph is a node with SubgraphStructure component |
SubgraphEntityId |
string & { __brand: 'SubgraphEntityId' } |
Eliminated; replaced by GraphId scope identifier |
| Subgraph components | SubgraphStructure, SubgraphMeta listed as separate-entity components |
Become node components on SubgraphNode entities |
| Storage structure | Implied per-graph containment | Dedicated stores with graphScope-tagged entries; no single registry |
| Acyclicity | Not addressed | DAG invariant on SubgraphStructure.graphId references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on SubgraphStructure; no virtual nodes or magic IDs |
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; SerializationSystem is the membrane |
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
These amendments should be applied to ADR 0008 and the related architecture documents in a follow-up pass after team review of this document:
- ECS Target Architecture — World Overview diagram, Entity IDs diagram, component tables
- ECS Migration Plan — Phase 1c World type definition, dependency graph
- ECS Lifecycle Scenarios — unpack flow uses
subgraphEntityId - Proto-ECS Stores — extraction map lists Subgraph as distinct entity kind
- Entity Interactions — entity table and overview diagram list Subgraph separately