## Summary
Architecture documentation proposing an Entity Component System for the
litegraph layer.
```mermaid
graph LR
subgraph Today["Today: Spaghetti"]
God["🍝 God Objects"]
Circ["🔄 Circular Deps"]
Mut["💥 Render Mutations"]
end
subgraph Tomorrow["Tomorrow: ECS"]
ID["🏷️ Branded IDs"]
Comp["📦 Components"]
Sys["⚙️ Systems"]
World["🌍 World"]
end
God -->|"decompose"| Comp
Circ -->|"flatten"| ID
Mut -->|"separate"| Sys
Comp --> World
ID --> World
Sys -->|"query"| World
```
## Changes
- **What**: ADR 0008 + 4 architecture docs (no code changes)
- `docs/adr/0008-entity-component-system.md` — entity taxonomy, branded
IDs, component decomposition, migration strategy
- `docs/architecture/entity-interactions.md` — as-is Mermaid diagrams of
all entity relationships
- `docs/architecture/entity-problems.md` — structural problems with
file:line evidence
- `docs/architecture/ecs-target-architecture.md` — target architecture
diagrams
- `docs/architecture/proto-ecs-stores.md` — analysis of existing Pinia
stores as proto-ECS patterns
## Review Focus
- Does the entity taxonomy (Node, Link, Subgraph, Widget, Slot, Reroute,
Group) cover all cases?
- Are the component decompositions reasonable starting points?
- Is the migration strategy (bridge layer, incremental extraction)
feasible?
- Are there entity interactions or problems we missed?
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10420-docs-ADR-0008-Entity-Component-System-32d6d73d365081feb048d16a5231d350)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
27 KiB
Subgraph Boundaries and Widget Promotion
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.
1. Graph Model Unification
The false distinction
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.
What the code says
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.
The unified model
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["World (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
In the ECS World:
- Every graph is a graph. The "root" graph is simply the one whose
graphScopehas no parent. - Nesting is a component, not a type. A node can carry a
SubgraphStructurecomponent, which references another graph scope. That scope contains its own entities — nodes, links, widgets, reroutes, groups — all living in the same flat World. - One World per workflow. All entities across all nesting levels coexist in
a single World, each tagged with a
graphScopeidentifier. There are no sub-worlds, no recursive containers. The fractal structure is encoded in the data, not in the container hierarchy. - Entity taxonomy: six kinds, not seven. ADR 0008 defines seven entity kinds
including
SubgraphEntityId. Under unification, "subgraph" is not an entity kind — it is a node with a component. The taxonomy becomes: Node, Link, Widget, Slot, Reroute, Group. - ID counters remain global. All entity IDs are allocated from a single
counter space, shared across all nesting levels. This preserves the current
rootGraph.statebehavior and guarantees ID uniqueness across the entire World. - Graph scope parentage is tracked. The World maintains a scope registry:
each
graphIdmaps to 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."
The acyclicity invariant
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.
2. Graph Boundary Model
How boundaries work today
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."
Boundaries as typed contracts
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
Type-driven widget surfacing
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.
Pack and unpack
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.
3. Widget Promotion: Open Decision
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.
Current mechanism
The current system has three layers:
-
PromotionStore (
src/stores/promotionStore.ts): A ref-counted Pinia store mappinggraphId → subgraphNodeId → PromotedWidgetSource[]. Tracks which interior widgets are promoted and provides O(1)isPromotedByAny()queries. -
PromotedWidgetViewManager: A reconciliation layer that maintains stable
PromotedWidgetViewproxy widget objects, diffing against the store on each update — a pattern analogous to virtual DOM reconciliation. -
PromotedWidgetView: A proxy widget on the SubgraphNode that mirrors the interior widget's type, value, and options. Reads and writes delegate to the original widget's entry in
WidgetValueStore.
Serialized as properties.proxyWidgets on the SubgraphNode.
Candidate A: Connections-only
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)
Candidate B: Simplified component promotion
Promotion remains a first-class concept, simplified from three layers to one:
- A
WidgetPromotioncomponent on a widget entity:{ promotedTo: NodeEntityId, sourceWidget: WidgetEntityId } - The SubgraphNode's widget list includes promoted widget entity IDs directly
- Value reads/writes delegate to the source widget's
WidgetValuecomponent via World lookup - Serialized as
properties.proxyWidgets(unchanged)
This removes the ViewManager and proxy widget reconciliation but preserves the concept of promotion as distinct from connection.
Tradeoff matrix
| 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 | ⚠️ Promotion delegates to a source widget by entity ID — when multiple SubgraphNode instances share a definition, which instance's source widget is authoritative? |
| Dynamic widgets | ✅ Input type drives widget creation via existing registry | ⚠️ Must handle type changes in promotion component |
| 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 |
Constraints that hold regardless
Whichever candidate is chosen:
WidgetEntityIdis internal. Serialization uses widget name + parent node reference. This is settled (see Section 4).- 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.
Recommendation and decision criteria
Lean toward A. 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 proxyWidgets entries into interface
inputs and boundary links. This is a one-time ratchet conversion — once
loaded and re-saved, the workflow uses the new format.
Choose B if the team determines that promoted widgets must remain
visually or behaviorally distinct from normal input widgets in ways the type →
widget mapping cannot express, or if the proxyWidgets migration burden exceeds
the current release cycle's capacity.
Decision needed before Phase 3 of the ECS migration, when systems are introduced and the widget/connectivity architecture solidifies.
4. Serialization Boundary
Principle
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.
Current serialization structure
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.
What changes under graph unification
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 | Ratchet: normalize to flat World on load |
The "ratchet conversion" pattern: load any supported format, normalize to the internal model. The system accepts old formats indefinitely but produces the current format on save.
Widget identity at the boundary
| Context | Identity | Example |
|---|---|---|
| Internal (World) | WidgetEntityId (opaque branded number) |
42 as WidgetEntityId |
| 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 assigned WidgetEntityIds from the global counter.
This is the existing contract, preserved exactly.
Subgraph interface at the boundary
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[]
}
If Candidate A (connections-only promotion) is chosen: promoted widgets become
interface inputs, serialized as additional SubgraphIO entries. On load, legacy
proxyWidgets data is converted to interface inputs and boundary links (ratchet
migration). On save, proxyWidgets is no longer written.
If Candidate B (simplified promotion) is chosen: proxyWidgets continues to be
serialized in its current format.
Backward-compatible loading contract
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.
5. Impact on ADR 0008
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 |
| World structure | Implied per-graph containment | Flat World with graphScope tags; one World per workflow |
| 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 | Open decision: Candidate A (connections-only) vs B (simplified component) |
| 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