Files
ComfyUI_frontend/docs/architecture/ecs-migration-plan.md
Alexander Brown 3e197b5c57 docs: ADR 0008 — Entity Component System (#10420)
## 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>
2026-03-26 16:14:44 -07:00

723 lines
28 KiB
Markdown

# ECS Migration Plan
A phased roadmap for migrating the litegraph entity system to the ECS
architecture described in [ADR 0008](../adr/0008-entity-component-system.md).
Each phase is independently shippable. Later phases depend on earlier ones
unless noted otherwise.
For the problem analysis, see [Entity Problems](entity-problems.md). For the
target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
For verified accuracy of these documents, see
[Appendix: Critical Analysis](appendix-critical-analysis.md).
## Planning assumptions
- The bridge period is expected to span 2-3 release cycles.
- Bridge work is treated as transitional debt with explicit owners and sunset
checkpoints, not as a permanent architecture layer.
- Phase 5 is entered only by explicit go/no-go review against the criteria in
this document.
## Phase 0: Foundation
Zero behavioral risk. Prepares the codebase for extraction without changing
runtime semantics. All items are independently shippable.
### 0a. Centralize version counter
`graph._version++` appears in 19 locations across 7 files. The counter is only
read once — for debug display in `LGraphCanvas.renderInfo()` (line 5389). It
is not used for dirty-checking, caching, or reactivity.
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
increments.
```
incrementVersion(): void {
this._version++
}
```
| File | Sites |
| ---------------------- | ------------------------------------------------------- |
| `LGraph.ts` | 5 (lines 956, 989, 1042, 1109, 2643) |
| `LGraphNode.ts` | 8 (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567) |
| `LGraphCanvas.ts` | 2 (lines 3084, 7880) |
| `BaseWidget.ts` | 1 (line 439) |
| `SubgraphInput.ts` | 1 (line 137) |
| `SubgraphInputNode.ts` | 1 (line 190) |
| `SubgraphOutput.ts` | 1 (line 102) |
**Why first:** Creates the seam where a VersionSystem can later intercept,
batch, or replace the mechanism. Mechanical find-and-replace with zero
behavioral change.
**Risk:** None. Existing null guards at call sites are preserved.
### 0b. Add missing ID type aliases
`NodeId`, `LinkId`, and `RerouteId` exist as type aliases. Two are missing:
| Type | Definition | Location |
| ----------- | ---------- | ---------------------------------------------------------------- |
| `GroupId` | `number` | `LGraphGroup.ts` (currently implicit on `id: number` at line 39) |
| `SlotIndex` | `number` | `interfaces.ts` (slot positions are untyped `number` everywhere) |
**Change:** Add the type aliases, update property declarations, re-export from
barrel (`litegraph.ts`).
**Why:** Foundation for branded IDs. Type aliases are erased at compile time —
zero runtime impact.
**Risk:** None. Type-only change.
### 0c. Fix architecture doc errors
Five factual errors verified during code review (see
[Appendix](appendix-critical-analysis.md#vii-summary-of-findings)):
- `entity-problems.md`: `toJSON()` should be `toString()`, `execute()` should
be `doExecute()`, method count ~539 should be ~848, `configure()` is ~240
lines not ~180
- `proto-ecs-stores.md`: `resolveDeepest()` does not exist on
PromotedWidgetViewManager; actual methods are `reconcile()` / `getOrCreate()`
---
## Phase 1: Types and World Shell
Introduces the ECS type vocabulary and an empty World. No migration of existing
code — new types coexist with old ones.
### 1a. Branded entity ID types
Define branded types in a new `src/ecs/entityId.ts`:
```
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
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
system boundaries (deserialization, legacy bridge).
**Does NOT change existing code.** The branded types are new exports consumed
only by new ECS code.
**Risk:** Low. New files, no modifications to existing code.
**Consideration:** `NodeId = number | string` is the current type. The branded
`NodeEntityId` narrows to `number`. The `string` branch exists solely for
subgraph-related nodes (GroupNode hack). The migration must decide whether to:
- Keep `NodeEntityId = number` and handle the string case at the bridge layer
- Or define `NodeEntityId = number | string` with branding (less safe)
Recommend the former: the bridge layer coerces string IDs to a numeric
mapping, and only branded numeric IDs enter the World.
### 1b. Component interfaces
Define component interfaces in `src/ecs/components/`:
```
src/ecs/
entityId.ts # Branded ID types
components/
position.ts # Position (shared by Node, Reroute, Group)
nodeType.ts # NodeType
nodeVisual.ts # NodeVisual
connectivity.ts # Connectivity
execution.ts # Execution
properties.ts # Properties
widgetContainer.ts # WidgetContainer
linkEndpoints.ts # LinkEndpoints
...
world.ts # World type and factory
```
Components are TypeScript interfaces only — no runtime code. They mirror
the decomposition in ADR 0008 Section "Component Decomposition."
**Risk:** None. Interface-only files.
### 1c. World type
Define the World as a typed container:
```ts
interface World {
nodes: Map<NodeEntityId, NodeComponents>
links: Map<LinkEntityId, LinkComponents>
widgets: Map<WidgetEntityId, WidgetComponents>
slots: Map<SlotEntityId, SlotComponents>
reroutes: Map<RerouteEntityId, RerouteComponents>
groups: Map<GroupEntityId, GroupComponents>
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
getComponent<C>(id: EntityId, component: ComponentKey<C>): C | undefined
setComponent<C>(id: EntityId, component: ComponentKey<C>, data: C): void
}
```
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.
World scope is per workflow instance. Linked subgraph definitions can be reused
across instances, but mutable runtime state (widget values, execution state,
selection/transient view state) remains instance-scoped through `graphId`.
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
persistence. The World exists but nothing populates it yet.
**Risk:** Low. New code, no integration points.
---
## Phase 2: Bridge Layer
Connects the legacy class instances to the World. Both old and new code can
read entity state; writes still go through legacy classes.
### 2a. Read-only bridge for Position
The LayoutStore (`src/renderer/core/layout/store/layoutStore.ts`) already
extracts position data for nodes, links, and reroutes into Y.js CRDTs. The
bridge reads from LayoutStore and populates the World's `Position` component.
**Approach:** A `PositionBridge` that observes LayoutStore changes and mirrors
them into the World. New code reads `world.getComponent(nodeId, Position)`;
legacy code continues to read `node.pos` / LayoutStore directly.
**Open question:** Should the World wrap the Y.js maps or maintain its own
plain-data copy? Options:
| Approach | Pros | Cons |
| ---------------------- | ------------------------------------- | ----------------------------------------------- |
| World wraps Y.js | Single source of truth; no sync lag | World API becomes CRDT-aware; harder to test |
| World copies from Y.js | Clean World API; easy to test | Two copies of position data; sync overhead |
| World replaces Y.js | Pure ECS; no CRDT dependency in World | Breaks collaboration (ADR 0003); massive change |
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
copy is cheap (position is small data). Revisit if sync overhead becomes
measurable.
**Risk:** Medium. Introduces a sync point between two state systems. Must
ensure the bridge doesn't create subtle ordering bugs (e.g., World reads stale
position during render).
### 2b. Read-only bridge for WidgetValue
WidgetValueStore (`src/stores/widgetValueStore.ts`) already extracts widget
state into plain `WidgetState` objects keyed by `graphId:nodeId:name`. This is
the closest proto-ECS store.
**Approach:** A `WidgetBridge` that maps `WidgetValueStore` entries into
`WidgetValue` components in the World, keyed by `WidgetEntityId`. Requires
assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
**Dependency:** Requires 1a (branded IDs) for `WidgetEntityId`.
**Risk:** Low-Medium. WidgetValueStore is well-structured. Main complexity is
the ID mapping — widgets currently lack independent IDs, so the bridge must
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
### 2c. Read-only bridge for Node metadata
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
reading from `LGraphNode` instances. These are simple property copies.
**Approach:** When a node is added to the graph (`LGraph.add()`), the bridge
creates the corresponding entity in the World and populates its components.
When a node is removed, the bridge deletes the entity.
The `incrementVersion()` method from Phase 0a becomes the hook point — when
version increments, the bridge can re-sync changed components. (This is why
centralizing version first matters.)
**Risk:** Medium. Must handle the full node lifecycle (add, configure, remove)
without breaking existing behavior. The bridge is read-only (World mirrors
classes, not the reverse), which limits blast radius.
### Bridge sunset criteria (applies to every Phase 2 bridge)
A bridge can move from "transitional" to "removal candidate" only when:
- All production reads for that concern flow through World component queries.
- All production writes for that concern flow through system APIs.
- Serialization parity tests show no diff between legacy and World paths.
- Extension compatibility tests pass without bridge-only fallback paths.
These criteria prevent the bridge from becoming permanent by default.
### Bridge duration and maintenance controls
To contain dual-path maintenance cost during Phases 2-4:
- Every bridge concern has a named owner and target sunset release.
- Every PR touching bridge-covered data paths must include parity tests for both
legacy and World-driven execution.
- Bridge fallback usage is instrumented in integration/e2e and reviewed every
milestone; upward trends block new bridge expansion.
- Any bridge that misses its target sunset release requires an explicit risk
review and revised removal plan.
---
## Phase 3: Systems
Introduce system functions that operate on World data. Systems coexist with
legacy methods — they don't replace them yet.
### 3a. SerializationSystem (read-only)
A function `serializeFromWorld(world: World): SerializedGraph` that produces
workflow JSON by querying World components. Run alongside the existing
`LGraph.serialize()` in tests to verify equivalence.
**Why first:** Serialization is read-only and has a clear correctness check
(output must match existing serialization). It exercises every component type
and proves the World contains sufficient data.
**Risk:** Low. Runs in parallel with existing code; does not replace it.
### 3b. VersionSystem
Replace the `incrementVersion()` method with a system that owns all change
tracking. The system observes component mutations on the World and
auto-increments the version counter.
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
doesn't see changes).
**Risk:** Medium. Must not miss any change that the scattered `_version++`
currently catches. The 19-site inventory from Phase 0a serves as the test
matrix.
### 3c. ConnectivitySystem (queries only)
A system that can answer connectivity queries by reading `Connectivity`,
`SlotConnection`, and `LinkEndpoints` components from the World:
- "What nodes are connected to this node's inputs?"
- "What links pass through this reroute?"
- "What is the execution order?"
Does not perform mutations yet — just queries. Validates that the World's
connectivity data is complete and consistent with the class-based graph.
**Risk:** Low. Read-only system with equivalence tests.
---
## Phase 4: Write Path Migration
Systems begin owning mutations. Legacy class methods delegate to systems.
This is the highest-risk phase.
### 4a. Position writes through World
New code writes position via `world.setComponent(nodeId, Position, ...)`.
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
**This inverts the data flow:** Phase 2 had legacy -> World (read bridge).
Phase 4 has World -> legacy (write bridge). Both paths must work during the
transition.
**Risk:** High. Two-way sync between World and legacy state. Must handle
re-entrant updates (World write triggers bridge, which writes to legacy,
which must NOT trigger another World write).
### 4b. ConnectivitySystem mutations
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
system.
**Extension API concern:** The current system fires callbacks at each step:
- `onConnectInput()` / `onConnectOutput()` — can reject connections
- `onConnectionsChange()` — notifies after connection change
- `onRemoved()` — notifies after node removal
These callbacks are the **extension API contract**. The ConnectivitySystem
must fire them at the same points in the operation, or extensions break.
**Recommended approach:** The system emits lifecycle events that the bridge
layer translates into legacy callbacks. This preserves the contract without
the system knowing about the callback API.
**Phase 4 callback contract (locked):**
- `onConnectOutput()` and `onConnectInput()` run before any World mutation.
- If either callback rejects, abort with no component writes, no version bump,
and no lifecycle events.
- `onConnectionsChange()` fires synchronously after commit, preserving current
source-then-target ordering.
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
compatibility API during Phase 4.
**Risk:** High. Extensions depend on callback ordering and timing. Must be
validated against real-world extensions.
### 4c. Widget write path
Widget value changes go through the World instead of directly through
WidgetValueStore. The World's `WidgetValue` component becomes the single
source of truth; WidgetValueStore becomes a read-through cache or is removed.
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
change is routing writes through the World instead of the store.
### 4d. Layout write path and render decoupling
Remove layout side effects from render incrementally by node family.
**Approach:**
1. Inventory `drawNode()` call paths that still trigger `arrange()`.
2. For one node family at a time, run `LayoutSystem` in update phase and mark
entities as layout-clean before render.
3. Keep a temporary compatibility fallback that runs legacy layout only for
non-migrated families.
4. Delete fallback once parity tests and frame-time budgets are met.
**Risk:** High. Mixed-mode operation must avoid stale layout reads. Requires
family-level rollout and targeted regression tests.
### Render hot-path performance gate
Before enabling ECS render reads as default for any migrated family:
- Benchmark representative workflows (200-node and 500-node minimum).
- Compare legacy vs ECS p95 frame time and mean draw cost.
- Block rollout on statistically significant regression beyond agreed budget
(default budget: 5% p95 frame-time regression ceiling).
- Capture profiler traces proving the dominant cost is not repeated
`world.getComponent()` lookups.
### Phase 3 -> 4 gate (required)
Phase 4 starts only when all of the following are true:
- A transaction wrapper API exists on the World and is used by connectivity and
widget write paths in integration tests.
- Undo batching parity is proven: one logical user action yields one undo
checkpoint in both legacy and ECS paths.
- Callback timing and rejection semantics from Phase 4b are covered by
integration tests.
- A representative extension suite passes, including `rgthree-comfy`.
- Write bridge re-entrancy tests prove there is no World <-> legacy feedback
loop.
- Layout migration for any enabled node family passes read-only render checks
(no `arrange()` writes during draw).
- Render hot-path benchmark gate passes for every family moving to ECS-first
reads.
---
## Phase 5: Legacy Removal
Remove bridge layers and deprecated class properties. This phase happens
per-component, not all at once.
### 5a. Remove Position bridge
Once all position reads and writes go through the World, remove the bridge
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
### 5b. Remove widget class hierarchy
Once all widget behavior is in systems, the 23+ widget subclasses can be
replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
`ComboWidget`, etc. become configuration data rather than class instances.
### 5c. Dissolve god objects
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is
holding the entity ID and delegating to the World. Eventually, they can be
removed entirely, replaced by entity ID + component queries.
**Risk:** Very High. This is the irreversible step. Must be done only after
thorough validation that all consumers (including extensions) work with the
ECS path.
### Phase 4 -> 5 exit criteria (required)
Legacy removal starts only when all of the following are true:
- The component being removed has no remaining direct reads or writes outside
World/system APIs.
- Serialization equivalence tests pass continuously for one release cycle.
- A representative extension compatibility matrix is green, including
`rgthree-comfy`.
- Bridge instrumentation shows zero fallback-path usage in integration and e2e
suites.
- A rollback plan exists for each removal PR until the release is cut.
- ECS write path has run as default behind a kill switch for at least one full
release cycle.
- No unresolved P0/P1 extension regressions are attributed to ECS migration in
that cycle.
### Phase 5 trigger packet (required before first legacy-removal PR)
The team prepares a single go/no-go packet containing:
- Phase 4 -> 5 criteria checklist with links to evidence.
- Extension compatibility matrix results.
- Bridge fallback usage report (must be zero for the target concern).
- Performance gate report for ECS render/read paths.
- Rollback owner, rollback steps, and release coordination sign-off.
---
## Open Questions
### CRDT / ECS coexistence
The LayoutStore uses Y.js CRDTs for collaboration-ready position data
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The ECS World
uses plain `Map`s. These must coexist.
**Options explored in Phase 2a.** The recommended path (World copies from Y.js)
defers the hard question. Eventually, the World may need to be CRDT-native —
but this requires a separate ADR.
**Questions to resolve:**
- Should non-position components also be CRDT-backed for collaboration?
- Does the World need an operation log for undo/redo, or can that remain
external (Y.js undo manager)?
- How does conflict resolution work when two users modify the same component?
### Extension API preservation
The current system exposes lifecycle callbacks on entity classes:
| Callback | Class | Purpose |
| --------------------- | ------------ | ----------------------------------- |
| `onConnectInput` | `LGraphNode` | Validate/reject incoming connection |
| `onConnectOutput` | `LGraphNode` | Validate/reject outgoing connection |
| `onConnectionsChange` | `LGraphNode` | React to topology change |
| `onRemoved` | `LGraphNode` | Cleanup on deletion |
| `onAdded` | `LGraphNode` | Setup on graph insertion |
| `onConfigure` | `LGraphNode` | Post-deserialization hook |
| `onWidgetChanged` | `LGraphNode` | React to widget value change |
Extensions register these callbacks to customize node behavior. The ECS
migration must preserve this contract or provide a documented migration path
for extension authors.
**Recommended approach:** Define an `EntityLifecycleEvent` system that emits
typed events at the same points where callbacks currently fire. The bridge
layer translates events into legacy callbacks. Extensions can gradually adopt
event listeners instead of callbacks.
**Phase 4 decisions:**
- Rejection callbacks act as pre-commit guards (reject before World mutation).
- Callback dispatch remains synchronous during the bridge period.
- Callback order remains: output validation -> input validation -> commit ->
output change notification -> input change notification.
### Extension Migration Examples (old -> new)
The bridge keeps legacy callbacks working, but extension authors can migrate
incrementally to ECS-native patterns.
#### 1) Widget lookup by name
```ts
// Legacy pattern
const seedWidget = node.widgets?.find((w) => w.name === 'seed')
seedWidget?.setValue(42)
// ECS pattern (using the bridge/world widget lookup index)
const seedWidgetId = world.widgetIndex.getByNodeAndName(nodeId, 'seed')
if (seedWidgetId) {
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
if (widgetValue) {
world.setComponent(seedWidgetId, WidgetValue, {
...widgetValue,
value: 42
})
}
}
```
#### 2) `onConnectionsChange` callback
```ts
// Legacy pattern
nodeType.prototype.onConnectionsChange = function (
side,
slot,
connected,
linkInfo
) {
updateExtensionState(this.id, side, slot, connected, linkInfo)
}
// ECS pattern
lifecycleEvents.on('connection.changed', (event) => {
if (event.nodeId !== nodeId) return
updateExtensionState(
event.nodeId,
event.side,
event.slotIndex,
event.connected,
event.linkInfo
)
})
```
#### 3) `onRemoved` callback
```ts
// Legacy pattern
nodeType.prototype.onRemoved = function () {
cleanupExtensionResources(this.id)
}
// ECS pattern
lifecycleEvents.on('entity.removed', (event) => {
if (event.kind !== 'node' || event.entityId !== nodeId) return
cleanupExtensionResources(event.entityId)
})
```
#### 4) `graph._version++`
```ts
// Legacy pattern (do not add new usages)
graph._version++
// Bridge-safe transitional pattern (Phase 0a)
graph.incrementVersion()
// ECS-native pattern: mutate through command/system API.
// VersionSystem bumps once at transaction commit.
executor.run({
type: 'SetWidgetValue',
execute(world) {
const value = world.getComponent(widgetId, WidgetValue)
if (!value) return
world.setComponent(widgetId, WidgetValue, { ...value, value: 42 })
}
})
```
**Question to resolve after compatibility parity:**
- Should ECS-native lifecycle events stay synchronous after bridge removal, or
can they become asynchronous once legacy callback compatibility is dropped?
### Atomicity and transactions
The ECS lifecycle scenarios claim operations are "atomic." This requires
the World to support transactions — the ability to batch multiple component
writes and commit or rollback as a unit.
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
checkpoints but not true transactions. The graph can be in an inconsistent
state between these calls.
**Phase 4 baseline semantics:**
- Mutating systems run inside `world.transaction(label, fn)`.
- The bridge maps one World transaction to one `beforeChange()` /
`afterChange()` bracket.
- Operations with multiple component writes (for example `connect()` touching
slots, links, and node metadata) still commit as one transaction and therefore
one undo entry.
- Failed transactions do not publish partial writes, lifecycle events, or
version increments.
**Questions to resolve:**
- How should `world.transaction()` interact with Y.js transactions when a
component is CRDT-backed?
- Is eventual consistency acceptable for derived data updates between
transactions, or must post-transaction state always be immediately
consistent?
### Keying strategy unification
The 6 proto-ECS stores use 6 different keying strategies:
| Store | Key Format |
| ----------------------- | --------------------------------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` |
| PromotionStore | `"${sourceNodeId}:${widgetName}"` |
| DomWidgetStore | Widget UUID |
| LayoutStore | Raw nodeId/linkId/rerouteId |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
The World unifies these under branded entity IDs. But stores that use
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
reality — a widget is identified by its relationship to a node. Synthetic
`WidgetEntityId`s replace this with an opaque number, requiring a reverse
lookup index.
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
for the transition period.
---
## Dependency Graph
```
Phase 0a (incrementVersion) ──┐
Phase 0b (ID type aliases) ───┤
Phase 0c (doc fixes) ─────────┤── no dependencies between these
Phase 1a (branded IDs) ────────┤
Phase 1b (component interfaces) ┤── 1b depends on 1a
Phase 1c (World type) ─────────┘── 1c depends on 1a, 1b
Phase 2a (Position bridge) ────┐── depends on 1c
Phase 2b (Widget bridge) ──────┤── depends on 1a, 1c
Phase 2c (Node metadata bridge) ┘── depends on 0a, 1c
Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
Phase 3c (ConnectivitySystem) ──── depends on 2c
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c
Phase 4a (Position writes) ────── depends on 2a, 3b
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
Phase 4c (Widget writes) ─────── depends on 2b
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
Phase 4->5 exit criteria ──────── depends on all of Phase 4
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
```
## Risk Summary
| Phase | Risk | Reversibility | Extension Impact |
| ------------------ | ---------- | ----------------------- | --------------------------- |
| 0 (Foundation) | None | Fully reversible | None |
| 1 (Types/World) | Low | New files, deletable | None |
| 2 (Bridge) | Low-Medium | Bridge is additive | None |
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
The plan is designed so that Phases 0-3 can ship without any risk to
extensions or existing behavior. Phase 4 is where the real migration begins,
and Phase 5 is the point of no return.