docs: add subgraph boundaries doc, unify graph model across ECS architecture

- New companion doc: subgraph-boundaries-and-promotion.md
  - Graph model unification: all graphs are isomorphic, 7→6 entity kinds
  - Typed boundary contracts replace virtual nodes and magic IDs
  - Widget promotion as open decision (connections-only vs simplified)
  - Serialization boundary with indefinite backward-compatible loading
- Update ADR 0008: remove SubgraphEntityId, add GraphId scope, flat World
- Update ecs-target-architecture: World diagram, Entity IDs, problem map
- Update ecs-migration-plan: Phase 1a/1c types and World interface
- Update ecs-lifecycle-scenarios: pack/unpack use graphScope re-parenting
- Update proto-ecs-stores and entity-interactions: annotate subgraph rows

Amp-Thread-ID: https://ampcode.com/threads/T-019d2311-3707-746a-ae9c-65e6f0d67f3e
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
DrJKL
2026-03-24 22:06:00 -07:00
parent b6d8836d14
commit 8449a496ca
7 changed files with 113 additions and 148 deletions

View File

@@ -34,14 +34,14 @@ Adopt an Entity Component System architecture for the graph domain model. This A
Six entity kinds, each with a branded ID type:
| Entity Kind | Current Class(es) | Current ID | Branded ID |
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
| Entity Kind | Current Class(es) | Current ID | Branded ID |
| ----------- | ------------------------------------------------- | --------------------------- | ------------------ |
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
Subgraphs are not a separate entity kind. A subgraph is a node with a `SubgraphStructure` component. See [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) for the full design rationale.
@@ -69,16 +69,14 @@ Components are plain data objects — no methods, no back-references to parent e
#### Shared Components
- **Position** — `{ pos: Point }` — used by Node, Reroute, Group
- **Dimensions** — `{ size: Size, bounding: Rectangle }` — used by Node, Group
- **Position** — `{ pos: Point, size: Size, bounding: Rectangle }` — used by Node, Reroute, Group
- **Visual** — rendering properties specific to each entity kind (separate interfaces, shared naming convention)
#### Node
| Component | Data (from `LGraphNode`) |
| ----------------- | --------------------------------------------------- |
| `Position` | `pos` |
| `Dimensions` | `size`, `_bounding` |
| `Position` | `pos`, `size`, `_bounding` |
| `NodeVisual` | `color`, `bgcolor`, `boxcolor`, `title` |
| `NodeType` | `type`, `category`, `nodeData`, `description` |
| `Connectivity` | slot entity refs (replaces `inputs[]`, `outputs[]`) |
@@ -98,10 +96,10 @@ Components are plain data objects — no methods, no back-references to parent e
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
| Component | Data |
| ------------------- | ------------------------------------------------------------------------ |
| `SubgraphStructure` | `graphId`, typed interface (input/output names, types, slot entity refs) |
| `SubgraphMeta` | `name`, `description` |
| Component | Data |
| ------------------- | ------------------------------------------------------------------------- |
| `SubgraphStructure` | `graphId`, typed interface (input/output names, types, slot entity refs) |
| `SubgraphMeta` | `name`, `description` |
#### Widget
@@ -123,7 +121,7 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| Component | Data (from `Reroute`) |
| --------------- | --------------------------------- |
| `Position` | `pos` (shared) |
| `Position` | (shared) |
| `RerouteLinks` | `parentId`, input/output link IDs |
| `RerouteVisual` | `color`, badge config |
@@ -131,46 +129,24 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| Component | Data (from `LGraphGroup`) |
| --------------- | ----------------------------------- |
| `Position` | `pos` (shared) |
| `Dimensions` | `size`, `bounding` |
| `Position` | (shared) |
| `GroupMeta` | `title`, `font`, `font_size` |
| `GroupVisual` | `color` |
| `GroupChildren` | child entity refs (nodes, reroutes) |
### World
A central registry (the "World") maps entity IDs to their component sets. One
World exists per workflow instance, containing all entities across all nesting
levels. Each entity carries a `graphScope` identifier linking it to its
containing graph. The World also maintains a scope registry mapping each
`graphId` to its parent (or null for the root graph).
The "single source of truth" claim in this ADR is scoped to one workflow
instance. In a future linked-subgraph model, shared definitions can be loaded
into multiple workflow instances, but mutable runtime components
(`WidgetValue`, execution state, selection, transient layout caches) remain
instance-scoped unless explicitly declared shareable.
### Subgraph recursion model
The ECS model preserves recursive nesting without inheritance. A subgraph node
stores `SubgraphStructure.childGraphId`, and the scope registry stores
`childGraphId -> parentGraphId`. This forms a DAG that can represent arbitrary
subgraph depth.
Queries such as "all nodes at depth N" run by traversing the scope registry
from the root, materializing graph IDs at depth `N`, and then filtering entity
queries by `graphScope`.
A central registry (the "World") maps entity IDs to their component sets. One World exists per workflow, containing all entities across all nesting levels. Each entity carries a `graphScope` identifier linking it to its containing graph. The World also maintains a scope registry mapping each `graphId` to its parent (or null for the root graph).
### Systems (future work)
Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:
- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components
- **RenderSystem** — queries `Position` + `*Visual` components
- **SerializationSystem** — queries all components to produce/consume workflow JSON
- **ExecutionSystem** — queries `Execution` + `Connectivity` to determine run order
- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout
- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing
- **LayoutSystem** — queries `Position` + structural components for auto-layout
- **SelectionSystem** — queries `Position` for hit-testing
System design is deferred to a future ADR.
@@ -182,18 +158,6 @@ System design is deferred to a future ADR.
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
### Relationship to ADR 0003 (Command Pattern / CRDT)
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World.
- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands.
The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.
For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md).
### Alternatives Considered
- **Refactoring classes in place**: Lower initial cost, but doesn't solve the cross-cutting concern problem. Each new feature still requires modifying multiple god objects.
@@ -207,7 +171,7 @@ For the full design showing how each lifecycle scenario maps to a command, see [
- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic
- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
- The World provides a single source of truth for all entity state, simplifying debugging and state inspection
- Aligns with the CRDT layout system direction from ADR 0003
### Negative
@@ -217,20 +181,6 @@ For the full design showing how each lifecycle scenario maps to a command, see [
- Migration period where both OOP and ECS patterns coexist, increasing cognitive load
- Widgets and Slots need synthetic IDs, adding ID management complexity
### Render-Loop Performance Implications and Mitigations
Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
Planned mitigations for the ECS render path:
1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities.
3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).
The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.
## Notes
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.

View File

@@ -401,18 +401,20 @@ sequenceDiagram
CS->>W: query Connectivity + SlotConnection for selected nodes
CS->>CS: classify links as internal vs boundary
CS->>W: createEntity(SubgraphEntityId)
CS->>W: setComponent(sgId, SubgraphMeta, { name: 'New Subgraph' })
Note over CS,W: Move selected entities into subgraph scope
CS->>W: setComponent(sgId, SubgraphStructure, {<br/> nodeIds: [...selected],<br/> linkIds: [...internal],<br/> rerouteIds: [...internal]<br/>})
CS->>W: create new GraphId scope in scopes registry
Note over CS,W: Create SubgraphNode entity in parent scope
CS->>W: createEntity(NodeEntityId) [the SubgraphNode]
CS->>W: setComponent(nodeId, Position, { center of selection })
CS->>W: setComponent(nodeId, NodeType, { type: sgId })
CS->>W: setComponent(nodeId, SubgraphStructure, { graphId, interface })
CS->>W: setComponent(nodeId, SubgraphMeta, { name: 'New Subgraph' })
Note over CS,W: Re-parent selected entities into new graph scope
loop each selected entity
CS->>W: update graphScope to new graphId
end
Note over CS,W: Create boundary slots on SubgraphNode
@@ -431,7 +433,7 @@ sequenceDiagram
| Aspect | Current | ECS |
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------- |
| Entity movement | Clone → serialize → configure → remove originals | Move entity IDs into SubgraphStructure component |
| Entity movement | Clone → serialize → configure → remove originals | Re-parent entities: update graphScope to new GraphId |
| Boundary links | Disconnect → remove → recreate → reconnect | Update LinkEndpoints to point at new SubgraphNode slots |
| Intermediate inconsistency | Graph is partially disconnected during operation | Atomic: all component writes happen together |
| Code size | 200+ lines | ~50 lines in system |
@@ -498,26 +500,27 @@ sequenceDiagram
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
CS->>W: getComponent(subgraphNodeId, NodeType)
W-->>CS: { type: subgraphEntityId }
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
W-->>CS: { graphId, interface }
CS->>W: getComponent(subgraphEntityId, SubgraphStructure)
W-->>CS: { nodeIds, linkIds, rerouteIds }
CS->>W: query entities where graphScope = graphId
W-->>CS: all child entities (nodes, links, reroutes, etc.)
Note over CS,W: Move entities back to parent scope
Note over CS,W: Re-parent entities to containing graph scope
CS->>W: remove nodeIds from SubgraphStructure
Note over CS,W: Entities already exist in World — just reparent
loop each child entity
CS->>W: update graphScope to parent scope
end
Note over CS,W: Reconnect boundary links
loop each boundary slot on SubgraphNode
loop each boundary slot in interface
CS->>W: getComponent(slotId, SlotConnection)
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
end
CS->>W: deleteEntity(subgraphNodeId)
CS->>W: deleteEntity(subgraphEntityId) [if no other refs]
CS->>W: remove graphId from scopes registry
Note over CS,W: Offset positions
@@ -530,7 +533,7 @@ sequenceDiagram
| Aspect | Current | ECS |
| ----------------- | --------------------------------------------------- | ------------------------------------------------ |
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entities keep their IDs |
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entities keep their IDs, only graphScope changes |
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as slot entities |
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Move entity references between scopes |
| Link reconnection | Remap origin_id/target_id, create new LLink objects | Update LinkEndpoints component in place |

View File

@@ -88,7 +88,11 @@ Define branded types in a new `src/ecs/entityId.ts`:
```
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
// ... etc per ADR 0008
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
type GraphId = string & { readonly __brand: 'GraphId' } // scope, not entity
```
Add cast helpers (`asNodeEntityId(id: number): NodeEntityId`) for use at
@@ -146,7 +150,7 @@ interface World {
slots: Map<SlotEntityId, SlotComponents>
reroutes: Map<RerouteEntityId, RerouteComponents>
groups: Map<GroupEntityId, GroupComponents>
subgraphs: Map<SubgraphEntityId, SubgraphComponents>
scopes: Map<GraphId, GraphId | null> // graph scope DAG (parent or null for root)
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
@@ -155,6 +159,11 @@ interface World {
}
```
Subgraphs are not a separate entity kind. A node with a `SubgraphStructure`
component represents a subgraph. The `scopes` map tracks the graph nesting DAG.
See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full
model.
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
persistence. The World exists but nothing populates it yet.

View File

@@ -14,8 +14,8 @@ graph TD
Map&lt;NodeEntityId, NodeComponents&gt;"]
LinkStore["Links
Map&lt;LinkEntityId, LinkComponents&gt;"]
SubgraphStore["Subgraphs
Map&lt;SubgraphEntityId, SubgraphComponents&gt;"]
ScopeRegistry["Graph Scopes
Map&lt;GraphId, ParentGraphId | null&gt;"]
WidgetStore["Widgets
Map&lt;WidgetEntityId, WidgetComponents&gt;"]
SlotStore["Slots
@@ -56,8 +56,6 @@ graph LR
number & { __brand: 'NodeEntityId' }"]
LID["LinkEntityId
number & { __brand: 'LinkEntityId' }"]
SID["SubgraphEntityId
string & { __brand: 'SubgraphEntityId' }"]
WID["WidgetEntityId
number & { __brand: 'WidgetEntityId' }"]
SLID["SlotEntityId
@@ -68,10 +66,15 @@ number & { __brand: 'RerouteEntityId' }"]
number & { __brand: 'GroupEntityId' }"]
end
GRID["GraphId
string & { __brand: 'GraphId' }"]:::scopeId
NID -.-x LID
LID -.-x WID
WID -.-x SLID
classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5
linkStyle 0 stroke:red,stroke-dasharray:5
linkStyle 1 stroke:red,stroke-dasharray:5
linkStyle 2 stroke:red,stroke-dasharray:5
@@ -79,6 +82,8 @@ number & { __brand: 'GroupEntityId' }"]
Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected.
Note: `GraphId` is a scope identifier, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a `SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
## 2. Component Composition
### Node: Before vs After
@@ -414,7 +419,8 @@ graph LR
S1["Components: small, focused
data objects (5-10 fields each)"]
S2["Entities are just IDs.
No inheritance hierarchy."]
No inheritance hierarchy.
Subgraph = node + component."]
S3["One system per concern.
Systems don't overlap."]
S4["Branded per-kind IDs.

View File

@@ -4,16 +4,16 @@ This document maps the relationships and interaction patterns between all entity
## Entities
| Entity | Class | ID Type | Primary Location |
| -------- | ------------- | --------------- | ---------------------------------------------------------------------------- |
| Graph | `LGraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` |
| Node | `LGraphNode` | `NodeId` | `src/lib/litegraph/src/LGraphNode.ts` |
| Link | `LLink` | `LinkId` | `src/lib/litegraph/src/LLink.ts` |
| Entity | Class | ID Type | Primary Location |
| -------- | ------------- | --------------- | --------------------------------------------- |
| Graph | `LGraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` |
| Node | `LGraphNode` | `NodeId` | `src/lib/litegraph/src/LGraphNode.ts` |
| Link | `LLink` | `LinkId` | `src/lib/litegraph/src/LLink.ts` |
| Subgraph | `Subgraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` (ECS: node component, not separate entity) |
| Widget | `BaseWidget` | name + nodeId | `src/lib/litegraph/src/widgets/BaseWidget.ts` |
| Slot | `SlotBase` | index on parent | `src/lib/litegraph/src/node/SlotBase.ts` |
| Reroute | `Reroute` | `RerouteId` | `src/lib/litegraph/src/Reroute.ts` |
| Group | `LGraphGroup` | `number` | `src/lib/litegraph/src/LGraphGroup.ts` |
| Widget | `BaseWidget` | name + nodeId | `src/lib/litegraph/src/widgets/BaseWidget.ts` |
| Slot | `SlotBase` | index on parent | `src/lib/litegraph/src/node/SlotBase.ts` |
| Reroute | `Reroute` | `RerouteId` | `src/lib/litegraph/src/Reroute.ts` |
| Group | `LGraphGroup` | `number` | `src/lib/litegraph/src/LGraphGroup.ts` |
Under the ECS model, subgraphs are not a separate entity kind — they are nodes with `SubgraphStructure` and `SubgraphMeta` components. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).

View File

@@ -353,14 +353,14 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
### Priority Order for Extraction

View File

@@ -29,7 +29,7 @@ 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
*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
@@ -183,8 +183,8 @@ graph LR
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
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.
@@ -270,7 +270,6 @@ Under graph unification, packing and unpacking become operations on `graphScope`
tags rather than on class hierarchies:
**Pack** (convert selection to subgraph):
1. Create a new `graphId`
2. Move selected entities: change their `graphScope` to the new graph
3. For links that crossed the selection boundary: create boundary slot mappings
@@ -279,7 +278,6 @@ tags rather than on class hierarchies:
component
**Unpack** (dissolve subgraph):
1. Move entities back: change their `graphScope` to the parent
2. Reconnect boundary links directly (remove the SubgraphNode intermediary)
3. Delete the SubgraphNode
@@ -326,7 +324,6 @@ 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:
1. A new entry is added to `SubgraphStructure.interface.inputs`:
`{ name: "seed", type: "INT", slotId: <new slot> }`
2. The SubgraphNode gains a new input slot of type `INT`. The type → widget
@@ -376,17 +373,17 @@ 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 |
| 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
@@ -471,9 +468,9 @@ 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 |
| 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
@@ -482,10 +479,10 @@ 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 |
| 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
@@ -547,17 +544,17 @@ This is a hard constraint with no expiration:
This document proposes or surfaces the following changes to
[ADR 0008](../adr/0008-entity-component-system.md):
| 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 |
| 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: