diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml
index 0e6207340..092e27785 100644
--- a/.github/workflows/pre-merge.yml
+++ b/.github/workflows/pre-merge.yml
@@ -43,7 +43,7 @@ jobs:
# - name: Install Rust toolchain
# uses: dtolnay/rust-toolchain@stable
# with:
- # toolchain: nightly
+ # toolchain: nightly-2026-04-27
# components: rustfmt, clippy, rust-src
#
# - name: Install RISC-V target
@@ -87,10 +87,11 @@ jobs:
repository: light-player/lp-regalloc2
path: lp-regalloc2
+ # Toolchain is pinned in lp2025/rust-toolchain.toml; keep this in sync.
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
- toolchain: nightly
+ toolchain: nightly-2026-04-27
components: rustfmt, clippy, rust-src
- name: Install RISC-V target
diff --git a/.idea/lp2025.iml b/.idea/lp2025.iml
index 5de7212fb..27f10b7b8 100644
--- a/.idea/lp2025.iml
+++ b/.idea/lp2025.iml
@@ -96,10 +96,17 @@
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 46922eb8b..8779970b5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -137,6 +137,31 @@ just build-rv32 # cargo build --target riscv32imac-... -p ...
just build # parallel: host + rv32
```
+### ESP32 linked-build pitfall
+
+For `fw-esp32`, **linked firmware builds, size measurements, and bloat
+analysis must run from `lp-fw/fw-esp32/`** (or through a just recipe that
+`cd`s there first, such as `just build-fw-esp32`). The crate-local
+`.cargo/config.toml` and linker setup are part of the build.
+
+This is fine from the workspace root because it does not final-link:
+
+```bash
+cargo check -p fw-esp32 --target riscv32imac-unknown-none-elf --profile release-esp32 --features esp32c6,server
+```
+
+For a real linked ELF or size numbers, do this instead:
+
+```bash
+cd lp-fw/fw-esp32
+cargo build --target riscv32imac-unknown-none-elf --profile release-esp32 --features esp32c6,server
+rust-size ../../target/riscv32imac-unknown-none-elf/release-esp32/fw-esp32
+```
+
+Running `cargo build -p fw-esp32 ...` from the workspace root can fail at final
+link with `memory region not defined: ROTEXT`, because it bypasses the
+crate-local firmware build context.
+
For targeted host validation of specific crates:
```bash
diff --git a/Cargo.lock b/Cargo.lock
index 07c8ffe02..0fde21ea6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1070,7 +1070,7 @@ dependencies = [
"cranelift-isle 0.127.0",
"hashbrown 0.15.5",
"log",
- "lp-collection",
+ "lp-collection 1.0.0 (git+https://github.com/light-player/lp-common?branch=main)",
"regalloc2 0.13.6",
"rustc-hash 2.1.1",
"serde",
@@ -2851,6 +2851,7 @@ dependencies = [
"lp-riscv-emu-guest",
"lpa-client",
"lpa-server",
+ "lpc-hardware",
"lpc-model",
"lpc-shared",
"lpc-view",
@@ -2886,9 +2887,11 @@ dependencies = [
"libm",
"littlefs-rust",
"log",
+ "lp-collection 1.0.0",
"lp-perf",
"lp-shader",
"lpa-server",
+ "lpc-hardware",
"lpc-model",
"lpc-shared",
"lpc-wire",
@@ -3902,6 +3905,7 @@ dependencies = [
"futures-util",
"fw-checks",
"log",
+ "lp-collection 1.0.0",
"lp-perf",
"lp-riscv-elf",
"lp-riscv-emu",
@@ -3910,6 +3914,7 @@ dependencies = [
"lpa-client",
"lpa-server",
"lpc-engine",
+ "lpc-hardware",
"lpc-model",
"lpc-shared",
"lpc-view",
@@ -3933,6 +3938,15 @@ dependencies = [
"unicode-width 0.2.2",
]
+[[package]]
+name = "lp-collection"
+version = "1.0.0"
+dependencies = [
+ "rustc-hash 2.1.1",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "lp-collection"
version = "1.0.0"
@@ -4023,6 +4037,7 @@ dependencies = [
name = "lp-shader"
version = "40.0.0"
dependencies = [
+ "lp-collection 1.0.0",
"lpir",
"lps-frontend",
"lps-glsl",
@@ -4063,7 +4078,9 @@ dependencies = [
"log",
"lp-perf",
"lpc-engine",
+ "lpc-hardware",
"lpc-model",
+ "lpc-registry",
"lpc-shared",
"lpc-view",
"lpc-wire",
@@ -4078,9 +4095,12 @@ dependencies = [
"hashbrown 0.15.5",
"libm",
"log",
+ "lp-collection 1.0.0",
"lp-perf",
"lp-shader",
+ "lpc-hardware",
"lpc-model",
+ "lpc-registry",
"lpc-shared",
"lpc-view",
"lpc-wire",
@@ -4099,12 +4119,23 @@ dependencies = [
"unwinding",
]
+[[package]]
+name = "lpc-hardware"
+version = "40.0.0"
+dependencies = [
+ "lp-collection 1.0.0",
+ "lpc-model",
+ "serde",
+ "toml",
+]
+
[[package]]
name = "lpc-model"
version = "40.0.0"
dependencies = [
"base64 0.22.1",
"hashbrown 0.15.5",
+ "lp-collection 1.0.0",
"lpc-slot-codegen",
"lpc-slot-macros",
"lpfs",
@@ -4115,18 +4146,31 @@ dependencies = [
"toml",
]
+[[package]]
+name = "lpc-registry"
+version = "40.0.0"
+dependencies = [
+ "lp-collection 1.0.0",
+ "lpc-model",
+ "lpc-wire",
+ "lpfs",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "lpc-shared"
version = "40.0.0"
dependencies = [
"libm",
"log",
+ "lp-collection 1.0.0",
+ "lpc-hardware",
"lpc-model",
"lpc-wire",
"lpfs",
"lps-shared",
"serde",
- "toml",
]
[[package]]
@@ -4146,17 +4190,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "lpc-slot-mockup"
-version = "40.0.0"
-dependencies = [
- "lpc-model",
- "lpc-slot-codegen",
- "lpc-view",
- "lpc-wire",
- "toml",
-]
-
[[package]]
name = "lpc-view"
version = "40.0.0"
@@ -4171,6 +4204,7 @@ name = "lpc-wire"
version = "40.0.0"
dependencies = [
"base64 0.22.1",
+ "lp-collection 1.0.0",
"lpc-model",
"schemars",
"ser-write-json",
@@ -4226,7 +4260,7 @@ name = "lpir"
version = "40.0.0"
dependencies = [
"libm",
- "lp-collection",
+ "lp-collection 1.0.0",
"lps-q32",
]
@@ -4276,6 +4310,7 @@ dependencies = [
"anyhow",
"glob",
"glsl",
+ "lp-collection 1.0.0",
"lp-riscv-elf",
"lp-riscv-emu",
"lp-riscv-inst",
@@ -4321,6 +4356,7 @@ name = "lps-frontend"
version = "40.0.0"
dependencies = [
"libm",
+ "lp-collection 1.0.0",
"lpir",
"lps-builtin-ids",
"lps-shared",
@@ -4332,7 +4368,7 @@ dependencies = [
name = "lps-glsl"
version = "40.0.0"
dependencies = [
- "lp-collection",
+ "lp-collection 1.0.0",
"lpir",
"lps-shared",
]
@@ -4348,6 +4384,7 @@ dependencies = [
name = "lps-shared"
version = "40.0.0"
dependencies = [
+ "lp-collection 1.0.0",
"lps-q32",
"schemars",
"serde",
@@ -4358,6 +4395,7 @@ dependencies = [
name = "lpvm"
version = "40.0.0"
dependencies = [
+ "lp-collection 1.0.0",
"lpir",
"lps-q32",
"lps-shared",
@@ -4374,6 +4412,7 @@ dependencies = [
"cranelift-native 0.127.0",
"cranelift-object",
"libm",
+ "lp-collection 1.0.0",
"lp-riscv-elf",
"lpir",
"lps-builtin-ids",
@@ -4407,6 +4446,7 @@ version = "40.0.0"
dependencies = [
"cranelift-codegen 0.127.0",
"log",
+ "lp-collection 1.0.0",
"lp-perf",
"lp-riscv-elf",
"lp-riscv-emu",
@@ -5671,7 +5711,7 @@ dependencies = [
"bumpalo",
"hashbrown 0.15.5",
"log",
- "lp-collection",
+ "lp-collection 1.0.0 (git+https://github.com/light-player/lp-common?branch=main)",
"rustc-hash 2.1.1",
"smallvec",
]
diff --git a/Cargo.toml b/Cargo.toml
index 007a3c1d5..0320754ed 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,10 +2,12 @@
members = [
# lp-base workspace members
"lp-base/lp-perf",
+ "lp-base/lp-collection",
"lp-base/lpfs",
# lp-app workspace members
"lp-core/lpc-engine",
"lp-core/lpc-view",
+ "lp-core/lpc-hardware",
"lp-core/lpc-shared",
"lp-app/lpa-server",
"lp-app/lpa-client",
@@ -18,7 +20,7 @@ members = [
"lp-core/lpc-model",
"lp-core/lpc-slot-codegen",
"lp-core/lpc-slot-macros",
- "lp-core/lpc-slot-mockup",
+ "lp-core/lpc-registry",
"lp-core/lpc-wire",
# lps workspace members
"lp-shader/lps-builtin-ids",
@@ -62,6 +64,7 @@ default-members = [
# lp-app workspace members
"lp-core/lpc-engine",
"lp-core/lpc-view",
+ "lp-core/lpc-hardware",
"lp-core/lpc-shared",
"lp-app/lpa-server",
"lp-app/lpa-client",
@@ -70,6 +73,7 @@ default-members = [
"lp-fw/fw-tests",
"lp-cli",
"lp-core/lpc-model",
+ "lp-core/lpc-registry",
"lp-core/lpc-wire",
# lps workspace members (excluding lps-builtins-emu-app)
"lp-shader/lps-builtin-ids",
@@ -143,7 +147,7 @@ cranelift-interpreter = { git = "https://github.com/light-player/lp-cranelift",
# regalloc2 - fork (ChunkedVec for OOM mitigation, feature-gating ION allocator)
regalloc2 = { git = "https://github.com/light-player/lp-regalloc2", branch = "lightplayer", default-features = false, features = ["std"] }
-lp-collection = { git = "https://github.com/light-player/lp-common", branch = "main", default-features = false }
+lp-collection = { path = "lp-base/lp-collection", default-features = false }
# Common dependencies
diff --git a/docs/adr/2026-06-10-project-edit-vocabulary.md b/docs/adr/2026-06-10-project-edit-vocabulary.md
new file mode 100644
index 000000000..034f75ee8
--- /dev/null
+++ b/docs/adr/2026-06-10-project-edit-vocabulary.md
@@ -0,0 +1,73 @@
+# ADR 2026-06-10: Canonical Project Overlay Vocabulary
+
+## Status
+
+Accepted
+
+## Context
+
+The registry branch needs a future UI and engine cutover path that edits
+authored project artifacts, not only immediate engine memory. At the time this
+ADR was written, the temporary `WireSlotMutation*` API was intentionally
+narrow: it set value leaves on runtime slot roots like `node..def`, applied
+immediately, and had no overlay or commit concept. That temporary API was
+removed during the M4 wire/server cutover.
+
+The first registry wire POC proved useful behavior, but it duplicated the same
+ideas across layers with command-shaped types such as `ArtifactEdit`,
+`ArtifactEditOp`, and `ProjectEditBatch`, while the registry still had its own
+`ArtifactEdits` / `AssetEdit` overlay model.
+
+We want the model layer to own the durable vocabulary, the wire layer to wrap it
+in message envelopes, and the registry to apply it.
+
+## Decision
+
+`lpc-model::edit` owns the canonical project overlay model:
+
+- `ProjectOverlay`: the full current pending edit set for a project.
+- `ArtifactOverlay`: either a structured `SlotOverlay` or one
+ `ArtifactBodyEdit`.
+- `SlotOverlay`: canonical map from `SlotPath` to `SlotEditOp`.
+- `SlotEdit`: path-bearing slot edit.
+- `SlotEditOp`: path-free operation, one of `EnsurePresent`,
+ `AssignValue(LpValue)`, or `Remove`.
+- `ArtifactBodyEdit`: byte-level artifact body `ReplaceBody(Vec)` or
+ `Delete`.
+- `OverlayMutation`: ordered edits to the overlay itself.
+
+The overlay represents current pending intent, not edit history. Multiple
+mutations targeting the same path are coalesced into one overlay entry.
+Ancestor/descendant conflicts are normalized in model code. Artifact body edits
+and slot overlays are mutually exclusive for a given artifact.
+
+`lpc-wire` defines thin wire envelopes around model types:
+
+- read overlay request/response returns a full `ProjectOverlay`;
+- mutate overlay request/response applies an ordered `OverlayMutationBatch`;
+- commit overlay request/response returns a portable `ProjectCommitSummary`.
+
+`lpc-registry` stores and applies `ProjectOverlay`. It owns path validation,
+slot application, effective inventory derivation, filesystem writes/deletes, and
+commit. It does not define a second overlay model and does not depend on
+`lpc-wire` in library code.
+
+The legacy `WireSlotMutation*` path was allowed during the POC, but M4 removed
+it in favor of overlay project commands.
+
+## Consequences
+
+The future UI can read and mirror one canonical pending overlay instead of
+reconstructing pending state from command history.
+
+Wire schemas stay message-shaped and avoid copying model concepts.
+
+Registry tests can exercise wire-shaped behavior without coupling registry
+library code to the protocol crate.
+
+Overlay application order is deterministic and derived from the canonical
+`SlotOverlay` map; user mutation order affects coalescing, not the persisted
+overlay representation.
+
+Revisioning, idempotency, conflict semantics, and backward compatibility remain
+future wire/API work.
diff --git a/docs/adr/2026-06-11-asset-source-model.md b/docs/adr/2026-06-11-asset-source-model.md
new file mode 100644
index 000000000..a49c74a98
--- /dev/null
+++ b/docs/adr/2026-06-11-asset-source-model.md
@@ -0,0 +1,65 @@
+# ADR 2026-06-11: Asset Location Model
+
+## Status
+
+Accepted
+
+## Context
+
+`ProjectRegistry` derives a project view by walking the loaded project graph.
+That graph includes node definitions and assets. Early registry code treated
+every asset as a non-definition file identified by `ArtifactLocation`, which was
+enough for shader source files but too narrow for the model we need.
+
+Source files are assets, but not all assets are source files. Future image
+assets need the same reference/discovery/materialization path. Source can also
+be inline today, and other asset kinds may gain inline bodies later.
+
+## Decision
+
+Make assets a first-class `lpc-model::asset` concept.
+
+`ArtifactLocation` remains durable file identity. It answers "which file-like
+artifact is this?"
+
+`AssetLocation` is project asset identity. It answers "where does this referenced
+project asset come from?" Initial variants are:
+
+- artifact-backed assets, identified by `ArtifactLocation`;
+- inline assets, identified by owner `NodeDefLocation` plus `SlotPath`;
+
+URL-backed assets should be modeled as future `ArtifactLocation` variants, not
+as a separate asset location kind.
+
+`AssetContentType` is the specialization point for how callers should interpret or
+materialize bytes/text. Initial kinds include shader source, compute shader
+source, fixture SVG, image, text, and binary.
+
+`ProjectInventory.assets` is keyed by `AssetLocation`, and `AssetEntry` carries
+the `AssetContentType`, state, and revision. Inline assets are inventory entries, but
+they are not registered in `ArtifactStore`. Artifact-backed assets continue to
+use `ArtifactStore` for durable location tracking, filesystem reads, overlay
+body replacement, and filesystem change revisions.
+
+Source-file APIs may remain named `source` where they specifically deal with
+authored source text. They sit under the asset model: source materialization is
+a text-specific wrapper over effective asset bytes plus inline slot text.
+
+Normal registry operation does not scan or snapshot every file. Assets and
+definitions are discovered by walking static authored references in the current
+effective project graph.
+
+## Consequences
+
+The project view can represent file-backed shader sources, inline shader
+sources, fixture SVGs, and future image assets with one inventory shape.
+
+Engine/runtime consumers can key loaded assets by `AssetLocation` instead of
+assuming every runtime asset maps directly to a file.
+
+The model still assumes statically discoverable references. Dynamic asset or
+node-definition references will need a later design.
+
+`AssetContentType::Image` exists as vocabulary before image loading is
+implemented, so image support can reuse the same identity and inventory path
+when it arrives.
diff --git a/docs/adr/2026-06-11-project-registry-effective-inventory.md b/docs/adr/2026-06-11-project-registry-effective-inventory.md
new file mode 100644
index 000000000..9eb99e539
--- /dev/null
+++ b/docs/adr/2026-06-11-project-registry-effective-inventory.md
@@ -0,0 +1,94 @@
+# ADR 2026-06-11: Project Registry Effective Inventory
+
+## Status
+
+Accepted
+
+## Context
+
+The incremental artifact reload branch started with a node-definition registry
+that mixed artifact tracking, overlay application, effective reads, sync
+vocabulary, commit promotion, and node-specific inventory logic. That shape was
+hard to explain and was too node-only for the UI and engine cutover we need.
+
+The project editor needs three distinct views:
+
+- files: durable artifacts on disk;
+- project: referenced node definitions and referenced assets, including error
+ entries;
+- runtime: instantiated engine nodes and loaded runtime assets.
+
+The registry is responsible for the project view. A project artifact can produce
+either node definitions or assets, and both are discovered by walking the loaded
+project graph.
+
+## Decision
+
+Replace the old node-only registry concept with `lpc-registry::ProjectRegistry`.
+The registry owns:
+
+- an `ArtifactStore` for known durable artifact locations and read freshness;
+- `WithRevision` for pending edit intent;
+- one effective `ProjectInventory`;
+- the root `NodeDefLocation`.
+
+The effective inventory is the registry truth:
+
+```text
+artifacts + overlay -> ProjectInventory { defs, assets }
+```
+
+`ProjectInventory` lives in `lpc-model` because clients need to inspect current
+project state. It contains:
+
+- `NodeDefEntry` keyed by `NodeDefLocation`;
+- `AssetEntry` keyed by `AssetLocation`, where file-backed sources wrap an
+ `ArtifactLocation` and inline sources are identified by owner `NodeDefLocation`
+ plus `SlotPath`.
+- loaded and error states for both definitions and assets.
+
+The registry does not maintain a semantic `base_defs` graph. On load, overlay
+apply, filesystem refresh, discard, and commit, the registry recomputes the
+effective inventory and compares old inventory to new inventory.
+
+Runtime-facing changes are represented as `ProjectChangeSet`, not as a diff.
+`ProjectChangeSet` is identifier-oriented and coarse:
+
+- node defs: added, removed, changed with `Body`, `KindChanged`,
+ `EnteredError`, or `LeftError`;
+- assets: added, removed, changed with `Body`, `EnteredError`, or `LeftError`.
+
+Callers use the change set to decide what to refresh, then fetch current entries
+from the registry. A snapshot-to-overlay helper may still exist for tests and
+bootstrap workflows, but it is an operation that derives edit intent between two
+file snapshots. It is not the runtime change vocabulary.
+
+`NodeDef::invocation_sites` and `NodeDef::referenced_assets` are the model
+APIs for graph walking. The registry does not keep node-kind lists for project
+topology. This pass assumes static authored references: no dynamic node-def refs
+or dynamic asset refs are discovered at runtime.
+
+Commit persists the already-effective overlay state to durable artifacts and
+clears the overlay. A successful commit should normally return no
+runtime-facing `ProjectChangeSet`; runtime consumers already reacted when the
+overlay became effective.
+
+## Consequences
+
+The registry API is easier to reason about: load artifacts, apply overlay
+mutations, refresh durable artifact changes, commit overlay, and observe
+project changes.
+
+Assets are first-class project inventory entries instead of being incidental
+source reads behind node definitions.
+
+Missing referenced defs/assets remain visible as project inventory errors, which
+lets the future UI render a complete project view instead of losing broken
+edges.
+
+Full recompute is the first implementation. Incremental dirty tracking can be
+added later behind the same API.
+
+The crate is now `lpc-registry`. Historical plans and reviews may still mention
+`lpc-node-registry`, `NodeDefRegistry`, `SyncOp`, or `NodeDefView`; live source
+should use the new vocabulary.
diff --git a/docs/adr/2026-06-12-asset-slot-runtime-refresh.md b/docs/adr/2026-06-12-asset-slot-runtime-refresh.md
new file mode 100644
index 000000000..5f7406e59
--- /dev/null
+++ b/docs/adr/2026-06-12-asset-slot-runtime-refresh.md
@@ -0,0 +1,67 @@
+# ADR 2026-06-12: AssetSlot Runtime Refresh
+
+## Status
+
+Accepted
+
+## Context
+
+The project registry can discover shader source files as effective assets, but
+the runtime previously loaded shader source as plain strings. After a
+filesystem refresh reported that `/shader.glsl` changed, the engine knew an
+asset changed but the running shader node had no retained asset identity or
+revision to decide whether its cached shader source should be invalidated.
+
+The older authored model also had source-specific slot types such as
+`SourcePathSlot`, `SourceFileSlot`, and `ShaderSource`. That made source files
+special even though the emerging project model treats shader source, fixture
+SVGs, future images, and inline bodies as assets.
+
+## Decision
+
+Use `AssetSlot` as the authored slot shape for file-or-inline assets.
+
+`AssetSlotValue` is authored storage:
+
+- `Artifact(ArtifactSpec)` references an artifact relative to the containing
+ definition;
+- `InlineText { extension, text }` stores UTF-8 text with an optional language
+ or extension hint;
+- `InlineBytes { extension, bytes }` stores raw bytes with an optional extension
+ hint.
+
+`AssetLocation` remains effective project identity after registry discovery.
+Artifact-backed slots resolve to `AssetLocation::Artifact`; inline asset slots
+resolve to `AssetLocation::Inline { owner, path }`.
+
+`AssetContentType` remains a coarse bridge for current specialized consumers. It
+is not the final MIME or requirements model.
+
+Runtime nodes do not get reattached for same-location asset body changes.
+`Engine::apply_project_changes` routes changed `AssetLocation`s through
+`ProjectRuntimeIndex` to affected runtime nodes. Nodes may implement
+`NodeRuntime::refresh_asset` to compare the effective asset revision they last
+consumed with the registry's current asset revision and invalidate only their
+own cached state.
+
+Shader and compute shader nodes retain their source asset location and revision.
+When that asset changes, they read the effective text through
+`ProjectRegistry::read_asset_text_if_changed`, replace cached source text, clear
+compile errors, and drop compiled shader state so the next render/produce path
+compiles from the new source.
+
+## Consequences
+
+Filesystem edits to shader source files update existing runtime shader nodes
+without full project reload or runtime node reattach.
+
+Source files are now assets in the model. Source-specific names remain
+appropriate only for actual source text handling, not for asset identity or
+authored asset references.
+
+The runtime refresh hook is generic enough for future image or binary asset
+consumers without adding image loading in this milestone.
+
+The implementation still assumes statically discoverable asset references.
+Generic slot requirement metadata, a generic reference walker, richer MIME or
+format handling, and image asset loading remain future work.
diff --git a/docs/adr/2026-06-12-effective-asset-materialization.md b/docs/adr/2026-06-12-effective-asset-materialization.md
new file mode 100644
index 000000000..cb98fe11a
--- /dev/null
+++ b/docs/adr/2026-06-12-effective-asset-materialization.md
@@ -0,0 +1,68 @@
+# ADR 2026-06-12: Effective Asset Materialization
+
+## Status
+
+Accepted
+
+## Context
+
+Assets are now first-class project inventory entries. Shader source files,
+compute shader source files, fixture SVG mappings, future images, and inline
+source text all use the `AssetLocation` and `AssetContentType` model vocabulary.
+
+Current engine loading still materializes shader source and fixture SVG files by
+reading paths directly from the filesystem. That duplicates registry knowledge
+about overlay replacement, overlay deletion, inline assets, committed artifact
+revisions, and error states.
+
+The engine cutover should not recreate asset precedence rules for every runtime
+node kind.
+
+## Decision
+
+`ProjectRegistry` owns effective asset materialization.
+
+The registry should provide engine-facing APIs that materialize current
+effective asset bytes and text from an `AssetLocation`, such as:
+
+```rust
+ProjectRegistry::materialize_asset(...)
+ProjectRegistry::materialize_asset_text(...)
+```
+
+Those APIs should honor the same effective project state as the inventory:
+
+- artifact-backed asset plus overlay replacement returns overlay bytes at the
+ overlay revision;
+- artifact-backed asset plus overlay delete returns a deleted error;
+- artifact-backed asset without overlay reads bytes transiently through the
+ registry `ArtifactStore` and reports the artifact revision;
+- inline source assets read from the effective owner definition and report the
+ owner definition revision;
+- unknown or unreferenced assets return a clear error unless a later API
+ intentionally permits ad hoc reads.
+
+Source-file helpers may remain named `source` when they specifically deal with
+authored source text and string diagnostics, but they sit under the broader
+asset model.
+
+The public registry materialization boundary is generic over effective assets;
+source-specific helpers are allowed only where the caller truly needs UTF-8
+source text or source-specific diagnostics.
+
+## Consequences
+
+Runtime node attachment uses one registry-owned materialization path for shader
+source, compute shader source, fixture SVG mappings, and future asset kinds.
+
+Overlay replacement and deletion affect asset consumers consistently before
+commit.
+
+Asset revisions come from the registry's effective state instead of a separate
+engine artifact cache.
+
+The future UI can reason about files, project inventory, and runtime consumers
+using the same asset identities.
+
+Authored `AssetSlot` discovery can evolve internally while preserving the
+registry materialization API consumed by the engine.
diff --git a/docs/adr/2026-06-12-effective-project-graph.md b/docs/adr/2026-06-12-effective-project-graph.md
new file mode 100644
index 000000000..89fed0d54
--- /dev/null
+++ b/docs/adr/2026-06-12-effective-project-graph.md
@@ -0,0 +1,93 @@
+# ADR 2026-06-12: Effective Project Graph
+
+## Status
+
+Accepted
+
+## Context
+
+`ProjectRegistry` currently derives an effective `ProjectInventory` containing
+node definitions keyed by `NodeDefLocation` and assets keyed by `AssetLocation`.
+That flat inventory is enough to answer "which definitions and assets are
+currently referenced?", but it is not enough to build or update the engine
+runtime tree.
+
+The engine needs node instances and parent-child edges. A single external node
+definition can be referenced more than once:
+
+```toml
+[nodes.left]
+ref = "./shader.toml"
+
+[nodes.right]
+ref = "./shader.toml"
+```
+
+That project has one definition identity for `/shader.toml`, but it needs two
+runtime node instances.
+
+Inline node definitions add another wrinkle: they are definitions located
+inside an owning artifact at a slot path, but they are still instantiated as
+project graph nodes.
+
+## Decision
+
+Add an effective project graph/topology model to the registry output.
+
+The graph models project node instances separately from node definition
+identity:
+
+- `NodeDefLocation` remains definition identity.
+- A new project node instance identity, such as `ProjectNodeKey`, identifies one
+ authored node instance in the effective project graph.
+- Runtime `NodeId` remains engine-local runtime identity and is not used as
+ project graph identity.
+
+`ProjectInventory` may own the graph directly or expose it through an adjacent
+registry-owned type, but the registry must provide this information as part of
+its effective project state.
+
+The graph needs enough data for engine projection:
+
+- root project node instance;
+- all reachable project node instances;
+- parent-child relationships;
+- child name;
+- child invocation slot path;
+- authored `NodeInvocation`;
+- resolved `NodeDefLocation`;
+- role or ownership metadata, such as root, project child, and playlist entry;
+- indexes from `NodeDefLocation` to project node instances;
+- indexes from `AssetLocation` to project node instances that consume the asset.
+
+`ProjectNodeKey` should be deterministic, serializable, stable across refreshes
+when authored topology does not change, distinct from `NodeDefLocation`, and
+independent of runtime `NodeId`. An ancestry-based project node path is the
+preferred first implementation.
+
+The first implementation may continue using current model discovery APIs:
+
+- `NodeDef::invocation_sites`;
+- `NodeDef::referenced_assets`.
+
+The engine must consume the registry graph and must not reimplement discovery by
+matching directly on `ProjectDef`, `PlaylistDef`, shader source, or fixture
+mapping internals.
+
+## Consequences
+
+The engine can build a runtime `NodeTree` from registry state without keeping
+its own project walker.
+
+Duplicate external references produce multiple project graph nodes and later
+multiple runtime nodes while sharing one `NodeDefEntry`.
+
+Missing or errored referenced definitions remain visible as graph nodes that
+point at error `NodeDefEntry`s. Their children and assets cannot be discovered
+until the definition loads successfully.
+
+Future generic node-reference metadata can feed the same graph API. The cutover
+does not need to wait for a fully generic node slot model.
+
+Project graph changes can later drive incremental runtime apply. The first
+runtime update strategy may still be conservative subtree rebuilds.
diff --git a/docs/adr/2026-06-12-incremental-runtime-apply.md b/docs/adr/2026-06-12-incremental-runtime-apply.md
new file mode 100644
index 000000000..f059335ba
--- /dev/null
+++ b/docs/adr/2026-06-12-incremental-runtime-apply.md
@@ -0,0 +1,91 @@
+# ADR 2026-06-12: Incremental Runtime Apply
+
+## Status
+
+Accepted
+
+## Context
+
+M4 moved project edits onto `ProjectRegistry` overlays, but the server bridge
+still rebuilt the whole `Engine` after accepted overlay mutations and
+filesystem refreshes. That made the new registry API usable, but it kept the
+old runtime lifecycle behavior: any project change dropped and recreated the
+runtime projection.
+
+That is too coarse for the engine cutover. Runtime node identity, runtime
+buffers, output sinks, resolver state, and compiled shader state are expensive
+on ESP32. Plain authored value changes should not churn runtime nodes. The
+registry already computes effective project changes, so the engine can consume
+those summaries directly.
+
+Commit is a separate concern. Overlay mutation makes the effective project live;
+commit only persists that already-effective state into durable artifacts.
+
+## Decision
+
+`ProjectRegistry` remains the project truth. It owns artifacts, the overlay, and
+the effective `ProjectInventory`.
+
+`Engine` owns the runtime projection. It applies `ProjectChangeSummary` in
+place through `Engine::apply_project_changes`.
+
+`ProjectChangeSummary` includes:
+
+- node-definition changes;
+- asset changes;
+- node-use changes through `NodeUseChangeSummary`.
+
+Incremental runtime apply treats node-use changes and node-definition
+kind/error transitions as lifecycle/topology signals:
+
+- removed node uses remove runtime subtrees;
+- added node uses create missing runtime spine nodes and attach runtime
+ payloads;
+- changed node uses are conservatively reprojection candidates;
+- definition kind changes and loaded/error transitions reproject affected uses.
+
+Same-kind `NodeDef` body changes are not lifecycle signals. Asset body changes
+are not generic engine lifecycle signals. Runtime nodes are responsible for
+observing every value they consume through resolver/revision-aware APIs, as
+documented in
+`docs/adr/2026-06-12-node-runtime-slot-value-contract.md`.
+
+Overlay commit does not apply runtime changes and does not rebuild the engine.
+After a successful commit, the server advances the project filesystem version
+past commit-origin writes so the next filesystem refresh does not reprocess the
+same self-originated changes.
+
+Full project reload remains available as an explicit server-level recovery or
+manual operation. It is not the normal edit, refresh, or commit hot path.
+
+## Consequences
+
+The server project wrapper owns both registry and engine and orchestrates:
+
+```text
+overlay mutation -> registry change summary -> engine incremental apply
+filesystem refresh -> registry change summary -> engine incremental apply
+overlay commit -> artifact writes only
+manual reload -> rebuild registry and engine from durable artifacts
+```
+
+Runtime node ids survive ordinary authored body/value edits. Structural edits
+can still remove and reproject affected subtrees.
+
+Engine removal is now responsible for lifecycle cleanup before tree tombstones:
+
+- `NodeRuntime::destroy`;
+- demand roots;
+- output sinks;
+- node-owned runtime buffers;
+- project-runtime indexes;
+- binding indexes.
+
+The first incremental apply implementation remains conservative. It may rebuild
+affected structural subtrees, especially around playlist topology, but it does
+not rebuild unrelated runtime nodes or use `NodeDefChangeKind::Body` as a broad
+rebuild trigger.
+
+Future work can add finer node-specific reload hooks or structural metadata, but
+those should refine this boundary rather than restore full-engine rebuilds to
+the edit hot path.
diff --git a/docs/adr/2026-06-12-node-runtime-slot-value-contract.md b/docs/adr/2026-06-12-node-runtime-slot-value-contract.md
new file mode 100644
index 000000000..94772b77a
--- /dev/null
+++ b/docs/adr/2026-06-12-node-runtime-slot-value-contract.md
@@ -0,0 +1,95 @@
+# ADR 2026-06-12: Node Runtime Slot Value Contract
+
+## Status
+
+Accepted
+
+## Context
+
+Project registry change summaries can report that a node definition body
+changed, but that fact is too coarse to decide whether an engine runtime node
+must be rebuilt.
+
+A `NodeDef` is authored state. A runtime node consumes effective values through
+the slot resolver. Those effective values may come from authored defaults,
+overlay edits, bindings, upstream produced values, or other resolver behavior.
+Therefore an authored body change may not change the effective value observed
+by a runtime node at all.
+
+For example, a slot value in a node definition can change while the runtime node
+continues to read a bound value. Rebuilding the runtime node just because the
+definition body changed would waste CPU, churn runtime resources, and create
+unnecessary work on embedded targets.
+
+Some values feel structural or construction-oriented today, such as shader
+source, fixture source/mapping assets, parameter definitions, or buffer layout
+inputs. The current model does not yet provide a general, explicit distinction
+between ordinary values and these structural inputs.
+
+## Decision
+
+Runtime nodes are expected to fully support changes to every value they
+consume.
+
+The slot resolver is the source of truth for runtime node inputs. Runtime nodes
+must read consumed values through resolver-backed APIs and use revisions or
+equivalent change detection to refresh any cached internal state that depends on
+those values.
+
+A same-kind `NodeDef` body change is not a runtime lifecycle event by default.
+The engine must not destroy, recreate, or reattach runtime nodes solely because
+the authored body changed.
+
+The engine remains responsible for lifecycle and topology changes:
+
+- node uses are added or removed from the effective project tree;
+- a node use changes to a different definition or placement;
+- a definition changes kind;
+- a definition enters or leaves a load error state such that a runtime node can
+ no longer be constructed or can now be constructed.
+
+Runtime node implementations are responsible for their own consumed-value
+semantics:
+
+- ordinary scalar, enum, record, and collection values;
+- values overridden through bindings;
+- source text and source-like asset contents;
+- fixture mappings or other loaded asset-derived inputs;
+- node-specific structural details until the model grows explicit metadata for
+ them.
+
+If a node needs to recompile, rebuild a mapping, invalidate a cache, resize a
+buffer, or enter a failed state because a consumed value or asset changed, that
+logic belongs in the node implementation or in node-specific helper code. It
+should not be implemented as a generic engine reaction to `NodeDef` body
+changes.
+
+Future work may add explicit model metadata, structural notifications, or
+node-specific reload hooks. Those mechanisms should refine this contract rather
+than replace it: runtime nodes still observe effective values, and the engine
+still handles topology and lifecycle.
+
+## Consequences
+
+Incremental runtime apply can be conservative without being wasteful. It can
+apply node-use additions/removals and kind/error transitions while leaving
+same-kind body changes to runtime node value observation.
+
+Tests for project editing should distinguish lifecycle from value observation:
+
+- a simple authored value edit should not destroy or recreate the runtime node;
+- the runtime node should still observe the changed effective value when that
+ value is not masked by a binding;
+- a binding that masks the authored value means the runtime node should keep
+ seeing the bound value;
+- shader source, fixture mapping, and similar asset-backed inputs should be
+ tested through the consuming node's refresh behavior, not by asserting a
+ generic engine reattach.
+
+When a node fails to observe a consumed value change correctly, that is a bug in
+the node or resolver path. It should be fixed with a focused test for that node
+instead of broadening engine-level rebuild policy.
+
+This contract is especially important for ESP32. Avoiding unnecessary runtime
+node churn preserves CPU, memory, buffers, output sinks, and compiled shader
+state on the embedded target.
diff --git a/docs/adr/2026-06-12-project-registry-engine-ownership.md b/docs/adr/2026-06-12-project-registry-engine-ownership.md
new file mode 100644
index 000000000..ce9a4dc71
--- /dev/null
+++ b/docs/adr/2026-06-12-project-registry-engine-ownership.md
@@ -0,0 +1,114 @@
+# ADR 2026-06-12: Project Registry And Engine Ownership
+
+## Status
+
+Accepted
+
+## Context
+
+The incremental artifact reload branch introduced `lpc-registry` as a separate
+crate while the registry work was still a spike. The next phase cuts
+`lpc-engine` over to that project model.
+
+There are three distinct domains:
+
+- files: durable project artifacts and filesystem freshness;
+- project: effective node definitions, assets, overlay state, and project
+ change sets;
+- runtime: instantiated engine nodes, buffers, bindings, services, and ticking.
+
+Current engine loading still owns a separate artifact cache under
+`lpc-engine/src/artifact`. That cache mixes resolved artifact locations,
+opaque runtime handles, loaded `NodeDef` payloads, content revision, and
+refcount-like lifecycle behavior. The new registry already owns project artifact
+freshness and effective node-definition state, so keeping a second engine
+artifact truth would make the cutover harder to reason about.
+
+We also considered whether `ProjectOverlay` and `ProjectInventory` should be
+owned directly by `Engine` instead of `ProjectRegistry`, and whether
+`ProjectRegistry` should be a field on `Engine` or on the server-side project
+container.
+
+## Decision
+
+Keep `lpc-registry` as a separate project-state crate.
+
+`ProjectRegistry` owns:
+
+- the registry `ArtifactStore` for known durable artifact locations and read
+ freshness;
+- `WithRevision` for pending edit intent;
+- the effective `ProjectInventory`;
+- the root `NodeDefLocation`.
+
+`Engine` does not own `ProjectRegistry`. The server-side project container owns
+both:
+
+- `ProjectRegistry`, the canonical project state;
+- `Engine`, the current runtime projection of that project state.
+
+`Engine` owns runtime projection state:
+
+- `NodeTree>`;
+- runtime buffers and resources;
+- resolver and dataflow state;
+- services, graphics, and hardware-facing runtime dependencies;
+- projection indexes from project graph node instances to runtime `NodeId`s.
+
+`NodeTree` remains a runtime tree. It must not become the project discovery
+model.
+
+The old engine artifact cache should be removed or fully superseded by registry
+and model identities. Runtime tree entries should use project/model identities
+such as `NodeDefLocation` and project node instance keys instead of an engine
+local `ArtifactId`.
+
+Project loading returns both sides together through a lightweight
+`LoadedProjectRuntime` wrapper for direct loader callers and tests. Long-lived
+server ownership still lives in the server project container.
+
+The server project container orchestrates registry operations:
+
+```text
+load project -> registry load -> runtime projection build
+apply overlay mutation -> registry change summary -> rebuild runtime projection
+commit overlay -> write artifacts -> rebuild runtime projection
+refresh filesystem events -> registry change summary -> rebuild runtime projection
+```
+
+M4 deliberately uses a full runtime rebuild after accepted overlay edits or
+filesystem refreshes. Incremental runtime updates can later consume registry
+change summaries without changing ownership.
+
+This M4 bridge was superseded by
+`docs/adr/2026-06-12-incremental-runtime-apply.md`: overlay mutation and
+filesystem refresh now apply `ProjectChangeSummary` incrementally, while commit
+is persistence-only and full reload is manual/recovery.
+
+Project reads are runtime queries against `Engine` plus `ProjectRegistry`.
+Overlay reads, overlay mutations, overlay commits, and inventory reads use a
+separate project command API instead of being embedded in project-read
+requests.
+
+## Consequences
+
+Server and UI code can inspect and edit project state through `lpc-registry`
+without depending on engine runtime internals.
+
+Registry APIs remain responsible for keeping overlay, artifact freshness,
+effective inventory, and project change sets consistent.
+
+Engine work becomes a projection problem: turn current registry state and
+registry changes into runtime tree, bindings, resources, and node lifecycles.
+
+Removing engine-local `ArtifactId` avoids two parallel identities for the same
+project definition. If a temporary compatibility handle is needed during the
+cutover, it should be documented as transitional and removed before the cleanup
+milestone completes.
+
+The crate boundary adds one dependency from `lpc-engine` to `lpc-registry`, but
+keeps project/editor state out of concrete runtime node code.
+
+Removing project mutations from project reads gives the UI an explicit
+read/write split: `ProjectReadRequest` observes runtime state, while
+`WireProjectCommand` carries overlay and inventory operations.
diff --git a/docs/architecture.md b/docs/architecture.md
index 9c9712c01..1de9d8bb3 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -69,7 +69,7 @@ it is not currently packaged as a user-facing deployable CLI:
- **Project Management** - Creates, initializes, and manages LightPlayer projects
- **Debug UI** - Visual interface for inspecting node states, outputs, and project structure
- **Hardware Manifests** - Interactive CRUD and validation for board profiles under
- `lp-core/lpc-shared/boards`
+ `lp-core/lpc-hardware/boards`
- **Hardware Calibration** - Host-driven GPIO square-wave calibration that maps board-visible
silkscreen labels to internal HAL GPIO addresses
diff --git a/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md b/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md
index 3b3e5199e..c1df0874d 100644
--- a/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md
+++ b/docs/plans-old/2026-05-15-remove-serde-from-lpc-model/00-notes.md
@@ -99,7 +99,7 @@ their current serde-facing tests are obsolete.
Many small types still implement or derive serde as strings/compact structs:
-- `artifact/artifact_loc.rs`
+- `artifact/artifact_specifier.rs`
- `binding/bus_slot_ref.rs`
- `binding/node_slot_ref.rs`
- `binding/binding_endpoint.rs`
diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md
index 57de032fd..58bc542f9 100644
--- a/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md
+++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/00-design.md
@@ -25,7 +25,7 @@ The plan introduces:
```text
lp-core/lpc-model/src/
artifact/
- artifact_loc.rs
+ artifact_specifier.rs
artifact_read_root.rs
node/
node_invocation.rs
@@ -107,7 +107,7 @@ pub struct NodeInvocation {
}
pub enum NodeDefRef {
- Path { path: ArtifactLocator },
+ Path { path: ArtifactSpecifier },
Inline(Box),
}
```
@@ -155,13 +155,13 @@ Model direction:
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Slotted)]
#[slot(enum_encoding = "external", rename_all = "snake_case")]
pub enum ShaderSource {
- Path(ArtifactLocator),
+ Path(ArtifactSpecifier),
Glsl(String),
}
```
-If `ArtifactLocator` is awkward as a direct slot value, use a new
-`SourcePath`/`ShaderSourcePath` wrapper that parses into `ArtifactLocator`.
+If `ArtifactSpecifier` is awkward as a direct slot value, use a new
+`SourcePath`/`ShaderSourcePath` wrapper that parses into `ArtifactSpecifier`.
Do not expose `artifact` as the canonical authored field.
Breaking migration:
diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md
index e517533db..e0378adc0 100644
--- a/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md
+++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/01-authored-model-shapes.md
@@ -76,8 +76,8 @@ pub enum ShaderSource {
}
```
-Use `SourcePath` if direct `ArtifactLocator` slot support is not clean. The
-engine can parse `SourcePath` into `ArtifactLocator` later.
+Use `SourcePath` if direct `ArtifactSpecifier` slot support is not clean. The
+engine can parse `SourcePath` into `ArtifactSpecifier` later.
Update exports in:
@@ -172,7 +172,7 @@ pub struct NodeInvocation {
}
pub enum NodeDefRef {
- Path { path: ArtifactLocator },
+ Path { path: ArtifactSpecifier },
Inline(Box),
}
```
diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md
index 112fd4aa1..e7e72a7d1 100644
--- a/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md
+++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/03-project-loader-one-file-projects.md
@@ -52,7 +52,7 @@ File:
Current logic:
- Iterates `project_def.nodes`.
-- Extracts `invocation.artifact_locator()`.
+- Extracts `invocation.artifact_specifier()`.
- Resolves a child artifact path.
- Reads a child TOML file.
- Loads `NodeDef` payload into `ArtifactStore`.
diff --git a/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md b/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md
index a5aacab67..10e463fa0 100644
--- a/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md
+++ b/docs/plans-old/2026-05-19-versioned-source-artifacts/future.md
@@ -39,4 +39,4 @@
- **Idea:** Resolve `lib:package/item` locators for node defs and shader source.
- **Why not now:** Path and inline sources must be made clean first.
- **Useful context:** Authored fields should stay named `path` where they mean
- relative locator, while `ArtifactLocator` remains capable of parsing `lib:`.
+ relative locator, while `ArtifactSpecifier` remains capable of parsing `lib:`.
diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md b/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md
index b660d7c25..d632f5753 100644
--- a/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md
+++ b/docs/plans/2026-05-05-project-artifact-initial-load/00-design.md
@@ -14,14 +14,14 @@ with an artifact-rooted load path:
/project.toml -> ProjectDef -> ProjectNode root -> declared node artifacts
```
-The core runtime should start from a project artifact locator, load that
+The core runtime should start from a project artifact specifier, load that
artifact as the root node definition, then instantiate the root `ProjectNode`
and its declared node artifacts. Directory discovery and special directory
suffix semantics are removed from the core initial-load path.
In scope:
-- Stabilize the new source terminology and rustdocs around `ArtifactLocator`,
+- Stabilize the new source terminology and rustdocs around `ArtifactSpecifier`,
`NodeInvocation`, `NodeDef`, `NodeLoc`, and concrete `*Def` node bodies.
- Add `ProjectDef` with `kind = "project"` and a named `nodes` table.
- Flatten `examples/basic` early to the new canonical source layout.
@@ -54,7 +54,7 @@ lp-core/
│
├── lpc-source/src/
│ ├── artifact/
-│ │ └── artifact_loc.rs # UPDATE: ArtifactLocator docs and source-relative path semantics
+│ │ └── artifact_specifier.rs # UPDATE: ArtifactSpecifier docs and source-relative path semantics
│ └── node/
│ ├── node_def.rs # UPDATE: NodeDef docs/visibility/no_std cleanup
│ ├── node_invocation.rs # UPDATE: artifact-only invocation semantics for this plan
@@ -91,7 +91,7 @@ examples/basic/
## Conceptual architecture
```text
-ArtifactLocator("/project.toml")
+ArtifactSpecifier("/project.toml")
│
▼
load ProjectDef
@@ -110,7 +110,7 @@ ProjectNode becomes runtime root NodeEntry
The source model has four separate address concepts:
```text
-ArtifactLocator authored outside-world locator for a loadable artifact
+ArtifactSpecifier authored outside-world locator for a loadable artifact
ArtifactLocation engine-side resolved artifact-manager cache key
NodeLoc source-side relative locator into the runtime node tree
NodeId resolved runtime handle
@@ -123,9 +123,9 @@ the canonical example.
## Main components
-### ArtifactLocator
+### ArtifactSpecifier
-`ArtifactLocator` is the source-side authored locator for loading an artifact.
+`ArtifactSpecifier` is the source-side authored specifier for loading an artifact.
For this plan the important variant is path-based:
```toml
@@ -152,7 +152,7 @@ The long-term conceptual shape may be:
```rust
pub enum NodeInvocation {
- Artifact(ArtifactLocator),
+ Artifact(ArtifactSpecifier),
Inline(NodeDef),
}
```
@@ -244,7 +244,7 @@ Future property references may append `#...`, for example
The core loader should:
1. Load the project artifact from `/project.toml` or an explicitly supplied
- `ArtifactLocator`.
+ `ArtifactSpecifier`.
2. Validate that it is `kind = "project"`.
3. Create/attach the root `ProjectNode`.
4. Load all project node invocations into a project-local name index.
diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md b/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md
index 1e421ac0e..8b259bb28 100644
--- a/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md
+++ b/docs/plans/2026-05-05-project-artifact-initial-load/00-notes.md
@@ -63,7 +63,7 @@ load the project root through `ArtifactManager`.
RustRover-assisted renames/moves have already started:
-- `SrcArtifactSpec` has moved/renamed to `ArtifactLocator`.
+- `SrcArtifactSpec` has moved/renamed to `ArtifactSpecifier`.
- `SrcNodeConfig` has moved/renamed to `NodeInvocation`.
- `TextureConfig` / `ShaderConfig` / `OutputConfig` / `FixtureConfig` have moved
to `TextureDef` / `ShaderDef` / `OutputDef` / `FixtureDef` under
@@ -86,18 +86,18 @@ encouraged.
Terminology direction:
-- `ArtifactLocator`: authored outside-world locator for a loadable artifact
+- `ArtifactSpecifier`: authored outside-world locator for a loadable artifact
(`Path`, future builtin/library variants). This is the source-side form of
"where to load a node definition from".
- Engine-side `ArtifactLocation`: resolved runtime/cache key used by
`ArtifactManager`. This can stay separate from source-side
- `ArtifactLocator`.
+ `ArtifactSpecifier`.
- `NodeInvocation`: parent-owned instruction to instantiate a node at a `nodes`
table key. Desired shape:
```rust
pub enum NodeInvocation {
- Artifact(ArtifactLocator),
+ Artifact(ArtifactSpecifier),
Inline(NodeDef),
}
```
@@ -324,7 +324,7 @@ Context: Many tests and server/CLI paths call `load_from_root`. The new desired
runtime API is "provide the project artifact spec."
Answer: demolish the old loading path as part of this plan. The core runtime
-starts at `/project.toml` (or an explicitly supplied `ArtifactLocator`) and
+starts at `/project.toml` (or an explicitly supplied `ArtifactSpecifier`) and
loads from the project artifact. Remove directory discovery and `/project.json`
from the core initial-load path rather than preserving a compatibility wrapper.
Flatten `examples/basic` early, validate the idea there, then migrate remaining
@@ -365,7 +365,7 @@ not the authored node spec itself. `SrcArtifactSpec` is already widespread and
works, but "spec" now risks meaning both reference and definition.
Answer: already started. `SrcArtifactSpec` has moved/renamed to
-`ArtifactLocator`. The plan should stabilize comments, tests, and relative path
+`ArtifactSpecifier`. The plan should stabilize comments, tests, and relative path
semantics around that name. Keep engine-side `ArtifactLocation` as the resolved
artifact-manager key.
@@ -429,7 +429,7 @@ Initial audit set:
| Existing type | Current role | Likely action |
| --- | --- | --- |
-| `lpc_source::ArtifactLocator` | authored artifact path/lib locator | keep and stabilize source-side relative path semantics |
+| `lpc_model::ArtifactSpecifier` | authored artifact path/lib locator | keep and stabilize source-side relative path semantics |
| `lpc_engine::ArtifactLocation` | resolved artifact-manager key | keep, maybe document as engine-side resolved locator |
| `lpc_source::NodeInvocation` | currently struct `{ artifact, overrides }` | stabilize as the artifact-only invocation shape for this plan; inline variant can wait |
| `lpc_model::NodeLoc` | string wrapper for another node | keep wrapper; add relative dot-syntax parser/resolution semantics and rustdocs |
diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md b/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md
index 20bce270b..9d8f828a0 100644
--- a/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md
+++ b/docs/plans/2026-05-05-project-artifact-initial-load/01-stabilize-source-types.md
@@ -6,7 +6,7 @@ Stabilize the already-started rename/move state before changing loader behavior.
In scope:
-- Update rustdocs/comments for `ArtifactLocator`, engine-side `ArtifactLocation`, `NodeInvocation`, `NodeDef`, `NodeLoc`, and concrete `*Def` types.
+- Update rustdocs/comments for `ArtifactSpecifier`, engine-side `ArtifactLocation`, `NodeInvocation`, `NodeDef`, `NodeLoc`, and concrete `*Def` types.
- Add/finish `ProjectDef` in `lpc-source/src/node/project/`.
- Keep concrete node bodies named `TextureDef`, `ShaderDef`, `OutputDef`, `FixtureDef`, and `ProjectDef`.
- Keep `NodeLoc` as a source string wrapper for now, but add parsing/validation helpers for the relative dot syntax described in `00-design.md`.
@@ -42,7 +42,7 @@ Out of scope:
Relevant files:
-- `lp-core/lpc-source/src/artifact/artifact_loc.rs`
+- `lp-core/lpc-source/src/artifact/artifact_specifier.rs`
- `lp-core/lpc-engine/src/artifact/artifact_location.rs`
- `lp-core/lpc-source/src/node/node_invocation.rs`
- `lp-core/lpc-source/src/node/node_def.rs`
@@ -79,7 +79,7 @@ Use `BTreeMap` or the closest existing no_std-friendly
Update unit tests for:
-- `ArtifactLocator` path/lib round trips still pass.
+- `ArtifactSpecifier` path/lib round trips still pass.
- `NodeInvocation` TOML form for `[nodes.foo] artifact = "./foo.toml"`.
- `NodeLoc` accepts valid relative dot examples and rejects slash paths, empty strings, and absolute-looking node paths.
- `ProjectDef` deserializes a minimal `kind = "project"` TOML with a named `nodes` table.
diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md b/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md
index 9f9e4149f..b98169724 100644
--- a/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md
+++ b/docs/plans/2026-05-05-project-artifact-initial-load/03-project-artifact-loader.md
@@ -6,7 +6,7 @@ Rewrite the core project initial-load path around `/project.toml` and artifact-l
In scope:
-- Add a new core loader entry point that starts from an `ArtifactLocator`, defaulting to `/project.toml` for project-root loads.
+- Add a new core loader entry point that starts from an `ArtifactSpecifier`, defaulting to `/project.toml` for project-root loads.
- Load `ProjectDef` using artifact loading infrastructure.
- Instantiate or attach the root `ProjectNode` / project placeholder for `kind = "project"`.
- Load child node artifacts declared in `ProjectDef.nodes`.
@@ -50,7 +50,7 @@ Relevant files:
The new loader should conceptually do:
```text
-load_project_artifact(fs, services, ArtifactLocator::path("/project.toml"))
+load_project_artifact(fs, services, ArtifactSpecifier::path("/project.toml"))
-> resolve/load ProjectDef
-> create CoreProjectRuntime with services.project_root()
-> attach ProjectNode/root payload
diff --git a/docs/plans/2026-05-05-project-artifact-initial-load/summary.md b/docs/plans/2026-05-05-project-artifact-initial-load/summary.md
index d160fa1b9..ea1677649 100644
--- a/docs/plans/2026-05-05-project-artifact-initial-load/summary.md
+++ b/docs/plans/2026-05-05-project-artifact-initial-load/summary.md
@@ -4,7 +4,7 @@
- Added project artifacts as the root authored entry point: `project.toml` now carries `kind = "project"` and a `[nodes.*]` map of named node invocations.
- Introduced source-side `Def` types for authored node bodies: `ProjectDef`, `TextureDef`, `ShaderDef`, `OutputDef`, and `FixtureDef`.
-- Introduced `NodeInvocation` for "use this node here" authoring, currently backed by artifact locators and shaped for future params/bindings.
+- Introduced `NodeInvocation` for "use this node here" authoring, currently backed by artifact specifiers and shaped for future params/bindings.
- Added relative dot `NodeLoc` parsing in `lpc-model` and loader-side resolution for sibling/current/parent references.
- Reworked `CoreProjectLoader` to start from `/project.toml`, load declared artifact files, build the root `Project` node, and attach declared child nodes without directory discovery.
- Flattened the active examples to file-referenced artifacts: `project.toml`, node `.toml` files, and `shader.glsl`.
@@ -27,7 +27,7 @@
#### `Def`, `Invocation`, `Locator`, `Ref`
-- **Decision:** use `*Def` for authored node bodies, `NodeInvocation` for a node used at a place in a project, `ArtifactLocator` for source-side external locations, and reserve `Ref` language for runtime references.
+- **Decision:** use `*Def` for authored node bodies, `NodeInvocation` for a node used at a place in a project, `ArtifactSpecifier` for source-side external locations, and reserve `Ref` language for runtime references.
- **Why:** this keeps the "what is defined" and "where it is used" concepts distinct without overloading `Spec`.
- **Rejected alternatives:** `NodeSpec` as both path reference and full definition, and `ArtifactRef` for source authoring paths.
diff --git a/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md b/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md
index 8f5a96a44..c5289d67e 100644
--- a/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md
+++ b/docs/plans/2026-05-12-clock-node-time-mutation/00-notes.md
@@ -54,7 +54,7 @@ Full mutation may be substantial, but the clock controls are a valuable first re
- `lp-core/lpc-engine/src/engine/project_loader.rs`
- `ProjectLoader` loads `/project.toml`, then iterates `project_def.nodes.entries`.
- - Each child invocation is resolved through `invocation.artifact_locator()`.
+ - Each child invocation is resolved through `invocation.artifact_specifier()`.
- Each child node def is loaded from disk by `load_node_def`.
- Runtime nodes are attached in kind-specific passes.
- Bindings are registered from each node def's `bindings`.
diff --git a/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md b/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md
index c5666abb1..885feda0f 100644
--- a/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md
+++ b/docs/plans/2026-05-12-clock-node-time-mutation/02-inline-node-invocations.md
@@ -52,9 +52,9 @@ pub enum NodeInvocation {
Public helpers:
-- `NodeInvocation::new(ArtifactLocator)` remains available for existing tests.
+- `NodeInvocation::new(ArtifactSpecifier)` remains available for existing tests.
- `NodeInvocation::inline(NodeDef)` may be added.
-- `artifact_locator()` should return `Option`/`Result, _>` or be replaced with clearer helpers.
+- `artifact_specifier()` should return `Option`/`Result , _>` or be replaced with clearer helpers.
TOML forms:
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/00-design.md b/docs/plans/2026-05-21-artifact-pending-overlay/00-design.md
new file mode 100644
index 000000000..262848158
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/00-design.md
@@ -0,0 +1,111 @@
+# Artifact Pending Overlay — Design
+
+## Scope of work
+
+Replace materialized `SlotOverlay` (`DefDraft` / `Bytes` / `Deleted` snapshots) with
+**`ArtifactOverlay`**: a Slotted, revisioned **map of current pending changes** keyed
+by artifact address, **projected** over committed artifact data.
+
+**In:** `lpc-node-registry` overlay storage, apply, projection, commit, introspection,
+test migration.
+
+**Out:** `lpc-wire`, `lpa-server`, `SessionLog` (M8), engine cutover.
+
+**Deferred (not v1):** Per-artifact cache of folded effective `NodeDef` to avoid
+re-projecting on every path lookup — overlay map stays authoritative; see `future.md`.
+
+## File structure
+
+```
+lp-core/lpc-node-registry/src/
+├── edit/
+│ ├── artifact_overlay.rs # NEW: ArtifactOverlay, ArtifactPending, AssetPending
+│ ├── pending_slot_key.rs # NEW: SlotPath ↔ stable map key (String)
+│ ├── apply.rs # UPDATE: upsert into ArtifactOverlay
+│ ├── mod.rs # UPDATE: exports; remove DefDraft/SlotOverlay
+│ ├── slot_edit.rs # keep
+│ ├── asset_edit.rs # keep
+│ ├── artifact_edit.rs # keep (ingress vocabulary unchanged)
+│ ├── def_draft.rs # DELETE
+│ └── slot_overlay.rs # DELETE
+├── registry/
+│ ├── projection.rs # NEW: committed + pending → effective
+│ ├── node_def_registry.rs # UPDATE: field rename + pending API
+│ ├── slot_apply.rs # UPDATE: upsert, no DefDraft fork
+│ ├── effective_read.rs # UPDATE: delegate to projection
+│ ├── commit.rs # UPDATE: fold pending → fs
+│ └── node_def_entry.rs # unchanged v1
+├── source/
+│ └── materialize.rs # UPDATE: read asset pending from overlay
+├── lib.rs # UPDATE: re-exports
+└── tests/ # UPDATE integration tests
+```
+
+## Conceptual architecture
+
+```text
+ EditBatch / SyncOp::Apply
+ │
+ ▼
+ ┌────────────────────┐
+ │ upsert pending │ SlotEdit → slots[path] = edit (replace)
+ │ │ AssetEdit → asset = Some(...) (replace; clears slots)
+ └─────────┬──────────┘
+ │
+ ▼
+ ┌─────────────────────────────────────────┐
+ │ ArtifactOverlay │
+ │ MapSlot │
+ │ ArtifactPending: │
+ │ slots: MapSlot │ // key = canonical SlotPath string
+ │ asset: AssetPending │ // None | Delete | ReplaceBody
+ └─────────┬───────────────────────────────┘
+ │ project (on read / commit)
+ ▼
+ ArtifactStore (committed bytes) ──► effective bytes / NodeDef
+ │
+ ▼ commit
+ filesystem write
+ │
+ ▼
+ re-sync committed defs; remove overlay keys for committed artifacts
+```
+
+### Reads
+
+| API | Returns |
+|-----|---------|
+| `get(loc)` | Committed `NodeDefEntry` |
+| `effective_state(loc)` / `NodeDefView::get(loc)` | Project pending over committed at `loc` |
+| `overlay.pending_at(location)` | `Option<&ArtifactPending>` for client sync prep |
+| `overlay.is_active()` | Any pending keys exist |
+
+### Pending semantics (not a log)
+
+- **Slot path:** one `SlotEdit` per `SlotPath` key; later edit **replaces** same key.
+- **Asset:** at most one `AssetPending` per artifact; setting asset pending **clears**
+ slot map for that artifact (mutual exclusion).
+- **Delete:** `AssetPending::Delete` tombstone; projection yields missing/deleted bytes.
+
+## Resolved decisions (planning)
+
+| # | Decision |
+|---|----------|
+| Q1 | Overlay keyed by `ArtifactLocation` |
+| Q2 | Slot pending map with replace semantics |
+| Q3 | One asset pending per artifact |
+| Q4 | No `SessionLog` in this plan |
+| Q5 | Projection on read; no cached `NodeDefEntry.view` v1 |
+| Q6 | Slotted `MapSlot` containers |
+| Q7 | `lpc-node-registry` only |
+| Q8 | Keep `AssetEdit::ReplaceBody` escape hatch |
+| D1 | String keys for slot paths in `MapSlot` (v1) |
+| D2 | Asset pending and slot pending mutually exclusive per artifact |
+
+## Validation (full plan)
+
+```bash
+cargo test -p lpc-node-registry
+cargo check -p lpc-node-registry --no-default-features
+just check # final phase only (if lints touched)
+```
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/00-notes.md b/docs/plans/2026-05-21-artifact-pending-overlay/00-notes.md
new file mode 100644
index 000000000..464a75dcf
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/00-notes.md
@@ -0,0 +1,146 @@
+# Artifact pending overlay — planning notes
+
+## Scope of work
+
+Replace the current **materialized** `SlotOverlay` (`DefDraft` / `Bytes` / `Deleted`
+snapshots) with an **`ArtifactOverlay`**: a revisioned map of **current pending
+changes** keyed by artifact address, projected over committed artifact data.
+
+**In scope (this plan):**
+
+- New overlay storage: `ArtifactLocation` → pending slot/asset edits (path-keyed
+ within artifact for slot ops)
+- Apply path upserts into the map (no whole-`NodeDef` fork into `DefDraft`)
+- Effective read / `NodeDefView` = committed ∪ projected pending
+- Commit folds pending map → filesystem → re-sync committed registry state
+- Public read API on registry for pending map (client/wire prep)
+- Slotted `MapSlot` containers for revision + future wire sync reuse
+- Migrate / update existing `lpc-node-registry` tests
+
+**Out of scope (defer):**
+
+- `lpc-wire` message types and client mirror (`future.md`)
+- M8 `SessionLog` append-only session layer (superseded for v1 by address-keyed map;
+ may revisit for multi-client ordering)
+- Engine cutover (`lpc-engine` consuming pending state)
+- CRDT / multi-writer merge beyond per-key replace + revision CAS
+
+**Relationship to other docs:**
+
+- Supersedes the **materialized** overlay portion of
+ `docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md`
+ (SessionLog → DefDraft fold). This plan uses **map of path → edit** as source of
+ truth instead.
+- Complements `NodeDefLoc`-only registry identity (recent cutover).
+- Wire/sync hardening remains in `docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md`.
+
+## Current state
+
+### What exists today
+
+```
+NodeDefRegistry
+ store: ArtifactStore
+ overlay: SlotOverlay // BTreeMap
+ entries: BTreeMap
+
+SlotOverlayEntry
+ Deleted | Bytes(Vec) | DefDraft(NodeDef) // materialized snapshots
+```
+
+**Apply (slot):** fork committed/overlay → mutate `NodeDef` in memory → store whole
+`DefDraft` back. Incoming `SlotEdit` ops are **not retained**.
+
+**Apply (asset):** store raw `Bytes` or `Deleted` tombstone.
+
+**Effective read:** merge overlay snapshot with committed at read time.
+
+**Client pending list:** not available from server; only booleans (`slot_overlay_active`,
+`slot_overlay_contains_path`).
+
+### What's wrong for intended UX
+
+- Pending changes are not enumerable or syncable to client
+- `DefDraft` duplicates full parsed defs per edit
+- Two representations (ops on wire, snapshots in overlay) with no round-trip
+
+### Target model (agreed direction)
+
+```
+NodeDefRegistry
+ store: ArtifactStore
+ overlay: ArtifactOverlay
+ defs: BTreeMap
+ root: Option
+
+ArtifactOverlay
+ by_artifact: MapSlot // Slotted, revisioned
+
+ArtifactPending
+ slots: MapSlot // one pending edit per slot path (replace)
+ asset: Option // whole-file pending (replace)
+
+Projection:
+ effective_bytes(loc) = apply_pending(store.read(loc), overlay.get(loc.artifact))
+ effective_def(loc) = parse(project) sliced to loc.path for inline defs
+```
+
+Not a log — **address → current pending edit**. Repeated edit to same path replaces
+the entry.
+
+## Questions — resolved (2026-05-21)
+
+User confirmed plan: **all Q1–Q8 yes**; D1 = string keys v1; D2 = mutual exclusion.
+
+| # | Answer |
+|---|--------|
+| Q1–Q8 | Yes (per suggested answers) |
+| D1 | `MapSlot` with canonical path string key |
+| D2 | Asset pending clears slot map; slot upsert clears asset pending |
+
+## Questions (original)
+
+### Confirmation batch (answer in one pass: `all yes`, or `Q1 yes, Q2 …`)
+
+| # | Question | Context | Suggested answer |
+|---|----------|---------|------------------|
+| Q1 | Overlay keyed by `ArtifactLocation` (not `String` path)? | Matches `ArtifactStore` + recent identity cutover | Yes |
+| Q2 | Within a `.toml` artifact, pending slot edits keyed by `SlotPath` with **replace** semantics? | Map of path → current `SlotEdit`, not append-only | Yes |
+| Q3 | At most one **asset** pending per artifact (`Option` or enum: None / Delete / ReplaceBody)? | Whole-file replace supersedes prior asset pending | Yes |
+| Q4 | **No** `SessionLog` in this plan — overlay map is the pending source of truth? | M8 design had append log → materialized overlay; user rejected log model | Yes |
+| Q5 | **Projection on read** for v1 — do **not** add cached `view` field on `NodeDefEntry` yet? | Avoid invalidation complexity; `NodeDefView` computes effective | Yes |
+| Q6 | Slotted `MapSlot` from `lpc-model` for overlay containers (not plain `BTreeMap`)? | Reuse revision + future wire snapshot path | Yes |
+| Q7 | Plan scope = **`lpc-node-registry` only** (no wire/server changes)? | Wire follows M1/M3 after registry shape is stable | Yes |
+| Q8 | Keep `AssetEdit::ReplaceBody` for whole-file TOML escape hatch alongside structured `SlotEdit`? | Already in edit vocabulary; diff uses both | Yes |
+
+### Discussion-style (if confirmation answers differ)
+
+#### D1: SlotPath as MapSlot key
+
+`MapSlot` requires `K: MapSlotKeyLike`. `SlotPath` is not implemented today.
+Options:
+
+- **A:** `MapSlot` with canonical path string key (`SlotPath::to_wire()` or existing display)
+- **B:** Implement `MapSlotKeyLike for SlotPath` in `lpc-model`
+
+**Suggested:** A for v1 (minimal model churn); B as `future.md` if hot-path allocs hurt.
+
+#### D2: Asset pending vs slot pending on same artifact
+
+A `.toml` file could theoretically have both slot edits and a whole-file
+`ReplaceBody`. Options:
+
+- **A:** Mutual exclusion — asset pending clears slot map and vice versa
+- **B:** Asset pending wins at projection/commit time
+- **C:** Allow both; commit applies asset body then slot ops (or reverse)
+
+**Suggested:** A — asset replace is escape hatch; applying it clears structured slot
+pending for that artifact.
+
+## Notes
+
+- User correction: overlay is **not an op log** — it is a **mapping of current pending
+ changes** projected over underlying artifact data.
+- Slotted system reuse: overlay containers get revision tracking and wire-serializable
+ shape; `SlotEdit` / `AssetEdit` remain the edit vocabulary (not meta-mutations on
+ overlay slots).
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/01-pending-overlay-types.md b/docs/plans/2026-05-21-artifact-pending-overlay/01-pending-overlay-types.md
new file mode 100644
index 000000000..a74e3c6fb
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/01-pending-overlay-types.md
@@ -0,0 +1,132 @@
+# Phase 1: Pending Overlay Types + Slotted Storage
+
+## Scope of phase
+
+Introduce **`ArtifactOverlay`**, **`ArtifactPending`**, and **`pending_slot_key`**
+helpers. Wire types into `edit/mod.rs` exports. **Do not** change registry apply,
+projection, or commit yet — old `SlotOverlay` may remain until phase 2 removes it.
+
+**In scope:**
+
+- `edit/artifact_overlay.rs` — core types + unit tests
+- `edit/pending_slot_key.rs` — `SlotPath` ↔ canonical `String` key
+- `edit/mod.rs` — export new types alongside legacy (temporary) or replace exports if
+ compile allows
+
+**Out of scope:**
+
+- Registry field swap (`node_def_registry.rs`) — phase 2
+- Deleting `def_draft.rs` / `slot_overlay.rs` — phase 6
+- Wire sync
+
+## Code organization reminders
+
+- One concept per file (`artifact_overlay.rs`, `pending_slot_key.rs`).
+- Public types and impl entry points at top; helpers at bottom; `#[cfg(test)] mod tests` last.
+- Use `lpc_model::MapSlot` for revisioned maps.
+
+## Sub-agent reminders
+
+- Do **not** commit.
+- Do **not** expand scope.
+- Do **not** suppress warnings or weaken tests.
+- If blocked, stop and report.
+- Report: files changed, validation run, deviations.
+
+## Implementation details
+
+### `pending_slot_key.rs`
+
+```rust
+//! Canonical string keys for slot paths in overlay maps.
+
+pub fn slot_path_key(path: &SlotPath) -> String { ... }
+pub fn parse_slot_path_key(key: &str) -> Result { ... }
+```
+
+Use the same string form as existing wire/TOML path display (match how tests parse
+`SlotPath::parse("controls.rate")`). Round-trip tests required.
+
+### `artifact_overlay.rs`
+
+```rust
+use lpc_model::{MapSlot, Revision, SlotPath};
+use crate::{ArtifactLocation, AssetEdit, SlotEdit};
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub enum AssetPending {
+ #[default]
+ None,
+ Delete,
+ ReplaceBody(alloc::vec::Vec), // raw bytes, not String — matches fs write
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ArtifactPending {
+ pub slots: MapSlot,
+ pub asset: AssetPending,
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ArtifactOverlay {
+ by_artifact: MapSlot,
+}
+
+impl ArtifactOverlay {
+ pub fn new() -> Self;
+ pub fn is_empty(&self) -> bool;
+ pub fn contains(&self, location: &ArtifactLocation) -> bool;
+ pub fn pending_at(&self, location: &ArtifactLocation) -> Option<&ArtifactPending>;
+ pub fn pending_at_mut(&mut self, location: &ArtifactLocation) -> Option<&mut ArtifactPending>;
+ pub fn ensure_pending(&mut self, location: ArtifactLocation) -> &mut ArtifactPending;
+ pub fn remove(&mut self, location: &ArtifactLocation) -> bool;
+ pub fn clear(&mut self);
+ pub fn iter(&self) -> impl Iterator- ;
+}
+```
+
+**Note:** `ArtifactLocation` must work as `MapSlot` key — it already implements
+`Ord`/`Eq`. If `MapSlot` requires `MapSlotKeyLike`, add a thin newtype wrapper or
+use string URI key (`location.to_uri()`) consistently; document choice in report.
+
+**Mutual exclusion helpers** (implement now, used in phase 2):
+
+```rust
+impl ArtifactPending {
+ pub fn upsert_slot(&mut self, path: SlotPath, edit: SlotEdit);
+ pub fn set_asset(&mut self, pending: AssetPending); // clears slots
+ pub fn is_empty(&self) -> bool;
+}
+```
+
+- `upsert_slot`: insert/replace `slots[slot_path_key(path)]`, set `asset = None`
+- `set_asset`: assign asset, `slots = MapSlot::default()` (or clear map)
+
+### Tests (in `artifact_overlay.rs`)
+
+- Empty overlay
+- Upsert two slot paths → two keys
+- Upsert same path twice → second replaces first
+- Set asset pending → slot map cleared
+- Upsert slot after asset → asset cleared
+- `remove` / `clear`
+
+### `edit/mod.rs`
+
+Export:
+
+```rust
+pub use artifact_overlay::{ArtifactOverlay, ArtifactPending, AssetPending};
+pub use pending_slot_key::{parse_slot_path_key, slot_path_key};
+```
+
+Keep legacy `SlotOverlay` exports until phase 6 unless registry already broken — prefer
+keeping both temporarily.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry artifact_overlay
+cargo test -p lpc-node-registry pending_slot
+cargo check -p lpc-node-registry
+```
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/02-apply-upsert.md b/docs/plans/2026-05-21-artifact-pending-overlay/02-apply-upsert.md
new file mode 100644
index 000000000..c082ed9dd
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/02-apply-upsert.md
@@ -0,0 +1,118 @@
+# Phase 2: Apply Upsert (Replace DefDraft Path)
+
+## Scope of phase
+
+Switch registry apply path from materialized `DefDraft` to **`ArtifactOverlay` upsert**.
+Replace `NodeDefRegistry` field `overlay: SlotOverlay` with `overlay: ArtifactOverlay`.
+
+**In scope:**
+
+- `registry/node_def_registry.rs` — field type, `apply_artifact_edit`, introspection
+ methods (`slot_overlay_active` → `overlay_active` or keep name with deprecated alias)
+- `registry/slot_apply.rs` — upsert `SlotEdit` into overlay; remove `fork_slot_draft` /
+ `apply_def_draft`
+- `edit/apply.rs` — asset ops upsert into `ArtifactOverlay` (not `SlotOverlay`)
+- Rename internal uses: `slot_overlay` → `overlay` where touched
+
+**Out of scope:**
+
+- Effective read / projection (phase 3) — tests that read effective state may fail until
+ phase 3; keep compile green by leaving effective_read temporarily on old paths OR
+ stub projection as committed-only with TODO — **prefer minimal stub in effective_read
+ that returns committed until phase 3** only if tests block; coordinate in report.
+- Commit (phase 4)
+- Delete old files (phase 6)
+
+## Code organization reminders
+
+- Apply logic stays in `slot_apply.rs` + `edit/apply.rs`.
+- Registry orchestrates; overlay module holds storage only.
+
+## Sub-agent reminders
+
+- Do **not** commit.
+- Do **not** expand scope.
+- Do **not** suppress warnings or weaken tests.
+- If effective tests fail, document which fail and why (expected until phase 3); do not
+ delete tests.
+
+## Implementation details
+
+### Slot apply (`slot_apply.rs`)
+
+Replace body of `apply_slot_op`:
+
+```rust
+pub(crate) fn apply_slot_op(
+ &mut self,
+ path: LpPathBuf,
+ op: &SlotEdit,
+ ...
+) -> Result<(), EditError> {
+ ensure_toml_path(&path)?;
+ let location = self.resolve_location_for_path(&path)?; // or existing helper
+ let pending = self.overlay.ensure_pending(location);
+ pending.upsert_slot(op.path.clone(), op.clone()); // or upsert with op fields
+ Ok(())
+}
+```
+
+Remove: `fork_slot_draft`, `fork_committed_def` from apply path (keep parse helpers if
+projection needs them in phase 3).
+
+**Apply full op:** upsert stores the **`SlotEdit` as sent** (including path inside op).
+If incoming batch has multiple ops same path, last wins via sequential upsert.
+
+### Asset apply (`edit/apply.rs`)
+
+Change signature to accept `&mut ArtifactOverlay`:
+
+```rust
+pub(crate) fn apply_asset_op(
+ overlay: &mut ArtifactOverlay,
+ location: ArtifactLocation,
+ op: &AssetEdit,
+) -> Result<(), EditError>
+```
+
+Map:
+
+- `AssetEdit::Delete` → `pending.set_asset(AssetPending::Delete)`
+- `AssetEdit::ReplaceBody(text)` → `AssetPending::ReplaceBody(text.into_bytes())`
+
+Registry resolves `EditTarget` → `ArtifactLocation` before calling.
+
+### Registry (`node_def_registry.rs`)
+
+- `overlay: ArtifactOverlay`
+- `apply_artifact_edit`: pass location + ops to overlay upsert
+- Update: `slot_overlay_active` → delegate to `overlay.is_empty()` negated
+- Update: `slot_overlay_contains_path` → resolve path to location, `overlay.contains`
+- Update: `slot_overlay_bytes` → return bytes only when `AssetPending::ReplaceBody`
+ (not for slot-only pending)
+- `remove_pending_edit`: `overlay.remove(&location)`
+- `discard_slot_overlay`: `overlay.clear()`
+
+Add private helper if needed:
+
+```rust
+fn artifact_location_for_edit(&self, path: &LpPathBuf) -> Result
+```
+
+### Tests
+
+Update or add unit tests in `slot_apply` / registry for:
+
+- Apply slot op → overlay has one key, no DefDraft
+- Apply asset op → overlay has asset pending, slots empty
+- Second slot op same path → one key updated
+
+## Validate
+
+```bash
+cargo check -p lpc-node-registry
+cargo test -p lpc-node-registry overlay_lifecycle
+cargo test -p lpc-node-registry pending_sync
+```
+
+Note: effective/commit tests may fail — list in report if so.
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/03-projection-layer.md b/docs/plans/2026-05-21-artifact-pending-overlay/03-projection-layer.md
new file mode 100644
index 000000000..d7e3d9378
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/03-projection-layer.md
@@ -0,0 +1,134 @@
+# Phase 3: Projection Layer + Effective Read
+
+## Scope of phase
+
+Implement **projection**: committed artifact bytes/def + `ArtifactPending` → effective
+bytes / `NodeDefState`. Rewire `effective_read.rs`, `NodeDefView`, and
+`materialize.rs` asset overlay reads.
+
+**In scope:**
+
+- `registry/projection.rs` — new module
+- `registry/effective_read.rs` — delegate to projection
+- `registry/mod.rs` — declare `mod projection`
+- `source/materialize.rs` — read `AssetPending` from overlay
+- `view/node_def_view.rs` — unchanged API, works via effective_read
+
+**Out of scope:**
+
+- Commit (phase 4)
+- Public `pending_at` API (phase 5)
+- Delete old overlay files (phase 6)
+- **Cached effective projection** — v1 folds on each read; see `future.md`. Design
+ `projection.rs` so a per-artifact cache can wrap it later without API churn.
+
+## Code organization reminders
+
+- `projection.rs` owns fold logic; effective_read stays thin wrappers.
+- Reuse `slot_apply.rs` helpers: `apply_op_to_def`, `parse_def_bytes`, `serialize_slot_draft`.
+
+## Sub-agent reminders
+
+- Do **not** commit.
+- Do **not** expand scope.
+- Fix failing tests from phase 2; all effective tests must pass this phase.
+
+## Implementation details
+
+### `projection.rs`
+
+Core functions:
+
+```rust
+/// Effective raw bytes for an artifact path (for fs-like read).
+pub fn project_artifact_bytes(
+ committed: Option<&[u8]>,
+ pending: Option<&ArtifactPending>,
+ ctx: &ParseCtx<'_>,
+) -> Result>, RegistryError>;
+
+/// Effective NodeDefState at artifact root.
+pub fn project_artifact_def(
+ committed_state: &NodeDefState,
+ pending: Option<&ArtifactPending>,
+ ctx: &ParseCtx<'_>,
+) -> NodeDefState;
+
+/// Effective def at inline `NodeDefLoc` (slice projected root def).
+pub fn project_def_at_loc(
+ loc: &NodeDefLoc,
+ committed_entry: &NodeDefEntry,
+ pending: Option<&ArtifactPending>,
+ ctx: &ParseCtx<'_>,
+) -> NodeDefState;
+```
+
+**Asset pending:**
+
+- `None` → use committed bytes
+- `Delete` → `None` / parse error (match current deleted overlay behavior)
+- `ReplaceBody(bytes)` → use bytes directly
+
+**Slot pending:**
+
+1. Parse committed bytes → `NodeDef` (or use committed loaded state)
+2. For each `(path_key, edit)` in pending.slots (stable iteration order — sort keys for
+ determinism): `apply_op_to_def(&mut def, edit, ctx, frame)`
+3. Serialize back to bytes if needed, or keep def in memory for state
+
+Use `Revision` from caller frame parameter for apply ops.
+
+**Inline defs:** after projecting artifact root def, use existing
+`def_state_at_source` / `collect_invocations` walk (from effective_read) to slice
+`loc.path`.
+
+### `effective_read.rs`
+
+Replace `DefDraft` / `SlotOverlayEntry` match arms:
+
+```rust
+let pending = self.overlay.pending_at(location);
+// read committed bytes from store
+let bytes = project_artifact_bytes(...)?;
+```
+
+`effective_state(loc)`:
+
+- Get committed entry from `defs`
+- Get pending via `loc.artifact`
+- If no overlay entry for artifact path → return committed state
+- Else `project_def_at_loc`
+
+Remove references to `SlotOverlayEntry::DefDraft`.
+
+### `materialize.rs`
+
+Replace `SlotOverlayEntry` matching with `ArtifactPending`:
+
+- Overlay bytes from `AssetPending::ReplaceBody`
+- Delete → `ArtifactReadFailure::Deleted`
+- Slot-only pending on shader's **parent toml** does not affect glsl file read (unchanged
+ behavior)
+
+### Tests
+
+All must pass:
+
+```bash
+cargo test -p lpc-node-registry effective_projection
+cargo test -p lpc-node-registry slot_overlay
+cargo test -p lpc-node-registry asset_overlay
+```
+
+Add projection unit tests in `projection.rs`:
+
+- Committed clock + pending AssignValue → effective rate changed
+- Asset replace body → effective bytes
+- Asset delete → error/None
+- Inline child path projection
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+```
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/04-commit-fold.md b/docs/plans/2026-05-21-artifact-pending-overlay/04-commit-fold.md
new file mode 100644
index 000000000..2b1c75f05
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/04-commit-fold.md
@@ -0,0 +1,93 @@
+# Phase 4: Commit — Fold Pending → Filesystem
+
+## Scope of phase
+
+Rewrite commit promotion to **fold pending map** into filesystem writes instead of
+serializing `DefDraft`.
+
+**In scope:**
+
+- `registry/commit.rs`
+- Integration with existing `sync_def_artifact` after fs write
+
+**Out of scope:**
+
+- Public pending introspection API (phase 5)
+- Wire
+
+## Code organization reminders
+
+- Commit plan struct builds writes from `ArtifactOverlay::iter()`, not `SlotOverlayEntry`.
+- Reuse `serialize_slot_draft` / projection for slot-only pending.
+
+## Sub-agent reminders
+
+- Do **not** commit.
+- Do **not** expand scope.
+- All commit_promotion tests must pass.
+
+## Implementation details
+
+### Commit plan from overlay
+
+Replace `SlotOverlayCommitPlan::from_slot_overlay`:
+
+```rust
+struct OverlayCommitPlan {
+ writes: Vec<(LpPathBuf, Vec)>,
+ deletes: Vec,
+}
+
+impl OverlayCommitPlan {
+ fn from_overlay(
+ overlay: &ArtifactOverlay,
+ store: &ArtifactStore,
+ fs: &dyn LpFs,
+ ctx: &ParseCtx<'_>,
+ ) -> Result;
+}
+```
+
+For each `(location, pending)` in `overlay.iter()`:
+
+1. Resolve `LpPathBuf` from `location.file_path()`
+2. **Asset pending:**
+ - `Delete` → push to `deletes`
+ - `ReplaceBody(bytes)` → push to `writes`
+3. **Slot pending only** (asset None):
+ - Read committed bytes from store/fs
+ - Project with `project_artifact_bytes` / def fold
+ - Serialize TOML → push to `writes`
+4. Skip empty pending buckets
+
+Do **not** write if projected bytes equal committed (optional optimization — OK to skip
+for v1).
+
+### `commit_slot_overlay` flow
+
+Keep existing order:
+
+1. Build plan from overlay
+2. Write/delete fs
+3. `store.apply_fs_changes`
+4. Register new paths
+5. `sync_def_artifact` for affected `.toml` locations
+6. `reconcile_artifacts`
+7. `overlay.clear()`
+
+Remove all `SlotOverlayEntry` / `DefDraft` / `serialize_slot_draft(draft.def)` paths
+from commit.
+
+### Edge cases
+
+- Implicit create: path in overlay but not in store — register on commit (existing behavior)
+- Slot + asset mutual exclusion already enforced at apply — commit sees one or the other
+- Empty overlay → early return (existing)
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry commit_promotion
+cargo test -p lpc-node-registry pending_sync
+cargo test -p lpc-node-registry project_diff
+```
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/05-pending-introspection-api.md b/docs/plans/2026-05-21-artifact-pending-overlay/05-pending-introspection-api.md
new file mode 100644
index 000000000..9b50dd8fe
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/05-pending-introspection-api.md
@@ -0,0 +1,78 @@
+# Phase 5: Registry Pending Introspection API
+
+## Scope of phase
+
+Expose read-only API for **pending map** contents — prep for wire/client sync without
+implementing wire yet.
+
+**In scope:**
+
+- `ArtifactOverlay` public iteration (if not already public)
+- `NodeDefRegistry` methods
+- `lib.rs` re-exports
+- Unit tests for introspection
+
+**Out of scope:**
+
+- `lpc-wire` types
+- Session/version CAS enforcement (document revision fields available on MapSlot)
+
+## Code organization reminders
+
+- Keep introspection on registry as thin delegates to overlay.
+- No mutation APIs beyond existing apply/discard/remove.
+
+## Sub-agent reminders
+
+- Do **not** commit.
+- Do **not** expand scope.
+
+## Implementation details
+
+### Registry API
+
+```rust
+impl NodeDefRegistry {
+ /// Whether any artifact has pending edits.
+ pub fn overlay_active(&self) -> bool;
+
+ /// Pending edits for one artifact, if any.
+ pub fn pending_at(&self, location: &ArtifactLocation) -> Option<&ArtifactPending>;
+
+ /// Iterate artifacts with pending edits (stable order).
+ pub fn iter_pending(&self) -> impl Iterator- ;
+
+ /// Whether a specific slot path has a pending edit within an artifact.
+ pub fn has_pending_slot(&self, location: &ArtifactLocation, path: &SlotPath) -> bool;
+}
+```
+
+Deprecate or alias old names:
+
+- `slot_overlay_active` → `overlay_active` (keep deprecated alias one release if exported)
+- `slot_overlay_contains_path(path)` → resolve location, check `overlay.contains`
+
+Document in module docs on `NodeDefRegistry` that pending is **address-keyed current
+edits**, syncable via future wire.
+
+### `ArtifactPending` accessors
+
+```rust
+impl ArtifactPending {
+ pub fn slot_edits(&self) -> impl Iterator
- ;
+ pub fn asset_pending(&self) -> &AssetPending;
+ pub fn slots_revision(&self) -> Revision; // delegate to MapSlot if available
+}
+```
+
+### Tests
+
+- Load project, apply edit, assert `iter_pending` yields one artifact with one slot key
+- Apply asset edit, assert slot map empty in introspection
+- Commit clears `iter_pending`
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+```
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/06-test-migration-dead-code.md b/docs/plans/2026-05-21-artifact-pending-overlay/06-test-migration-dead-code.md
new file mode 100644
index 000000000..731ca86b6
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/06-test-migration-dead-code.md
@@ -0,0 +1,77 @@
+# Phase 6: Test Migration + Delete Dead Overlay Code
+
+## Scope of phase
+
+Remove **`DefDraft`**, **`SlotOverlay`**, **`SlotOverlayEntry`**. Clean deprecated aliases
+if safe. Ensure all integration tests assert pending-map semantics where relevant.
+
+**In scope:**
+
+- Delete `edit/def_draft.rs`, `edit/slot_overlay.rs`
+- Update `edit/mod.rs`, `lib.rs` — remove dead exports and deprecated aliases for
+ `SlotOverlay` / `DefDraft` / `OverlayEntry` / `SlotDraft` if nothing in workspace uses
+ them (grep first; keep deprecated types only if external callers exist in same crate tests)
+- Update tests that referenced `slot_overlay_*` naming to `overlay_*` where user-facing
+- Grep for `DefDraft`, `SlotOverlayEntry`, `SlotOverlay`, `apply_def_draft`
+
+**Out of scope:**
+
+- Roadmap doc updates (optional one-line note in summary only)
+- Wire crate
+
+## Code organization reminders
+
+- After deletes, `cargo check -p lpc-node-registry --no-default-features` must pass.
+- Tests at bottom of files.
+
+## Sub-agent reminders
+
+- Do **not** commit.
+- Do **not** weaken tests to green.
+
+## Implementation details
+
+### Deletions
+
+- `lp-core/lpc-node-registry/src/edit/def_draft.rs`
+- `lp-core/lpc-node-registry/src/edit/slot_overlay.rs`
+
+### Export cleanup (`edit/mod.rs`, `lib.rs`)
+
+Remove:
+
+```rust
+pub use def_draft::DefDraft;
+pub use slot_overlay::{SlotOverlay, SlotOverlayEntry};
+```
+
+Remove deprecated type aliases if grep shows no uses outside this crate's deprecated
+re-exports in `lib.rs` legacy block.
+
+### Test updates
+
+Review and adjust assertions in:
+
+- `tests/slot_overlay.rs` — rename file optional (not required); update comments
+- `tests/overlay_lifecycle.rs`
+- `tests/commit_promotion.rs`
+- `tests/pending_sync.rs`
+- `registry/commit.rs` tests — remove `DefDraft` fixture
+
+Add one test: after two AssignValue same path, introspection shows **one** slot edit (last
+value).
+
+### Grep checklist
+
+```bash
+rg 'DefDraft|SlotOverlay|SlotOverlayEntry|slot_overlay' lp-core/lpc-node-registry
+```
+
+Zero hits in src/ except possibly CHANGELOG — fix all.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo check -p lpc-node-registry --no-default-features
+```
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/07-cleanup-validation.md b/docs/plans/2026-05-21-artifact-pending-overlay/07-cleanup-validation.md
new file mode 100644
index 000000000..2eabcb30a
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/07-cleanup-validation.md
@@ -0,0 +1,67 @@
+# Phase 7: Cleanup, Validation, Summary
+
+## Scope of phase
+
+Final grep for TODOs/temp code, formatting, full validation, write `summary.md`.
+
+**In scope:**
+
+- `cargo +nightly fmt` on touched crate
+- Fix clippy in `lpc-node-registry` if any new warnings
+- `summary.md` per plan template
+- Optional one-line cross-link in
+ `docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md`
+ **only if** user wants — prefer note in summary only: "M8 overlay layer superseded by
+ artifact-pending-overlay plan"
+
+**Out of scope:**
+
+- `just check` full workspace unless quick; minimum `cargo test -p lpc-node-registry`
+- Git commit (user triggers separately unless asked)
+
+## Code organization reminders
+
+- Remove debug prints, stray TODOs from phases 1–6.
+- Ensure module docs on `ArtifactOverlay` describe map-not-log semantics.
+
+## Sub-agent reminders
+
+- Do **not** commit unless user explicitly requests in phase prompt.
+- Report full validation output.
+
+## Implementation details
+
+### Grep cleanup
+
+```bash
+rg 'TODO|FIXME|println!' lp-core/lpc-node-registry/src/edit lp-core/lpc-node-registry/src/registry
+rg 'DefDraft|SlotOverlay' lp-core/lpc-node-registry
+```
+
+### Format
+
+```bash
+cargo +nightly fmt -p lpc-node-registry
+```
+
+### Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo check -p lpc-node-registry --no-default-features
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
+
+If clippy fails on pre-existing issues outside overlay touch, report without drive-by fixes.
+
+### `summary.md`
+
+Write per plan template:
+
+- **What was built** — bullet list
+- **Decisions for future reference** — map-not-log, mutual exclusion, string slot keys,
+ no SessionLog v1, projection-on-read, Slotted MapSlot, supersede M8 materialized overlay
+
+## Validate
+
+All commands above green.
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/future.md b/docs/plans/2026-05-21-artifact-pending-overlay/future.md
new file mode 100644
index 000000000..e67e662f6
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/future.md
@@ -0,0 +1,33 @@
+## Wire sync for ArtifactOverlay
+
+- **Idea:** Expose overlay roots via `slot_sync_codec` / `WireSlotPatch` per artifact or one registry root.
+- **Why not now:** Registry shape must land first; M1 wire design still open.
+- **Useful context:** `lpc-wire/src/slot/access_sync.rs`, M1 `ui-parity.md` pending row.
+
+## MapSlotKeyLike for SlotPath
+
+- **Idea:** Use `MapSlot
` directly instead of string keys.
+- **Why not now:** Requires `lpc-model` change; string keys sufficient for v1.
+- **Useful context:** `lpc-model/src/slot/value_slot.rs` `MapSlotKeyLike`.
+
+## SessionLog (M8) for ordering / audit
+
+- **Idea:** Optional append-only log above the pending map for multi-client ordering.
+- **Why not now:** Address-keyed map + revision CAS is enough for v1 single editor.
+- **Useful context:** `m8-edit-session-sync/00-design.md` — explicitly superseded for overlay storage.
+
+## Cached effective projection (per artifact)
+
+- **Idea:** After folding `committed + pending → NodeDef`, cache the result keyed by
+ `ArtifactLocation` (or on each affected `NodeDefEntry`). Path lookups (`effective_state`,
+ `NodeDefView`, slot accessors) hit the cache instead of re-cloning and re-applying the
+ pending map on every read.
+- **Invalidation:** Clear or rebuild cache when that artifact's `ArtifactPending` bucket
+ changes (any upsert/remove on apply, or `remove_pending_edit` / `discard` / successful
+ commit for that location). Committed fs sync that changes the base def also invalidates.
+- **Why not v1:** Ephemeral fold in `projection.rs` is simpler and has no invalidation bugs;
+ pending maps are usually small so per-read cost may be fine initially.
+- **API shape:** Phase 3 should keep projection behind `project_*` helpers so a cache layer
+ can wrap them without changing `NodeDefView` callers.
+- **Useful context:** User agreed v1 = no stored effective; caching is an optimization for
+ hot path reads, not a second source of truth (overlay map remains authoritative).
diff --git a/docs/plans/2026-05-21-artifact-pending-overlay/summary.md b/docs/plans/2026-05-21-artifact-pending-overlay/summary.md
new file mode 100644
index 000000000..bfd751bcb
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-pending-overlay/summary.md
@@ -0,0 +1,57 @@
+# Artifact Pending Overlay — Implementation Summary
+
+## What changed
+
+Replaced path-keyed `SlotOverlay` / `DefDraft` materialization with address-keyed
+`ArtifactOverlay` storing **current pending edits** per `ArtifactLoc`:
+
+- **Slot pending:** `MapSlot` with upsert-by-edit-key and apply-order
+ tracking (supports multiple `MapInsert` ops on the same map path).
+- **Asset pending:** `AssetPending` (`None` | `Delete` | `ReplaceBody`) mutually exclusive
+ with slot map.
+- **Projection:** `registry/projection.rs` folds committed + pending on read (in-memory
+ for loaded defs; bytes path for commit / effective bytes).
+- **Commit:** folds pending map → filesystem writes via `OverlayCommitPlan`.
+- **Introspection:** `overlay_active`, `pending_at`, `iter_pending`, `has_pending_slot`.
+
+## Removed
+
+- `edit/def_draft.rs`
+- `edit/slot_overlay.rs`
+- Public exports: `DefDraft`, `SlotOverlay`, `SlotOverlayEntry`
+
+## Key files
+
+| File | Role |
+|------|------|
+| `edit/artifact_overlay.rs` | Overlay storage + mutual exclusion |
+| `edit/pending_slot_key.rs` | `slot_path_key`, `slot_edit_key`, parse |
+| `registry/projection.rs` | Fold committed + pending |
+| `registry/effective_read.rs` | Effective reads delegate to projection |
+| `registry/commit.rs` | Promote overlay → fs |
+| `registry/slot_apply.rs` | Upsert slot ops (no fork draft) |
+
+## Deviations from plan
+
+1. **Outer overlay map** uses `MapSlot` keyed by
+ `ArtifactLoc::to_uri()` (not `MapSlot`) because `ArtifactLoc` does not
+ implement `MapSlotKeyLike`.
+2. **Slot edit keys** use `slot_edit_key()` (path + op kind + map key for map ops), not
+ path-only — required for multiple map inserts on one path.
+3. **Apply order** preserved via private `slot_order: Vec`, not sorted keys.
+4. **Inline def projection** always folds from **artifact root** entry, then slices
+ `NodeDefLoc.path` (fixes child effective view).
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry # 86 tests pass
+cargo check -p lpc-node-registry --no-default-features
+cargo +nightly fmt -p lpc-node-registry
+```
+
+## Follow-ups (see `future.md`)
+
+- Wire sync / client read-back of pending map
+- Per-artifact cached effective projection
+- `SessionLog` integration (M8)
diff --git a/docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md b/docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md
new file mode 100644
index 000000000..9e4ecd1cc
--- /dev/null
+++ b/docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md
@@ -0,0 +1,166 @@
+# Artifact-Routed File Reload Notes
+
+## Scope
+
+Implement file-change reload so a running LightPlayer project updates only the artifacts and nodes affected by changed files.
+
+The target architecture is:
+
+- File changes are routed into the running engine.
+- The artifact manager owns file identity, content freshness, and load error state.
+- Nodes react to changed artifacts in place.
+- File changes must not call `Project::reload()` or reconstruct the whole `Engine`.
+- Hot reload must avoid peak-memory patterns that keep an old whole-engine runtime alive while constructing a new one.
+- Source-file artifacts must not retain full file contents in memory. They should track only identity and freshness for now: file path/name plus content version. Nodes lazy-load actual bytes/text only while preparing or compiling.
+
+This plan covers:
+
+- Replacing the server file-change path that currently calls `Project::reload()`.
+- Clarifying the artifact model before building reload on top of it.
+- Extending `ArtifactStore` so source files such as GLSL and SVG are normal file-backed artifacts, not special server reload cases, while keeping their contents out of long-lived heap storage.
+- Separating parsed node definitions from artifacts if needed, so artifact storage does not become a confusing node definition registry.
+- Registering node-to-artifact dependencies during project load.
+- Letting shader and fixture nodes respond to changed dependent artifacts.
+- Handling node TOML changes by reloading/repreparing affected nodes in place.
+- Capturing bad source/TOML as artifact and node error state while keeping last-good runtime state where possible.
+- Preserving scarce ESP32 heap by avoiding cached source text, duplicate engines, and unnecessary duplicate node runtimes.
+
+This plan does not cover:
+
+- A full optimal graph diff for arbitrary `project.toml` edits in the first implementation.
+- Library artifact specifiers.
+- Host precompilation or any change that weakens the on-device GLSL JIT path.
+
+## Current State
+
+Relevant files:
+
+- `lp-app/lpa-server/src/server.rs`
+ - `LpServer::tick` collects project-relative `FsChange`s.
+ - Current behavior calls `project.reload()` for project changes.
+ - This is the wrong architectural boundary for file reload.
+
+- `lp-app/lpa-server/src/project.rs`
+ - `Project::reload()` drops the old runtime and calls `ProjectLoader::load_from_root`.
+ - This remains useful for explicit user-initiated full reload/load flows, but should not run on filesystem changes.
+
+- `lp-core/lpc-engine/src/engine/engine.rs`
+ - `Engine::handle_fs_changes(&mut self, _changes: &[FsChange])` exists but is a no-op.
+ - `Engine` owns `ArtifactStore`, `artifact_nodes`, `demand_roots`, and runtime node tree.
+ - `TickContext` currently receives the owning node definition artifact id and `content_frame`.
+
+- `lp-core/lpc-engine/src/artifact/*`
+ - `ArtifactStore` maps `ArtifactLocation` to `ArtifactId`.
+- `ArtifactLocation::File(LpPathBuf)` already exists.
+- `ArtifactLocation::InlineNode { owner, name }` exists, but this is likely a model smell. Inline node definitions are not artifacts; they are node definitions derived from an owning artifact such as `project.toml` or a playlist node definition.
+- `ArtifactState` currently stores only `NodeDef` payloads.
+- `ArtifactStore::load_with` overwrites state with an error on load failure.
+- There is no generic text/source-file payload.
+- There is no last-good payload plus latest-error model.
+- There is no content fingerprint/hash/diff API. Do not add one in the first pass.
+- The filesystem abstraction currently exposes `read_file`, which returns a `Vec`. Avoid using it in artifact freshness handling unless a node is actively preparing and will drop the buffer immediately.
+
+- `lp-core/lpc-engine/src/engine/project_loader.rs`
+- Node TOML files are loaded into `ArtifactStore` as `NodeDef`.
+ - `ShaderSource::Path` is read by `read_shader_source`, producing a `String` passed to `ShaderNode::new`.
+ - `MappingConfig::SvgPath` is read by `resolve_fixture_mapping`, producing a resolved `MappingConfig` passed to `FixtureNode::new`.
+ - GLSL and SVG files are therefore not tracked as artifacts after project load.
+
+- `lp-core/lpc-engine/src/nodes/shader/shader_node.rs`
+ - Shader node already syncs authored shader config from node def slots during `produce`.
+- Shader source text is currently stored directly as `glsl_source`.
+- Compile failures are stored in `compilation_error`.
+- Current compile failure drops `self.shader`; for hot reload we should stage new compiles so last-good compiled shader can remain active when a changed source fails.
+- For path-based shader sources, long-lived `glsl_source: String` is wasteful. The node should store a source artifact reference and last-seen source frame, read source text only during compilation, and drop it after compile succeeds or fails.
+
+- `lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs`
+ - Fixture node already syncs mutable authored mapping fields for `PathPoints`.
+ - `SvgPath` is currently ignored by runtime sync because it is resolved at load time.
+- Runtime holds precomputed/direct mapping state derived from the resolved mapping.
+- For SVG mappings, the SVG text should be read only while resolving a candidate mapping. The long-lived fixture state should keep the resolved/precomputed mapping, not the SVG text.
+
+- `lp-core/lpc-engine/src/node/contexts.rs`
+ - `TickContext::artifact_ref`, `artifact_content_frame`, and `artifact_changed_since` exist.
+ - These only cover the owning node definition artifact, not dependent source artifacts.
+
+- `lp-core/lpc-engine/src/engine/slot_mutation.rs`
+- Slot mutation updates a node definition artifact in place and bumps `content_frame`.
+- This is the closest existing example of the intended model: update artifact payload, let node runtime observe content frame and sync from authored slots.
+
+## Model Correction
+
+The current names and ownership boundaries are muddy:
+
+- An artifact should be the thing loaded from outside the runtime graph: usually a file or future library resource.
+- A `NodeDef` is derived from an artifact. It is not itself the artifact.
+- Inline node definitions are also derived from an owning artifact. They should not require fake artifact locations.
+- A running node entry should point to a node definition handle, and that handle should be backed by a node definition registry or table.
+- The artifact store should answer "what source changed, and what version is it?"
+- The node definition registry should answer "what parsed node definition does this node instance use, and when did that definition last change?"
+
+This suggests a split:
+
+- `ArtifactStore`: source identity and freshness. Minimal file artifacts store path plus content version. It should not assume payloads are only `NodeDef`.
+- `NodeDefRegistry` or equivalent: parsed `NodeDef` storage, including definitions parsed from node TOML files and inline definitions parsed out of another artifact.
+- `NodeDefHandle`: handle into the node definition registry, not an artifact id plus path pretending every node def is an artifact root.
+- Dependency index: maps artifact ids to derived node def handles and live node ids.
+
+The reload plan should start by making this model explicit enough that source-file reload does not add more special cases to `ArtifactStore`.
+
+## User Notes
+
+- File reloads should only reload what they need.
+- Reload must be routed through the artifact manager.
+- Server/file-watcher code should not know special cases for GLSL or SVG.
+- GLSL and SVG should just be file artifacts.
+- Artifacts are not meant to be only `NodeDef`s. The artifact is the source from which a `NodeDef` or source text comes.
+- `ArtifactLocation::InlineNode` is probably wrong; inline node definitions belong in a node definition registry derived from the owning artifact.
+- A separate `NodeDefRegistry` may be needed to clarify responsibilities.
+- The running project should handle changes.
+- Changes to node files should reload the affected node, not the whole project.
+- It is acceptable initially to reload child nodes when a node file changes, but avoid unnecessary whole-project reload.
+- ESP32 memory is tight; do not implement transactional whole-engine reload that holds two engines at once.
+- 300 KB of heap is a real production constraint. The first real art project hit memory limits, so this design should prefer metadata, handles, and staged local work over retaining full files or duplicating runtime state.
+- Keep the first artifact metadata model minimalist. Name/path and version are probably enough. Add digest, length, or richer read/stat metadata only when a real bug or performance problem demonstrates the need.
+- Preserve the on-device GLSL JIT. Do not gate or stub the compiler path.
+
+## Open Questions
+
+### How should `project.toml` file changes behave in the first implementation?
+
+- **Context:** Node TOML changes can be mapped to a specific loaded node through `artifact_nodes`. `project.toml` changes can add, remove, or rewire top-level nodes and may require graph reconciliation.
+- **Suggested answer:** Do not call `Project::reload()`. Treat `project.toml` as the root graph artifact. Phase 1 should route it through artifact invalidation and record a root/project error state if live reconciliation is unsupported. A later phase can implement limited reconciliation for adding/removing/repointing top-level nodes.
+
+### Should source file artifacts store bytes or UTF-8 text?
+
+- **Context:** GLSL and SVG need UTF-8 text. Future resources may need bytes. `ArtifactState` currently stores only `NodeDef`.
+- **Answer:** No for long-lived source files. Source-file artifacts should store metadata only: location/path and content version. Nodes lazy-load actual bytes/text from `ArtifactReadRoot` only while preparing/compiling. Node definitions remain stored as parsed `NodeDef` because the live graph reads their slots every frame.
+
+### Should parsed node definitions remain in `ArtifactStore`?
+
+- **Context:** `ArtifactStore` currently stores `NodeDef` directly and even has `ArtifactLocation::InlineNode`, which makes it act like a node definition registry. The user clarified that an artifact is the thing from which a node def comes, not the node def itself.
+- **Suggested answer:** Introduce a separate `NodeDefRegistry` or equivalent table. Keep parsed `NodeDef`s there. Artifacts should track source freshness and maybe load/read errors. The registry should track derived definitions and their source artifact/version.
+
+### What replaces `ArtifactLocation::InlineNode`?
+
+- **Context:** Inline nodes are authored inside another artifact, such as `project.toml` or a playlist/node definition file. They need stable definition handles but are not separately loadable files.
+- **Suggested answer:** Represent inline definitions as `NodeDefHandle`s in `NodeDefRegistry`, with metadata pointing to their owning source artifact plus an inline path/name. Do not represent them as artifact locations.
+
+### Should failed hot reload keep last-good payload?
+
+- **Context:** `ArtifactStore::load_with` currently replaces state with error on failure. For hot reload, replacing the only good payload would make dependent nodes lose working state.
+- **Suggested answer:** Yes. Store last-good payload and latest error separately. Load failure updates error metadata and error frame, not the last-good payload/content frame.
+
+### How should artifact diffing work without retaining full file contents?
+
+- **Context:** We might eventually want unchanged writes to avoid bumping `content_frame`, but retaining or reading full source text for generic diffing is too expensive for this first pass.
+- **Answer:** Do not solve byte-level diffing in the first pass. Treat filesystem change events as version bumps for known file artifacts. If duplicate writes become noisy later, add a cheap digest/length or filesystem-provided version/stat layer as a follow-up.
+
+### Should changed GLSL/SVG immediately reprepare nodes, or should nodes notice on next tick/render?
+
+- **Context:** Demand roots tick every frame, but shader compile happens during render, not produce. Fixture control render happens in control rendering. Immediate reprepare gives faster error readback but can do expensive work in the file-change handler.
+- **Suggested answer:** Artifact invalidation/checking happens immediately and updates metadata only. Nodes check dependent artifact frames during their normal produce/render/control paths and stage work there. The engine may mark affected nodes dirty/error-readable immediately when artifact stat/read/hash fails.
+
+## Suggested Plan Name
+
+`2026-05-21-artifact-routed-file-reload`
diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/01-FIXED-root-ensure-resets-existing-kind.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/01-FIXED-root-ensure-resets-existing-kind.md
new file mode 100644
index 000000000..80c9e7461
--- /dev/null
+++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/01-FIXED-root-ensure-resets-existing-kind.md
@@ -0,0 +1,43 @@
+# Root Ensure Resets Existing Kind
+
+- **Severity:** P1
+- **Status:** fixed
+- **First seen:** 2026-05-27-review.md
+- **Last reviewed:** 2026-05-27-review.md
+- **Owner:** unassigned
+
+## Finding
+
+`EnsurePresent` on a root variant path always calls `set_slot_variant_default` through `NodeArtifact` before checking whether the artifact is already on that variant. That can discard an existing node def payload when a client sends a variant-qualified path such as `Shader.render_order` against an already-`Shader` artifact.
+
+## Evidence
+
+- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:109` - any first field segment is treated as a possible root variant.
+- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:111` - the code calls `set_slot_variant_default` unconditionally once the variant name is valid.
+- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:125` - the default-constructed artifact payload is written back to `def`.
+- `lp-core/lpc-model/src/slot/slot_mutation.rs:198` - the generic `ensure_slot_present` path correctly guards nested enum changes with `if en.variant() != name.as_str()`, but the root bridge bypasses that guard.
+
+## Impact
+
+A valid edit can accidentally reset unrelated fields in the same artifact. For example, assigning one value through a variant-qualified root path can first replace the whole active root node with its default body, then assign only the requested leaf. Any fields not also present in the edit batch are lost.
+
+## Suggested Fix
+
+Before calling `set_slot_variant_default` in the root bridge, check whether `def.variant_name() == variant.as_str()`. If it already matches, either call generic `ensure_slot_present` on the current `NodeDef` for the tail path or wrap in `NodeArtifact` without resetting the variant.
+
+## Resolution
+
+`apply_ensure_present` now recognizes authored root node variants explicitly, only resets the root def when the requested root variant differs from the current artifact kind, and returns the payload-relative tail path for the following value assignment. Same-kind root-qualified edits therefore operate on the active payload instead of rebuilding the whole artifact.
+
+## Validation
+
+- Added `c1_root_variant_path_preserves_existing_same_kind_payload`, which starts with a non-default `Clock` def, applies `SlotEdit::AssignValue` through a root variant-qualified path, and asserts unrelated payload fields are preserved.
+- `cargo fmt --check`: passed
+- `cargo check -p lpc-node-registry`: passed
+- `cargo test -p lpc-model slot_mutation`: passed
+- `cargo test -p lpc-node-registry`: passed
+
+## History
+
+- 2026-05-27: opened by Codex review.
+- 2026-05-27: fixed by preserving same-kind root payloads during root variant-qualified ensure.
diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/02-FIXED-structural-edits-leave-stale-descendants.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/02-FIXED-structural-edits-leave-stale-descendants.md
new file mode 100644
index 000000000..4d4f81f6d
--- /dev/null
+++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/02-FIXED-structural-edits-leave-stale-descendants.md
@@ -0,0 +1,42 @@
+# Structural Edits Leave Stale Descendants
+
+- **Severity:** P2
+- **Status:** fixed
+- **First seen:** 2026-05-27-review.md
+- **Last reviewed:** 2026-05-27-review.md
+- **Owner:** unassigned
+
+## Finding
+
+Overlay upsert identity is now exact `SlotPath` equality only. That is clean for leaf writes, but structural edits such as `Remove { path: entries[0] }` or `EnsurePresent { path: entries[0].node.Shader }` can leave older descendant edits queued underneath the same parent.
+
+## Evidence
+
+- `lp-core/lpc-node-registry/src/edit/artifact_overlay.rs:39` - `upsert_slot` keys the pending edit by the new edit's exact path.
+- `lp-core/lpc-node-registry/src/edit/artifact_overlay.rs:43` - replacement only removes an existing edit when `existing.path() == &target`.
+- `lp-core/lpc-node-registry/src/registry/slot_apply.rs:57` - pending ops are applied later in stored order, so stale descendants still execute after a parent presence/kind change unless they happen to share the same exact path.
+
+## Impact
+
+A normal editing sequence can produce either surprising final state or a projection error. For example, a pending descendant assignment for a Clock child can survive a later parent edit that changes that child to Shader. If the stale descendant runs after the kind change, the effective projection can become a parse/mutation error; if it runs before, the later structural edit silently discards it. Both are hard for clients to reason about.
+
+## Suggested Fix
+
+Teach `ArtifactEdits::upsert_slot` about ancestor/descendant conflict policy. A parent `Remove` or structural `EnsurePresent` should remove pending descendants under that path. A later descendant `AssignValue` should supersede an ancestor `Remove` when auto-vivification is intended to recreate the path.
+
+## Resolution
+
+`ArtifactEdits::upsert_slot` now keeps exact path identity for ordinary replacement while adding ancestor/descendant conflict cleanup for structural edits. Parent removes and structural ensures clear stale descendants, positive edits clear ancestor removes so auto-vivification can recreate paths, and redundant child removes are skipped when an ancestor remove already covers the same subtree.
+
+## Validation
+
+- Added overlay tests for parent remove after descendant assign, descendant assign after parent remove, enum-kind ensure after stale descendant assign, and a root field ensure regression that keeps root variant ensures intact.
+- `cargo fmt --check`: passed
+- `cargo check -p lpc-node-registry`: passed
+- `cargo test -p lpc-model slot_mutation`: passed
+- `cargo test -p lpc-node-registry`: passed
+
+## History
+
+- 2026-05-27: opened by Codex review.
+- 2026-05-27: fixed by adding structural ancestor/descendant conflict cleanup to overlay slot upsert.
diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/03-wire-edit-contract-missing.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/03-wire-edit-contract-missing.md
new file mode 100644
index 000000000..8e7ed29de
--- /dev/null
+++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/03-wire-edit-contract-missing.md
@@ -0,0 +1,53 @@
+# Wire Edit Contract Missing
+
+- **Severity:** P1
+- **Status:** open
+- **First seen:** 2026-06-10-api-design-review.md
+- **Last reviewed:** 2026-06-10-api-design-review.md
+- **Owner:** unassigned
+
+## Finding
+
+The new registry edit model is not represented by a durable wire contract. `SlotEdit` is serde-ready locally, but the registry ingress enum mixes client intent with server-local filesystem notifications, and the existing `lpc-wire` mutation API is still the old value-leaf-only engine mutation path. That means we cannot currently write the requested "using on-wire API" registry test for add/delete node, structural def edits, asset CRUD, pending overlay, and commit.
+
+## Evidence
+
+- `lp-core/lpc-node-registry/src/registry/sync_op.rs:7` - `SyncOp` is documented as "filesystem or pending-edit CRUD", combining server-local `Fs(FsEvent)` with client-ish edit/commit operations.
+- `lp-core/lpc-node-registry/src/registry/sync_op.rs:8` - `SyncOp` derives `Clone`, `Debug`, and `PartialEq`, but not serde/schema traits for wire use.
+- `lp-core/lpc-node-registry/src/registry/sync_op.rs:10` - `SyncOp::Fs` carries `lpfs::FsEvent`, which should not be client wire vocabulary.
+- `lp-core/lpc-node-registry/src/edit_model/slot_edit.rs:6` - `SlotEdit` itself derives serde, but it is only the slot-level operation, not an edit request envelope with target, batch id, expected revision, commit/discard intent, or response semantics.
+- `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:28` - `AssetEdit` is not currently a wire/schema type.
+- `lp-core/lpc-wire/src/slot/mutation.rs:24` - `WireSlotMutationRequest` still addresses a string `root` plus `SlotPath` with shape/data CAS revisions.
+- `lp-core/lpc-wire/src/slot/mutation.rs:37` - `WireSlotMutationOp` only supports `SetValue`.
+- `lp-core/lpc-wire/src/messages/project_read/project_read_request.rs:25` - project reads still carry `Vec`, not registry edit batches.
+- `lp-core/lpc-engine/src/engine/slot_mutation.rs:19` - the current server-facing mutation path still mutates `Engine` state directly from `WireSlotMutationRequest`.
+
+## Impact
+
+This blocks using the registry as the authoritative edit layer over the wire. A client cannot express asset creation/deletion, node add/remove, structural `EnsurePresent`, overlay discard/commit, or registry commit results through the current protocol. If `SyncOp` is promoted directly, it will leak filesystem events and registry-local details onto the wire before revision, idempotency, and response semantics are decided.
+
+## Suggested Fix
+
+Define a wire-facing edit contract separately from registry internals, then add a small adapter into `NodeDefRegistry`.
+
+Suggested shape:
+
+- `ClientEditRequest { id, ops, base_revision?, commit_policy? }`
+- `ClientEditOp::UpsertSlot { artifact_path, edit: SlotEdit }`
+- `ClientEditOp::SetAsset { artifact_path, edit: AssetEdit }`
+- `ClientEditOp::RemovePending { artifact_path }`
+- `ClientEditOp::ClearPending`
+- `ClientEditOp::Commit`
+- `ClientEditResponse { id, accepted/rejected, pending_changed, committed: SyncResult?, current_revision? }`
+
+Keep `FsEvent` and filesystem sync as server-local registry API. Map wire requests to registry operations inside server code.
+
+## Validation
+
+- Add `lpc-wire` serde/schema roundtrip tests for the new edit request/response.
+- Add a registry adapter test that applies the wire request sequence and asserts the same `ArtifactOverlay`/`SyncOutcome` as direct registry calls.
+- Add a negative test proving clients cannot send filesystem events.
+
+## History
+
+- 2026-06-10: opened by Codex API design review.
diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/04-engine-cutover-story-test-missing.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/04-engine-cutover-story-test-missing.md
new file mode 100644
index 000000000..a197ab1bb
--- /dev/null
+++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/04-engine-cutover-story-test-missing.md
@@ -0,0 +1,53 @@
+# Engine Cutover Story Test Missing
+
+- **Severity:** P2
+- **Status:** open
+- **First seen:** 2026-06-10-api-design-review.md
+- **Last reviewed:** 2026-06-10-api-design-review.md
+- **Owner:** unassigned
+
+## Finding
+
+The registry has good slice tests, but no single test demonstrates the full behavior needed before engine cutover: load a root artifact, discover both inline and file-backed child nodes, apply a wire-shaped overlay with every supported mutation family, commit, reload, and prove the committed/effective graph is what the engine would consume.
+
+## Evidence
+
+- `lp-core/lpc-node-registry/tests/project_diff.rs:30` - project diff tests prove snapshot-to-overlay-to-commit equivalence, but they operate through `diff()`/`ArtifactOverlay`, not a wire-shaped edit sequence.
+- `lp-core/lpc-node-registry/tests/pending_sync.rs:21` - pending sync tests cover small `SyncOp` batches, including one slot edit plus commit, but not a complete project load/edit/reload workflow.
+- `lp-core/lpc-node-registry/tests/asset_overlay.rs:32` - asset overlay tests cover materializing pending asset create/replace/delete before commit.
+- `lp-core/lpc-node-registry/tests/slot_overlay.rs:41` - slot overlay tests cover value edits and inline child projection.
+- `lp-core/lpc-node-registry/tests/commit_promotion.rs:39` - commit tests cover slot/asset flush and inline child change details in separate scenarios.
+- `lp-core/lpc-engine/src/engine/project_loader.rs:755` - the engine still loads project TOML directly through its existing loader path.
+- `lp-core/lpc-engine/src/engine/slot_mutation.rs:19` - the engine still owns the current direct slot mutation path.
+
+## Impact
+
+Without a story test, it is hard to tell whether the registry API is actually ready for engine consumption or merely internally plausible. This is exactly the sort of broad workflow that can fail at the boundaries: node address mapping, inline child paths, child file references, asset freshness, pending overlay projection, commit results, and post-commit reload can all be individually green while still not composing into the engine cutover path.
+
+## Suggested Fix
+
+Add a focused `lpc-node-registry` integration test, for example `tests/engine_cutover_story.rs`, that uses a memory filesystem and a wire-shaped adapter once issue 03 exists.
+
+The scenario should include:
+
+- Root `project.toml` or `playlist.toml` loaded from an artifact.
+- At least one inline child def and one `ref` child def in a separate file.
+- At least one shader/fixture asset reference registered through `referenced_asset_paths`.
+- Overlay setup through the future wire-shaped API, not direct `ArtifactOverlay` construction.
+- Slot value edit via `AssignValue`.
+- Structural creation via `EnsurePresent` that adds a node/map entry.
+- Structural deletion via `Remove` that deletes a node/map entry.
+- Kind/variant change via `EnsurePresent` on a variant path.
+- Asset create, replace, and delete.
+- Commit.
+- Fresh registry reload from filesystem proving the committed artifacts are sufficient for engine loading.
+- Assertions on `SyncOutcome`, `NodeDefUpdates`, `NodeDefChangeDetail`, effective reads, materialized source, and final filesystem bytes.
+
+## Validation
+
+- `cargo test -p lpc-node-registry --test engine_cutover_story`
+- `cargo test -p lpc-node-registry`
+
+## History
+
+- 2026-06-10: opened by Codex API design review.
diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/05-sync-errors-are-lossy.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/05-sync-errors-are-lossy.md
new file mode 100644
index 000000000..867abc916
--- /dev/null
+++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/05-sync-errors-are-lossy.md
@@ -0,0 +1,49 @@
+# Sync Errors Are Lossy
+
+- **Severity:** P1
+- **Status:** open
+- **First seen:** 2026-06-10-api-design-review.md
+- **Last reviewed:** 2026-06-10-api-design-review.md
+- **Owner:** unassigned
+
+## Finding
+
+Several filesystem sync and committed-artifact refresh paths collapse errors into no-op/default behavior. That is risky for engine cutover because the engine needs to know when reload failed, which artifact failed, and whether it should keep old runtime state, mark a node error, or rebuild part of the graph.
+
+## Evidence
+
+- `lp-core/lpc-node-registry/src/registry/sync.rs:70` - `sync_fs` wraps filesystem events into `SyncOp::Fs`.
+- `lp-core/lpc-node-registry/src/registry/sync.rs:71` - `sync_fs` maps `Ok(outcome)` to committed changes.
+- `lp-core/lpc-node-registry/src/registry/sync.rs:73` - any `SyncError` becomes `SyncResult::default()`.
+- `lp-core/lpc-node-registry/src/registry/sync.rs:104` - `apply_fs_sync` ignores the result of `reconcile_artifacts()`.
+- `lp-core/lpc-node-registry/src/registry/inventory.rs:32` - `sync_def_artifact` derives the new inventory.
+- `lp-core/lpc-node-registry/src/registry/inventory.rs:35` - inventory errors are swallowed with `return`.
+- `lp-core/lpc-node-registry/src/registry/commit.rs:115` - commit refresh calls `registry.sync_def_artifact(...)`.
+- `lp-core/lpc-node-registry/src/registry/commit.rs:118` - that helper returns `Ok(())` regardless of inventory/reconcile failures inside `sync_def_artifact`.
+- `lp-core/lpc-node-registry/src/registry/effective_read.rs:113` - committed byte reads collapse any store read error to `Ok(None)`.
+
+## Impact
+
+The registry can silently report "no changes" while failing to reconcile the artifact inventory. For an engine reload path, that can leave stale runtime nodes alive, miss newly invalid definitions, skip child removal/addition, or hide broken references. Parse errors are represented as `NodeDefState::ParseError`, which is good, but structural registry failures such as duplicate definition locations, specifier resolution failures, artifact unregister failures, or missing committed bytes need explicit reporting.
+
+## Suggested Fix
+
+Make the engine-facing sync API explicit about failures.
+
+Recommended direction:
+
+- Change the engine/server path to use `Result` or `Result` instead of `sync_fs`'s lossy convenience.
+- Keep a lossy helper only if it is renamed to make the behavior obvious, such as `sync_fs_best_effort`.
+- Have `sync_def_artifact` return `Result<(), RegistryError>` and propagate inventory failures to both filesystem sync and commit.
+- Include per-artifact error details in `SyncResult` if the desired policy is "continue but report failures".
+- Treat missing committed bytes distinctly from "unknown/unregistered path" in effective reads.
+
+## Validation
+
+- Add a filesystem sync test where a child `ref` changes to an invalid specifier and assert the error is visible.
+- Add a commit test where a pending TOML edit creates an invalid referenced child and assert commit reports the failure or records a parse/error state.
+- Add a test proving `sync_fs` no longer hides `reconcile_artifacts` failure, or explicitly rename and document the lossy helper.
+
+## History
+
+- 2026-06-10: opened by Codex API design review.
diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-05-27-review.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-05-27-review.md
new file mode 100644
index 000000000..b93952588
--- /dev/null
+++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-05-27-review.md
@@ -0,0 +1,32 @@
+# Review - codex/incremental-artifact-reload
+
+## Target
+
+- **Branch/PR:** codex/incremental-artifact-reload
+- **Base:** a5064171dd09a48c22b1ac2e93a3cf6d9b106aad
+- **Head:** 8b6fac237d81a74d7f9d86cf62534d6794af4a26
+- **Review date:** 2026-05-27
+- **Reviewer:** Codex
+
+## Summary
+
+Reviewed the committed cleanup that simplifies `SlotEdit` to path-based `EnsurePresent`, `AssignValue`, and `Remove`, adds model-level `ensure_slot_present`, folds pending asset naming into `AssetEdit`, and updates registry projection/diff/application paths. The direction is good and noticeably simpler. The two correctness risks found during review have been fixed in the follow-up patch.
+
+## Findings
+
+| Issue | Severity | Status | Summary |
+| --- | --- | --- | --- |
+| [01-FIXED-root-ensure-resets-existing-kind.md](01-FIXED-root-ensure-resets-existing-kind.md) | P1 | fixed | Root variant-qualified ensure can default-reset an artifact that is already on the requested kind. |
+| [02-FIXED-structural-edits-leave-stale-descendants.md](02-FIXED-structural-edits-leave-stale-descendants.md) | P2 | fixed | Exact-path overlay upsert leaves stale descendant edits after parent structural edits. |
+
+## Validation
+
+- `cargo fmt --check`: passed
+- `cargo check -p lpc-node-registry`: passed
+- `cargo test -p lpc-model slot_mutation`: passed
+- `cargo test -p lpc-node-registry`: passed with existing dead-code warnings in shared test helpers
+
+## Notes
+
+- Over-the-wire edit schema, revisioning, and backward compatibility were not reviewed in depth because that work has not been done yet.
+- The review target was the single committed cleanup at `8b6fac237d81a74d7f9d86cf62534d6794af4a26`.
diff --git a/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-06-10-api-design-review.md b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-06-10-api-design-review.md
new file mode 100644
index 000000000..7b1c47aba
--- /dev/null
+++ b/docs/reviews/2026-05-27-codex-incremental-artifact-reload/2026-06-10-api-design-review.md
@@ -0,0 +1,106 @@
+# Review - codex/incremental-artifact-reload API Design
+
+## Target
+
+- **Branch/PR:** codex/incremental-artifact-reload
+- **Base:** origin/codex/incremental-artifact-reload (`a9c644b341554cf14e3f0767262d56e60baa8dcc`)
+- **Head:** `13f928ef76ac52dc2e4ce8a92baa62cb92f34c90`
+- **Review date:** 2026-06-10
+- **Reviewer:** Codex
+
+## Summary
+
+`lpc-node-registry` now has a solid local prototype for artifact inventory, parsed node definitions, pending overlay edits, effective reads, commit promotion, and diff-driven project changes. The current tests prove these pieces in useful slices. They do **not** yet prove the whole engine cutover workflow, mostly because the new registry edit model has no real wire contract and the current `lpc-wire` mutation API is still the old value-leaf-only engine mutation path.
+
+The short answer to "can we show the full functionality in a test today?" is **not yet**. We can show most underlying mechanics with direct registry APIs, but not the requested "using on-wire API" story.
+
+## Findings
+
+| Issue | Severity | Status | Summary |
+| --- | --- | --- | --- |
+| [03-wire-edit-contract-missing.md](03-wire-edit-contract-missing.md) | P1 | open | New registry edits have no durable wire request/response contract; current wire mutation only supports value leaves. |
+| [04-engine-cutover-story-test-missing.md](04-engine-cutover-story-test-missing.md) | P2 | open | Existing tests cover slices, but no single test proves the full root-load, child-discovery, edit, asset CRUD, commit, reload path needed for engine cutover. |
+| [05-sync-errors-are-lossy.md](05-sync-errors-are-lossy.md) | P1 | open | Filesystem sync and committed refresh paths can swallow registry errors and report no-op/default results. |
+
+## What Works Now
+
+| Capability | Current state | Evidence |
+| --- | --- | --- |
+| Root artifact load | Implemented through `NodeDefRegistry::load_root`; root path must be absolute, root artifact is registered, reachable children/assets are registered. | `lp-core/lpc-node-registry/src/registry/load.rs:14`, `lp-core/lpc-node-registry/src/registry/load.rs:30`, `lp-core/lpc-node-registry/src/registry/load.rs:33` |
+| Inline child discovery | Implemented via model-level `NodeDef::invocation_sites` and registry recursive registration. | `lp-core/lpc-model/src/nodes/node_def.rs:217`, `lp-core/lpc-node-registry/src/registry/load.rs:65`, `lp-core/lpc-node-registry/src/registry/load.rs:90` |
+| File-backed child discovery | Implemented for `NodeInvocation::Ref`, resolving relative artifact specifiers and registering child artifact roots. | `lp-core/lpc-node-registry/src/registry/load.rs:68`, `lp-core/lpc-node-registry/src/registry/load.rs:72`, `lp-core/lpc-node-registry/src/registry/load.rs:78` |
+| Asset discovery | Implemented for shader, compute shader, and fixture refs through `NodeDef::referenced_asset_paths`. | `lp-core/lpc-model/src/nodes/node_def.rs:246`, `lp-core/lpc-node-registry/src/registry/inventory.rs:280` |
+| Pending overlay | Implemented as `ArtifactOverlay`, keyed by `ArtifactLoc`, with current pending slot edits or one pending asset edit per artifact. | `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:12`, `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:18`, `lp-core/lpc-node-registry/src/edit_model/artifact_overlay.rs:74` |
+| Slot edit language | Simplified to `EnsurePresent`, `AssignValue`, and `Remove`. | `lp-core/lpc-node-registry/src/edit_model/slot_edit.rs:8` |
+| Auto-create/defaulting | `AssignValue` first calls `apply_ensure_present`; `EnsurePresent` delegates to model slot mutation helpers. | `lp-core/lpc-node-registry/src/edit_apply/slot_edit_apply.rs:53`, `lp-core/lpc-node-registry/src/edit_apply/slot_edit_apply.rs:56`, `lp-core/lpc-node-registry/src/edit_apply/slot_edit_apply.rs:97` |
+| Effective reads | Implemented through `NodeDefView`, `effective_state`, `read_effective_bytes`, and `materialize_source`. | `lp-core/lpc-node-registry/src/registry/effective_read.rs:19`, `lp-core/lpc-node-registry/src/registry/effective_read.rs:61`, `lp-core/lpc-node-registry/src/registry/effective_read.rs:80`, `lp-core/lpc-node-registry/src/registry/effective_read.rs:85` |
+| Commit | Implemented: writes pending bytes/deletes to fs, refreshes affected defs, reconciles artifacts, clears overlay. | `lp-core/lpc-node-registry/src/registry/commit.rs:17`, `lp-core/lpc-node-registry/src/registry/commit.rs:38`, `lp-core/lpc-node-registry/src/registry/commit.rs:44`, `lp-core/lpc-node-registry/src/registry/commit.rs:82` |
+| Diff to overlay | Implemented under the `diff` feature for snapshot equivalence tests. | `lp-core/lpc-node-registry/src/diff/project_diff.rs:17` |
+
+## Test Coverage Map
+
+| Needed behavior | Covered today? | Notes |
+| --- | --- | --- |
+| Load root node from artifact | Yes | `project_diff.rs:a1_roundtrip_load_root_after_commit`; registry unit tests also cover root load. |
+| Load inline child node | Yes | `slot_overlay.rs:c2_inline_child_slot_patch_visible_in_view`; `commit_promotion.rs:c2_inline_child_changed_after_commit`. |
+| Load referenced child node from separate file | Partial/yes | `fs_change_semantics.rs:s5b_path_child_parse_error_reports_entered_error` creates a ref child and syncs errors. No combined story test. |
+| Register referenced shader/fixture assets | Yes | `fs_change_semantics.rs:s2_glsl_edit_only_bumps_artifact_store_revision` and `s3_svg_edit_only_bumps_artifact_store_revision`. |
+| Set up overlay | Yes, local API | `overlay_lifecycle.rs` and `pending_sync.rs`; not wire API. |
+| Value leaf edit | Yes | `slot_overlay.rs:c1_setslot_patches_clock_rate_in_view`, `commit_promotion.rs:d2_commit_updates_committed_and_clears_overlay`. |
+| Structural create / add node | Partial | `EnsurePresent` exists and diff can create artifacts, but no explicit node-add story test using a wire-like API. |
+| Structural delete / delete node | Partial | `Remove` exists and overlay conflict cleanup is tested, but no explicit node-delete story test using a wire-like API. |
+| Kind/variant change | Partial | Filesystem kind change is tested; slot-driven kind/variant change is not covered in a full workflow. |
+| Asset create | Yes pre-commit | `asset_overlay.rs:c4a_add_asset_via_overlay_implicit_create`; commit path is less explicit outside diff tests. |
+| Asset replace | Yes | `asset_overlay.rs:c4c_replace_glsl_via_overlay_def_unchanged`, `commit_promotion.rs:d2_commit_setbytes_updates_committed`. |
+| Asset delete | Yes pre-commit | `asset_overlay.rs:c4b_delete_asset_via_overlay`; commit-delete story should be explicit. |
+| Commit changes | Yes | `commit_promotion.rs` and `pending_sync.rs`. |
+| Reload committed state as engine would | Partial | `project_diff.rs:a1_roundtrip_load_root_after_commit` reloads after diff commit, but not after all mutation families. |
+| On-wire API | No | Current `lpc-wire` mutation is still `WireSlotMutationRequest` + `SetValue`; registry `SyncOp` is local and not serde/schema-ready. |
+
+## Current Terms
+
+- **Artifact**: a file-like authored thing tracked by the registry, usually `.toml`, `.glsl`, `.svg`, or similar.
+- **Asset**: non-def artifact body edited as bytes/text, such as shader source or fixture mapping. Registry code now generally uses `AssetEdit` for this.
+- **Source**: still valid when it means authored shader/source text (`SourceFileSlot`, `materialize_source`). Avoid using it for generic artifact bookkeeping.
+- **ArtifactLoc**: registry identity for an artifact, currently path-backed for these workflows.
+- **ArtifactStore**: registry-owned catalog of artifact locations, revisions, and transient read state.
+- **NodeDefLocation**: identity of a parsed node definition: artifact location plus `SlotPath` inside that artifact. Root defs use the root path; inline defs use child invocation paths.
+- **NodeDefEntry**: current parsed registry entry: `NodeDefLocation`, `NodeDefState`, and revision.
+- **NodeDefState**: either a loaded `NodeDef` or a parse/error placeholder.
+- **NodeDefRegistry**: owner of committed parsed defs plus pending overlay; the main local API for load, sync, effective read, and commit.
+- **ArtifactOverlay**: pending current-state map keyed by `ArtifactLoc`. It is not an append-only edit log.
+- **ArtifactEdits**: one artifact's pending state: ordered slot edits or one asset edit.
+- **SlotEdit**: structural/value slot edit within a `.toml` artifact: `EnsurePresent`, `AssignValue`, or `Remove`.
+- **AssetEdit**: asset body operation: `None`, `ReplaceBody(Vec)`, or `Delete`.
+- **SyncOp**: local registry ingress enum mixing filesystem events, pending edits, remove/clear, and commit. This is not a settled wire type.
+- **SyncOutcome**: result of processing `SyncOp`s: committed changes plus a pending-changed bit.
+- **SyncResult**: factual committed registry changes: `NodeDefUpdates` plus `NodeDefChangeDetail`s.
+- **NodeDefUpdates**: added/changed/removed def locations.
+- **NodeDefChangeDetail**: coarse change classification: content, kind changed, entered error, left error.
+- **NodeDefView**: read-only effective projection over registry committed state plus overlay.
+- **ProjectSnapshot / diff**: host/test harness tools that compute an `ArtifactOverlay` between filesystem snapshots.
+
+## Half-Baked Or Still Moving
+
+- **Wire vocabulary**: old `WireSlotMutation` still exists; new registry edits are not exposed as `lpc-wire` request/response types.
+- **Revision/concurrency**: registry pending edits do not yet carry client ids, base revisions, conflict policy, or idempotency semantics.
+- **`SyncOp` naming/boundary**: useful local test API, but too broad for wire because it includes `FsEvent`.
+- **Error policy**: parse errors are modeled, but some sync/inventory/reconcile errors are swallowed.
+- **Engine identity mapping**: the registry uses artifact path plus `SlotPath`; the engine/UI still often think in `node..def` roots.
+- **Full generic topology**: `NodeDef::invocation_sites` is centralized in `lpc-model`, but it is still explicit for `Project` and `Playlist`, not a fully generic slot-shape walker.
+- **Compatibility facade**: `edit` re-exports `edit_model`/`edit_apply` for stability; this is fine temporarily but should not become the conceptual home.
+- **Commit helper naming**: internal names like `commit_slot_overlay` still carry older slot-overlay vocabulary.
+- **Source/asset wording**: authored shader source APIs should stay named source; registry bookkeeping should continue moving toward artifact/asset/definition location.
+
+## Suggested Next Step
+
+Do not cut the engine over yet. First, define the wire edit contract and add the engine-cutover story test against a registry adapter. Once that test is green locally, the engine cutover has a concrete target instead of a moving API.
+
+## Validation
+
+- `cargo test -p lpc-node-registry`: passed with existing dead-code warnings in shared integration-test helpers.
+
+## Notes
+
+- The worktree had pre-existing local changes before this review: `artifact_location.rs` is renamed to `artifact_loc.rs`, and `artifact/mod.rs` points at the new module name. This review did not modify those code changes.
+- Existing issue files `01` and `02` remain fixed and were not reopened.
diff --git a/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md b/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md
index 58c457750..ba189b4b0 100644
--- a/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md
+++ b/docs/roadmaps-old/2026-05-16-slot-codec-serde-removal/m4-remove-serde-abandoned/00-notes.md
@@ -159,7 +159,7 @@ Examples:
- `value/value_path.rs`
- `project/config.rs`
- `server/server_config.rs`
-- `artifact/artifact_loc.rs`
+- `artifact/artifact_specifier.rs`
Some of these are not slot-authored node definitions but still live in
`lpc-model`. Each needs a decision: convert to slot/value codecs, move serde
diff --git a/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md b/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md
index 698910179..50125faa4 100644
--- a/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md
+++ b/docs/roadmaps/2026-05-06-slot-domain-cutover/m1.2-authored-slot-serde-mockup-pressure/00-notes.md
@@ -69,7 +69,7 @@ Out of scope:
- Semantic slots wrap `Versioned` and expose metadata/access, but do not yet
deserialize from authored strings/scalars/records.
- `SourcePathSlot` and `ArtifactPathSlot` currently store `String`; real source
- uses `LpPathBuf` and `ArtifactLocator`, so the mockup can either stay stringy
+ uses `LpPathBuf` and `ArtifactSpecifier`, so the mockup can either stay stringy
for M1.2 or pressure more precise semantic wrappers.
### Mockup Source Model
@@ -92,7 +92,7 @@ Out of scope:
Real `lpc-source` remains plain serde structs:
- `ProjectDef.nodes: BTreeMap`
-- `NodeInvocation.artifact: ArtifactLocator`
+- `NodeInvocation.artifact: ArtifactSpecifier`
- `TextureDef.width/height`
- `ShaderDef.glsl_path: LpPathBuf`
- `ShaderDef.texture_loc: RelativeNodeRef`
@@ -142,7 +142,7 @@ the mockup or add more precise wrappers before M2.
Suggested direction: keep mockup path slots string-backed for the first serde
slice, but add tests that make the authored format explicit. Add a future note
or M2 task to decide whether real source needs `LpPathBufSlot` and
-`ArtifactLocatorSlot`.
+`ArtifactSpecifierSlot`.
### Arrays Versus Stable-Key Maps
diff --git a/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md b/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md
index 3125b57e0..df3cb4038 100644
--- a/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md
+++ b/docs/roadmaps/2026-05-06-slot-domain-cutover/m2-source-def-slot-roots/00-notes.md
@@ -132,7 +132,7 @@ Out of scope:
- `examples/basic/project.toml` also contains `uid = "basic"`, which is
currently ignored because `ProjectDef` has no `uid` field.
- `NodeInvocation`:
- - fields: `artifact: ArtifactLocator`, `overrides: Vec<(ValuePath, SrcBinding)>`
+ - fields: `artifact: ArtifactSpecifier`, `overrides: Vec<(ValuePath, SrcBinding)>`
- overrides are transitional and still use `ValuePath` because resolver/binding code has not moved fully to slots.
- `TextureDef`:
- fields: `width: u32`, `height: u32`
@@ -271,9 +271,9 @@ Decision: do not add arrays and do not add custom serde that hides arrays behind
Implication: `examples/basic` can change from TOML arrays to keyed path tables if M2 chooses the structured mapping path.
-### Artifact Locator And Path Leaves
+### Artifact Specifier And Path Leaves
-`ArtifactLocator`, `LpPathBuf`, and `RelativeNodeRef` are authored string-like values with different semantics.
+`ArtifactSpecifier`, `LpPathBuf`, and `RelativeNodeRef` are authored string-like values with different semantics.
Suggested direction: add or reuse semantic leaf shapes/conversions:
@@ -281,7 +281,7 @@ Suggested direction: add or reuse semantic leaf shapes/conversions:
- `LpPathBuf` / GLSL path -> `source_path_shape()` or a more precise
`LpPathBuf`-backed slot if we decide source defs should retain that concrete
type.
-- `ArtifactLocator` -> `artifact_path_shape()` or a more specific artifact
+- `ArtifactSpecifier` -> `artifact_path_shape()` or a more specific artifact
locator field/access implementation in `lpc-source`.
## Open Questions
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/decisions.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/decisions.md
new file mode 100644
index 000000000..1e77d6234
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/decisions.md
@@ -0,0 +1,163 @@
+# Artifact-Routed File Reload — Decisions
+
+#### Parallel build in lpc-node-registry until M6
+
+- **Decision:** M1–**M4** build the new system in `lpc-node-registry` alongside
+ the existing `lpc-engine` path. **ChangeSet** is a
+ [promoted roadmap](../2026-05-21-changeset-change-management/overview.md).
+ **No `lpc-engine` edits until M6.**
+- **Why:** Prove fs-change and projection semantics in isolation; keep the app
+ working on the old stack during development.
+- **Rejected alternatives:** In-place refactor of `lpc-engine` from M1; dual
+ models in one loader before harness gate.
+- **Revisit when:** M6 cutover (mandatory after M4 + ChangeSet M6 gate).
+
+#### ChangeSet before engine cutover
+
+- **Decision:** **ChangeSet** change management is proven in the promoted roadmap
+ [`2026-05-21-changeset-change-management`](../2026-05-21-changeset-change-management/overview.md)
+ before **M6** engine cutover here. Client edits are ordered, id'd, in-memory
+ until **commit**; express node def slot patches and asset add/replace/delete.
+- **Why:** Client-driven edits are as critical as fs-reload; cutover without
+ this shape repeats overlay work.
+- **Rejected alternatives:** Minimal projection only (old M4.1); defer ChangeSet
+ until after M6; keep as nested M5 plan only.
+- **Revisit when:** Wire protocol and CRDT merge (ChangeSet roadmap `future.md`).
+
+#### ChangeSet as test and diff vocabulary
+
+- **Decision:** ChangeSet ops are the **canonical edit vocabulary** for client
+ UI, future **project diff**, and **incremental stress replay** — not a
+ separate ad-hoc mutation path for tests.
+- **Why:** One representation supports compose/morph user stories, wire
+ messages, and high-level tests decomposed into long op streams for host/emu/device.
+- **Rejected alternatives:** Filesystem-only test setup; whole-`reload()` per
+ scenario on embedded targets.
+- **Revisit when:** Project diff tooling (`future.md`); post-M6 replay harness.
+
+#### Asset vs artifact naming
+
+- **Decision:** **Asset** = non-node dependency file (GLSL, SVG, …).
+ **Artifact** = store identity / freshness entry (any file path). ChangeSet
+ carries **asset** ops; commit bumps **artifact** store + registry.
+- **Why:** Clear vocabulary for node TOMLs vs dependency files.
+- **Resolved:** Single ChangeSet stream with `NodeChange` / `AssetChange`
+ variants; see [ChangeSet roadmap](../2026-05-21-changeset-change-management/decisions.md).
+
+#### lpc-node-registry crate; retire lpc-slot-mockup
+
+- **Decision:** New **`lpc-node-registry`** crate (`no_std` + alloc); delete
+ **`lpc-slot-mockup`** at M1 start.
+- **Why:** Clean namespace; fast isolated tests; avoid lpvm-heavy `lpc-engine`
+ churn before semantics are proven.
+- **Rejected alternatives:** Build directly in `lpc-engine`; keep mockup.
+- **Revisit when:** Unlikely — crate boundary matches domain.
+
+#### ArtifactStore vs NodeDefRegistry split
+
+- **Decision:** `ArtifactStore` tracks source freshness only; `NodeDefRegistry`
+ owns parsed `NodeDef` entries keyed by `NodeDefId`.
+- **Why:** Artifacts are sources; defs are derived. Conflating them blocked
+ file-only reload (GLSL/SVG) and overlay projection.
+- **Rejected alternatives:** Generic artifact payload enum; incremental facade
+ over current store.
+- **Revisit when:** Never for this boundary — extend with `BinaryFileSlot` etc.
+
+#### NodeDefUpdates drives reload
+
+- **Decision:** Registry **`sync`** returns `NodeDefUpdates`
+ `{ added, changed, removed }`; driver applies **`apply_fs_changes`** to
+ `ArtifactStore` first; engine applies lifecycle from report.
+- **Why:** Bounded, testable unit between fs events and node tree mutation.
+ Driver owns fs + store; registry owns parse + diff.
+- **Rejected alternatives:** Engine re-parses directly; whole `Project::reload()`;
+ registry calling `apply_fs_changes` internally.
+
+#### Registry bootstrap via load_root
+
+- **Decision:** **`load_root(absolute_path)`** is the single public bootstrap
+ entry. Root may be any node-definition TOML kind; `project.toml` is convention.
+ Path-backed child registration is private (walk recursion).
+- **Why:** Matches engine/test driver model: init once, then fs loop →
+ `NodeDefUpdates`. M5 ChangeSet commit uses same `sync` output shape.
+- **Rejected alternatives:** Public `register_file` per artifact; requiring
+ `NodeDef::Project` at root.
+
+#### Prove semantics before cutover (M4 + ChangeSet gate)
+
+- **Decision:** No production cutover until **M4** (fs-change) here and
+ **ChangeSet roadmap M6** (diff + equivalence gate) pass.
+- **Why:** Both reload and client edit paths must be proven in parallel stack.
+- **Rejected alternatives:** Cutover after M4 only.
+
+#### SourceFileSlot + SourceFileRef (not node SourceRef)
+
+- **Decision:** Authored `SourceFileSlot` with `$path` / extension-key inline
+ TOML; resolved `SourceFileRef` in slot data; materialize via context.
+- **Why:** File vs inline is a slot concern; nodes read resolved values like
+ other slots; no big data in slot values.
+- **Rejected alternatives:** `ShaderSource` enum; runtime `SourceRef` on nodes.
+- **Revisit when:** `BinaryFileSlot` for byte payloads.
+
+#### Error propagation, no last-good (v1)
+
+- **Decision:** Parse/load/validation errors put defs/artifacts in error state;
+ destroy dependent nodes; cascade parent errors.
+- **Why:** Simpler semantics and tests; avoids stale runtime after bad edits.
+- **Rejected alternatives:** Last-good def + last-good compiled shader on failure.
+- **Revisit when:** Live editing UX demands retaining last-good visuals.
+
+#### DefView is the sole read path
+
+- **Decision:** Nodes read through **`NodeDefView`** (base registry + active
+ **ChangeSet** projection). Proven in [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md) before M6.
+- **Why:** All client edits flow through ChangeSet; commit promotes to base.
+- **Rejected alternatives:** Direct registry mutation from wire; overlay only in
+ UI mockup branch.
+
+#### Inline def change does not imply parent changed
+
+- **Decision:** `NodeDefUpdates` reports def-level deltas; inline child edit
+ marks child `changed`, not parent, unless parent payload changed.
+- **Why:** Avoid unnecessary parent/node refresh on nested edits.
+- **Rejected alternatives:** Mark entire artifact subtree changed on any edit.
+
+#### Kind change requires node delete/recreate
+
+- **Decision:** When a bound def's **`NodeKind`** changes, the engine **deletes
+ and recreates** the runtime node — no in-place slot refresh. Registry still
+ reports the `NodeDefId` in **`changed`**; shell stubs at invocation sites
+ include kind so parent containers also **`changed`** when an inline child's
+ kind flips.
+- **Why:** Node kind determines runtime type, wiring, and lifecycle; treating
+ kind change as a content patch would leave stale node state.
+- **Rejected alternatives:** In-place refresh on kind change; separate
+ `removed`+`added` ids for kind flips in M2.
+- **Revisit when:** Stable `NodeDefId` preservation across kind morph (future).
+
+#### project.toml graph reconciliation deferred (M8)
+
+- **Decision:** Leaf file + source file reload first; topology/wiring changes
+ are a separate milestone after server wire-up.
+- **Why:** Graph mutation is distinct from def payload updates; large scope.
+- **Rejected alternatives:** Full graph diff in first reload slice.
+
+#### Explicit Project::reload retained
+
+- **Decision:** User-initiated full reload keeps drop-and-rebuild; fs watcher
+ uses incremental path only.
+- **Why:** Escape hatch for unsupported edits and debugging.
+- **Revisit when:** Unlikely.
+
+#### Slot provenance via ExplainSlot probe (M10)
+
+- **Decision:** Provenance is **on-demand**, not on tick/registry read paths.
+ Client attaches `ExplainSlot` probe to `project_read` (wire types in
+ `lpc-wire`; engine stub today) or re-derives locally on host when it holds
+ bindings + ChangeSet.
+- **Why:** ESP32 memory; values-only hot path; M3.5 resolver trace seeds
+ explain output.
+- **Rejected alternatives:** Provenance on every `Production`; registry
+ `explain_slot()` in v1; dedicated `lpa-server` provenance logic.
+- **Revisit when:** Thin remote clients without local edit context.
+- **Tracked:** [M10](m10-slot-provenance-client.md).
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/future.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/future.md
new file mode 100644
index 000000000..eadee74f1
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/future.md
@@ -0,0 +1,75 @@
+# Future Work — Artifact-Routed Reload
+
+## Slot resolution probes (ExplainSlot)
+
+- **Tracked as [M10](m10-slot-provenance-client.md)** — post-M6 milestone.
+- **Idea:** Provenance is **on-demand**, not on every read. Client attaches an
+ `ExplainSlot` probe to `project_read` (wire types exist; engine stub today) or
+ re-derives locally on host when it holds bindings + ChangeSet.
+- **Cascade:** binding / merge → effective registry def (overlay ∪ base) →
+ produced-slot fallback; optional `include_trace` for resolver steps.
+- **Why not on tick read path:** ESP32 memory; values-only on hot path.
+- **Why not lpa-server logic v1:** Server forwards `project_read` probes; engine
+ executes explain.
+- **Revisit when:** Thin remote clients without local edit context; registry
+ `explain_slot` helper.
+
+## Project diff → ChangeSet stream
+
+- **Implemented in [ChangeSet M6](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md)** — gates parent engine cutover.
+- Post-M6: extend diff to arbitrary `examples/*` pairs for stress replay.
+
+## ChangeSet replay stress harness (host / emu / device)
+
+- **Idea:** Record or generate a ChangeSet log; replay through full engine
+ (post-M6) with configurable granularity (batch commit vs per-op apply). One
+ high-level test name (`empty → fyeah-sign`) drives hundreds/thousands of
+ incremental mutations.
+- **Why not now:** Requires M6 engine on ChangeSet path; ChangeSet roadmap
+ proves registry harness first.
+- **What it catches:** Panics on partial graph states, OOM spikes on ESP32,
+ allocator fragmentation from repeated compile/prepare cycles, refcount leaks
+ on artifact bump — failures whole-reload tests rarely trigger.
+- **Useful context:** Same log runs on `cargo test`, `fw-emu`, and on-device CI
+ when available; compare heap high-water marks across targets.
+
+## Binary file sources (`BinaryFileSlot`)
+
+- **Idea:** Sibling to `SourceFileSlot` for byte payloads (textures, binary blobs) with the same file-or-inline authored shape and engine-side artifact registration + resolution.
+- **Why not now:** This roadmap covers text sources (GLSL, SVG) and node TOML reload; no current node def field needs binary file-or-inline yet.
+- **Useful context:** Same TOML encoding as `SourceFileSlot` (`$path` or extension key); inline values are base64. See roadmap `notes.md` § SourceFileSlot TOML encoding.
+
+## Last-good state on reload failure
+
+- **Idea:** Retain last-good parsed defs / compiled shaders when a hot reload fails (e.g. bad GLSL edit).
+- **Why not now:** v1 propagates errors — def error state destroys dependent nodes; parents cascade to error. Simpler semantics, easier to test.
+- **Useful context:** Roadmap `notes.md` § Error semantics.
+
+## `project.toml` / graph reconciliation
+
+- **Idea:** Engine applies tree add/remove/repoint when parent invocation maps
+ change; registry reports def-level `NodeDefUpdates`. Incremental add/remove
+ of top-level nodes when root project artifact changes.
+- **Why not now:** Leaf node TOML + source file reload covers most edit loops;
+ requires def-vs-child-def vs wiring distinction solid first (M8).
+- **Useful context:** `artifact_nodes` inverse index; `Engine` tree mutation
+ APIs; roadmap `notes.md` Q1.
+
+## ChangeSet wire protocol + CRDT merge
+
+- **Idea:** Full `lpc-wire` ChangeSet messages; concurrent edit merge.
+- **Why not now:** ChangeSet roadmap proves in-memory ordered ChangeSet +
+ commit/discard in harness.
+- **Useful context:** [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md);
+ `lightplayer-app-ui` overlay mockup for SlotOp reference.
+
+## Artifact digest / unchanged-write filtering
+
+- **Idea:** Cheap stat/digest to avoid bumping `content_frame` on no-op writes.
+- **Why not now:** Filesystem change events as version bumps are sufficient for first pass; ESP32 cost of retaining or hashing full sources is undesirable until proven necessary.
+- **Useful context:** Plan notes in original `docs/plans/2026-05-21-artifact-routed-file-reload/00-notes.md`.
+
+## Library artifact specifiers
+
+- **Idea:** `ArtifactSpecifier::Lib(...)` resolved through a library namespace.
+- **Why not now:** File-backed reload first; `ArtifactLocation::try_from_src_spec` already rejects lib locators.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store.md
new file mode 100644
index 000000000..edc2b134e
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store.md
@@ -0,0 +1,132 @@
+# Milestone 1: `lpc-node-registry` + ArtifactStore
+
+## Title And Goal
+
+Bootstrap **`lpc-node-registry`** and implement a **freshness-only
+`ArtifactStore`**: file identity, monotonic version, and load error state — **no
+parsed payloads, no cached file bytes**.
+
+This milestone establishes the new crate and the bottom layer of the target
+stack. Everything above (parsed defs, ChangeSets, engine) builds on it.
+
+## Parallel Build
+
+**M1 does not modify `lpc-engine`.** Production keeps the old
+`lpc-engine/src/artifact/` path (NodeDef payloads, `InlineNode`, etc.) until
+**M6**. New types live in `lpc-node-registry` with clear module boundaries.
+
+## Relationship To M2 (split vs combined)
+
+**Recommendation: keep M1 and M2 as separate milestones**, implement back-to-back
+(often one plan folder with phase 1 / phase 2).
+
+| | M1 (this milestone) | M2 |
+|---|---------------------|-----|
+| **Question answered** | "Which files exist, what version are they, did read fail?" | "What `NodeDef`s derive from those files?" |
+| **Owns** | Crate bootstrap, `ArtifactStore`, `FsChange` → bump | `NodeDefRegistry`, `NodeDefUpdates`, parse hook |
+| **Testable alone?** | Yes — acquire, bump, error state, no parser | Needs M1 store feeding artifact changes |
+| **Gate** | `cargo test -p lpc-node-registry` artifact tests green | Registry update tests green |
+
+Combining into one milestone is workable if you prefer a single PR, but the **M1
+done gate** should still be: store complete and tested **before** registry work
+starts — otherwise artifact semantics get buried in parse/diff noise.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/`
+
+## Scope
+
+### Crate bootstrap (first deliverable)
+
+- **Delete `lp-core/lpc-slot-mockup`** — remove crate and all workspace /
+ `default-members` / clippy exclusion references (not in CI `justfile`; defunct
+ slot-pressure harness).
+- **Create `lp-core/lpc-node-registry`**:
+ - `#![no_std]` + `alloc`
+ - Deps (initial): `lpc-model`, `lpfs`, `lpc-shared` (paths/revisions as needed)
+ - Layout: `src/lib.rs`, `src/artifact/` (M1); stub modules or `mod` declarations
+ for `registry/`, `source/`, `change/`, `view/` (filled in later milestones)
+ - `cargo test -p lpc-node-registry` runs in host CI (`just check` path)
+
+### ArtifactStore (freshness-only)
+
+Track **file artifacts callers acquire** — node TOMLs, GLSL, SVG, etc. — as
+**`ArtifactLocation::File(path)`** entries. v1 has **no `InlineNode`**
+location type (inline defs are registry paths in M2, not separate artifact
+locations).
+
+Per entry:
+
+- **`ArtifactId`** — opaque handle (same pattern as today; may reuse type from
+ `lpc-model` or define in this crate).
+- **Identity** — path + stable id even when load/read fails.
+- **`revision`** — monotonic bump on invalidation (fs change,
+ simulated bump in tests). No content hash/digest in v1. (Not `content_frame`.)
+- **Requester ownership** — entries exist because a caller **acquired** them;
+ **`release`** at refcount zero removes the entry. Fs changes do not register
+ artifacts.
+- **State** — `ArtifactReadState` (`Unread` / `ReadOk` / `Failed(ArtifactReadFailure)`);
+ failures: `Deleted`, `NotFound`, `Io`, `InvalidPath`.
+
+API surface (conceptual):
+
+- `acquire_specifier` / `acquire_location` → `ArtifactId` (always entry unless resolution fails)
+- `release(id)`
+- `revision(id) → Revision`
+- `apply_fs_changes(&[FsChange], frame)` — bumps **held** entries only
+- `read_error` via `read_state` on entry
+- Optional **`read_bytes(id, fs) → Result<..., ArtifactError>`** for **transient**
+ reads during tests / later registry parse — bytes not stored on the entry
+
+### Tests (M1 gate)
+
+Prove the store **without** `NodeDefRegistry` or TOML parsing:
+
+- Acquire same path twice → same `ArtifactId`.
+- Simulated `FsChange` on path → `revision` bumps.
+- Unrelated path change → other entries unchanged.
+- Read failure → entry stays registered, error state set, version semantics defined.
+- No long-lived payload after read helper returns (if read helper exists in M1).
+
+Out of scope:
+
+- **`NodeDefRegistry`**, **`NodeDefUpdates`**, inline def paths (**M2**).
+- TOML / `NodeDef` parse integration (**M2**).
+- `SourceFileSlot` / `SourceFileRef` (**M3**).
+- Reload harness, expected engine actions (**M4**).
+- ChangeSet (**M5**).
+- Any `lpc-engine` / `ProjectLoader` edits (**M6**).
+
+## Key Decisions
+
+- **M1 = new home + artifact freshness layer** — not "a refactor of engine
+ ArtifactStore in place."
+- **Metadata only** — path + version + error; lazy transient read at parse/prepare time.
+- **Fs event = version bump** — no byte-level diff in v1.
+- **All file types equal** — GLSL and SVG are artifacts from M1, not special cases
+ added later.
+- **Parallel crate only** — zero `lpc-engine` churn this milestone.
+
+## Deliverables
+
+- `lp-core/lpc-node-registry/` crate in workspace, CI-clean.
+- `lpc-slot-mockup` removed.
+- `src/artifact/` — store, id, location, state, fs-change bump.
+- Unit tests satisfying M1 gate above.
+
+## Dependencies
+
+- None (first milestone).
+
+## Execution Strategy
+
+Full plan written — see `m1-artifact-store/` (`00-design.md`, phases `01`–`05`).
+
+Dispatch: **`composer-2.5-fast`** default; phase 01 uses **`composer-2-fast`**
+(bootstrap only). Implement via `/implement`; single commit at end.
+
+Suggested chat opener:
+
+> M1 plan is ready in `m1-artifact-store/`. Run `/implement` to dispatch phases
+> 01–05. Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-design.md
new file mode 100644
index 000000000..9dac0c283
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-design.md
@@ -0,0 +1,186 @@
+# M1 Design — `lpc-node-registry` + ArtifactStore
+
+## Scope
+
+Bootstrap `lpc-node-registry` and implement a **freshness-only**, **requester-owned**
+`ArtifactStore`. Entries exist because a caller acquired them; the filesystem
+only **invalidates** (bumps `revision`) entries that are already held.
+
+## Ownership model
+
+```
+Engine (M6) ──acquire──► NodeDefRegistry (M2) ──acquire──► ArtifactStore
+ │ │
+ │ release when done │
+ └──────────────────────────────┘
+```
+
+- **Acquire** resolves an authored specifier to `ArtifactLocation::File(path)`,
+ creates or reuses the entry, increments **refcount**, returns **`ArtifactId`**.
+- **Release** decrements refcount; at **zero**, entry is **removed** from the store.
+- **Fs changes** do not create entries. They only affect paths that already have
+ an acquired entry in the store.
+- **Bad locator** (e.g. `lib:…` unsupported) → **`ArtifactError::Resolution`**
+ at acquire time — no entry created.
+
+This matches production engine refcount semantics without retaining `NodeDef`
+payloads.
+
+## File structure
+
+```
+lp-core/lpc-node-registry/
+├── Cargo.toml
+└── src/
+ ├── lib.rs # crate root, re-exports
+ ├── artifact/
+ │ ├── mod.rs
+ │ ├── artifact_id.rs # opaque ArtifactId
+ │ ├── artifact_location.rs # File(LpPathBuf) only; try_from_specifier
+ │ ├── artifact_error.rs
+ │ ├── artifact_read_state.rs # Unread | ReadOk | Failed(ArtifactReadFailure)
+ │ ├── artifact_entry.rs
+ │ └── artifact_store.rs # acquire, release, apply_fs_changes, read_bytes
+ ├── registry/mod.rs # stub — M2
+ ├── source/mod.rs # stub — M3
+ ├── change/mod.rs # stub — M5
+ └── view/mod.rs # stub — M5
+```
+
+Delete: `lp-core/lpc-slot-mockup/` and workspace member entry.
+
+## Conceptual architecture
+
+```
+ acquire(locator) / release(id)
+ │
+┌──────────────┐ ┌─────────▼─────────┐ apply_fs_changes([FsChange])
+│ Caller │───►│ ArtifactStore │◄──────────────────────────────
+│ (tests/M2) │ │ │
+└──────────────┘ │ by_handle │
+ │ location→handle │
+ └─────────┬─────────┘
+ │ read_bytes(id, &LpFs) [transient]
+ ▼
+ Vec dropped; entry keeps
+ ReadOk / ReadError only
+```
+
+### ArtifactEntry (freshness-only)
+
+| Field | Type | Role |
+|-------|------|------|
+| `id` | `ArtifactId` | Opaque handle |
+| `location` | `ArtifactLocation` | Resolved `File(path)` |
+| `refcount` | `u32` | Requester ownership |
+| `revision` | `Revision` | Monotonic content generation |
+| `read_state` | `ArtifactReadState` | Last read / fs-notify outcome (see below) |
+
+No `NodeDef`, no `Vec` on the entry.
+
+### Read state (structured failures)
+
+```rust
+enum ArtifactReadState {
+ Unread,
+ ReadOk,
+ Failed(ArtifactReadFailure),
+}
+
+enum ArtifactReadFailure {
+ /// FsChange::Delete while entry held — watcher-sourced; no read required.
+ Deleted,
+ /// read_bytes: file not on disk (never existed, or gone before notify).
+ NotFound,
+ /// read_bytes: FsError::Filesystem or host I/O.
+ Io { message: String },
+ /// read_bytes: FsError::InvalidPath.
+ InvalidPath { message: String },
+}
+```
+
+Acquire-time locator errors stay in `ArtifactError::Resolution` — not mixed into
+`read_state`.
+
+### Revision bumps
+
+| Event | Effect on matching acquired entry |
+|-------|-------------------------------------|
+| `FsChange::Modify` | `revision = frame` (or `revision.next()`), `read_state = Unread` |
+| `FsChange::Create` | Same as modify (path already held — content replaced) |
+| `FsChange::Delete` | Bump `revision`; `read_state = Failed(Deleted)` |
+| `read_bytes` Ok | `read_state = ReadOk` (bytes not stored) |
+| `read_bytes` Err | Map `FsError` → `Failed(NotFound \| Io \| InvalidPath)` |
+
+`apply_fs_changes` takes a **`Revision` frame** argument (caller-supplied sync
+tick), same pattern as engine `acquire_location(..., frame)`.
+
+### Public API (M1)
+
+```rust
+impl ArtifactStore {
+ pub fn new() -> Self;
+
+ pub fn acquire_location(
+ &mut self,
+ location: ArtifactLocation,
+ frame: Revision,
+ ) -> ArtifactId;
+
+ pub fn acquire_specifier(
+ &mut self,
+ locator: &ArtifactSpecifier,
+ frame: Revision,
+ ) -> Result;
+
+ pub fn release(&mut self, id: &ArtifactId, frame: Revision) -> Result<(), ArtifactError>;
+
+ pub fn apply_fs_changes(&mut self, changes: &[FsChange], frame: Revision);
+
+ pub fn read_bytes(
+ &mut self,
+ id: &ArtifactId,
+ fs: &dyn LpFs,
+ ) -> Result, ArtifactError>;
+
+ pub fn revision(&self, id: &ArtifactId) -> Option;
+ pub fn entry(&self, id: &ArtifactId) -> Option<&ArtifactEntry>;
+}
+```
+
+## Main components
+
+- **`ArtifactLocation`** — resolved cache key; M1: `File(LpPathBuf)` only.
+- **`ArtifactStore`** — refcounted freshness cache; fs changes are invalidations.
+- **`ArtifactReadState`** — separates “content generation” (`revision`) from
+ “last read attempt outcome”.
+- **Stubs** — `registry`, `source`, `change`, `view` modules declared for roadmap
+ layout; empty in M1.
+
+## Validation
+
+```bash
+cargo +nightly fmt --all
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
+
+## Out of scope
+
+- `NodeDefRegistry`, TOML parse, `SourceFileSlot`, ChangeSet, engine cutover.
+
+## Plan phases (dispatch)
+
+Default model policy: **`composer-2.5-fast`**; **`composer-2-fast`** only for
+very simple mechanical work. See `/plan` and `/implement` commands.
+
+| # | Phase | Dispatch |
+|---|-------|----------|
+| 01 | Bootstrap crate + delete mockup | [sub-agent: yes, model: **composer-2-fast**] |
+| 02 | Artifact types | [sub-agent: yes, model: **composer-2.5-fast**] |
+| 03 | Store acquire/release + fs changes | [sub-agent: yes, model: **composer-2.5-fast**] |
+| 04 | Transient `read_bytes` | [sub-agent: yes, model: **composer-2.5-fast**] |
+| 05 | Cleanup + validation + `summary.md` | [sub-agent: **supervised**, model: **composer-2.5-fast**] |
+
+Phases run sequentially (each depends on the previous). Single commit at end
+of plan per `/implement`.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-notes.md
new file mode 100644
index 000000000..c7f43b344
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/00-notes.md
@@ -0,0 +1,102 @@
+# M1 Plan Notes — `lpc-node-registry` + ArtifactStore
+
+## Scope of work
+
+Bootstrap **`lpc-node-registry`** and implement a **freshness-only
+`ArtifactStore`**:
+
+1. **Crate bootstrap** — new `no_std` + `alloc` crate; delete `lpc-slot-mockup`.
+2. **Requester-owned artifacts** — entries exist because a caller **acquired**
+ them; **`release`** when done. No filesystem-driven auto-registration.
+3. **Freshness metadata** — per entry: path, **`revision`** (`lpc_model::Revision`),
+ read outcome state, **no cached bytes**, **no `NodeDef` payload**.
+4. **Fs integration** — `apply_fs_changes` bumps **`revision`** on **existing**
+ acquired entries whose path matches; handles missing/deleted files without
+ removing entries while refs are held.
+5. **Transient read** — `read_bytes(id, fs)` for M2 parse path; does not retain
+ payload.
+6. **Unit tests** — M1 gate; `cargo test -p lpc-node-registry`.
+
+**Out of scope:** `NodeDefRegistry` (M2), parsing, `SourceFileSlot`, ChangeSet,
+`lpc-engine` edits.
+
+## Current state
+
+See prior analysis: production artifact logic in `lpc-engine/src/artifact/`
+(NodeDef payloads, refcount). No `lpc-node-registry` yet. `lpc-slot-mockup` has
+no Cargo dependents.
+
+## Questions — resolved
+
+### Confirmation batch (Q1–Q2, Q4, Q6–Q8)
+
+| # | Answer |
+|---|--------|
+| Q1 | **Yes** — delete `lpc-slot-mockup` entirely |
+| Q2 | **Yes** — new types in `lpc-node-registry` (parallel to engine until M6) |
+| Q4 | **Yes** — `ArtifactLocation::File` only in M1 |
+| Q6 | **Yes** — transient `read_bytes` in M1 |
+| Q7 | **Yes** — stub `registry`, `source`, `change`, `view` modules |
+| Q8 | **Yes** — `#![no_std]` + `default = ["std"]` |
+
+### Q3, Q5, Q9 — revised by ownership model (user)
+
+| # | Original suggestion | **Decision** |
+|---|---------------------|--------------|
+| Q3 | No refcount | **Refcount acquire/release** — artifacts owned by requester |
+| Q5 | `content_frame` | **`revision`** field name (`lpc_model::Revision`) |
+| Q9 | Delete removes entry | **Fs Delete bumps revision on held entries**; entry removed only on **release to refcount 0** |
+
+### Q10 — artifact state (user)
+
+**Decision:** Requester-owned entries + read outcome state.
+
+- **`acquire`** (resolve locator → location) **always yields an entry** unless the
+ locator is badly formed (e.g. unsupported `lib:`).
+- Entry lifetime tied to **refcount**, not filesystem.
+- Bootstrap chain (future): **engine → registry acquires `project.toml` → …**
+- Missing/deleted files: entry persists while acquired; **`revision` bumps** on fs
+ delete/modify; read state reflects failure without dropping identity.
+
+```rust
+enum ArtifactReadState {
+ Unread,
+ ReadOk,
+ Failed(ArtifactReadFailure),
+}
+
+enum ArtifactReadFailure {
+ Deleted,
+ NotFound,
+ Io { message: String },
+ InvalidPath { message: String },
+}
+```
+
+- **`revision` bump** on fs modify/create → `Unread` (clears prior failure).
+- **`FsChange::Delete`** → bump + `Failed(Deleted)` immediately.
+- **`release` at refcount 0** → remove entry from store.
+
+## Resolved decisions (roadmap + plan)
+
+- M1 does **not** touch `lpc-engine`.
+- Metadata only — no byte retention on entries.
+- Fs events affect **already-acquired** entries only.
+- Field name **`revision`**, not `content_frame`.
+
+## Notes
+
+- User: artifacts are owned by the **requester**, not the filesystem. The node
+ system asks the store; store returns an entry (unless locator resolution fails).
+- M2 `NodeDefRegistry` will acquire artifacts when defs need file sources; M1
+ tests simulate acquire/release directly.
+
+## Implementation dispatch
+
+Per `/plan` and `/implement` (updated model policy):
+
+| Phase | Model |
+|-------|-------|
+| 01 Bootstrap | `composer-2-fast` |
+| 02–04 | `composer-2.5-fast` |
+| 05 Cleanup | `composer-2.5-fast` (supervised) |
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/01-bootstrap-crate.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/01-bootstrap-crate.md
new file mode 100644
index 000000000..4c53b31a0
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/01-bootstrap-crate.md
@@ -0,0 +1,105 @@
+# Phase 01 — Delete mockup and bootstrap crate
+
+**Dispatch:** [sub-agent: yes, model: composer-2-fast, parallel: -]
+
+## Scope of phase
+
+- Delete `lp-core/lpc-slot-mockup/` entirely.
+- Remove `"lp-core/lpc-slot-mockup"` from workspace `Cargo.toml` `members`.
+- Create `lp-core/lpc-node-registry/` with `Cargo.toml`, `src/lib.rs`, and **empty
+ stub modules** for future milestones.
+- Crate must compile (`cargo check -p lpc-node-registry`).
+
+**Out of scope:** `artifact/` implementation (phase 02+). Do not touch `lpc-engine`.
+
+## Code Organization Reminders
+
+- One concept per file; entry points at top of each file, helpers at bottom.
+- Tests at bottom of files in `#[cfg(test)] mod tests`.
+- Stub modules get a one-line `//! M2` (etc.) doc comment only.
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Do **not** expand scope.
+- Do **not** suppress warnings with `#[allow]` — fix them.
+- Do **not** disable or weaken existing tests.
+- If blocked, stop and report back.
+
+## Implementation Details
+
+### Delete mockup
+
+Remove directory `lp-core/lpc-slot-mockup/`. Grep workspace for
+`lpc-slot-mockup` and remove references (expect only root `Cargo.toml` members).
+
+### `lp-core/lpc-node-registry/Cargo.toml`
+
+```toml
+[package]
+name = "lpc-node-registry"
+version.workspace = true
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[features]
+default = ["std"]
+std = ["lpc-model/std", "lpfs/std"]
+
+[dependencies]
+lpc-model = { path = "../lpc-model", default-features = false }
+lpfs = { path = "../../lp-base/lpfs", default-features = false }
+
+[lints]
+workspace = true
+```
+
+Add `"lp-core/lpc-node-registry"` to workspace `members` in root `Cargo.toml`
+(near other `lp-core/*` entries). Add to `default-members` as well (host CI).
+
+### `src/lib.rs`
+
+```rust
+#![no_std]
+
+extern crate alloc;
+
+#[cfg(feature = "std")]
+extern crate std;
+
+pub mod artifact;
+
+mod registry;
+mod source;
+mod change;
+mod view;
+```
+
+Stub files (`src/registry/mod.rs`, etc.):
+
+```rust
+//! NodeDefRegistry — milestone M2.
+```
+
+`src/artifact/mod.rs` — empty module declaration only for now (phase 02 fills it):
+
+```rust
+// Types added in phase 02.
+```
+
+Or minimal placeholder so `pub mod artifact` compiles.
+
+### Workspace
+
+Ensure `cargo check -p lpc-node-registry` succeeds with no warnings.
+
+## Validate
+
+```bash
+cargo check -p lpc-node-registry
+rg "lpc-slot-mockup" Cargo.toml lp-core/ --glob '!docs/**' || true
+```
+
+Expected: no mockup dir; registry crate compiles.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/02-artifact-types.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/02-artifact-types.md
new file mode 100644
index 000000000..b55e2c400
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/02-artifact-types.md
@@ -0,0 +1,127 @@
+# Phase 02 — Artifact types
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Implement artifact **types** under `lpc-node-registry/src/artifact/` (no store logic
+yet beyond what tests need for type checking):
+
+- `ArtifactId`
+- `ArtifactLocation` (`File` only) + `try_from_specifier`
+- `ArtifactError`
+- `ArtifactReadState`
+- `ArtifactReadFailure`
+- `ArtifactEntry`
+
+Export all public types from `artifact/mod.rs`.
+
+**Out of scope:** `ArtifactStore` methods (phase 03). Do not touch `lpc-engine`.
+
+## Code Organization Reminders
+
+- Granular files per type; `mod.rs` re-exports.
+- Unit tests for `try_from_specifier` at bottom of `artifact_location.rs`.
+- Helpers at bottom of files.
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Stay in scope — types only.
+- Fix warnings; no `#[allow]` without reason.
+- Report back: files added, tests run.
+
+## Implementation Details
+
+### `artifact_id.rs`
+
+Mirror engine pattern:
+
+```rust
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct ArtifactId { handle: u32 }
+```
+
+`from_raw` / `handle()` — `from_raw` pub(crate) until store needs it, or pub(crate) in store module only.
+
+### `artifact_location.rs`
+
+```rust
+#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
+pub enum ArtifactLocation {
+ File(LpPathBuf),
+}
+```
+
+- `file(path) -> Self`
+- `try_from_specifier(loc: &ArtifactSpecifier) -> Result`
+ - `ArtifactSpecifier::Path(p)` → `File(p.clone())`
+ - `ArtifactSpecifier::Lib(_)` → `ArtifactError::Resolution("library artifact references are not supported yet")`
+
+Use `lpc_model::{ArtifactSpecifier, LpPathBuf}`.
+
+Port ordering tests from `lpc-engine/src/artifact/artifact_location.rs` (file-only subset).
+
+### `artifact_error.rs`
+
+```rust
+pub enum ArtifactError {
+ UnknownHandle { handle: u32 },
+ InvalidRelease { handle: u32 },
+ Resolution(String),
+ Read(String),
+}
+```
+
+No `Load`/`Prepare` — freshness store uses `Read` for transient read failures.
+
+### `artifact_read_state.rs`
+
+```rust
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ArtifactReadState {
+ Unread,
+ ReadOk,
+ Failed(ArtifactReadFailure),
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ArtifactReadFailure {
+ Deleted,
+ NotFound,
+ Io { message: String },
+ InvalidPath { message: String },
+}
+```
+
+Add `ArtifactReadFailure::from_fs_error(FsError) -> Self` helper mapping
+`FsError::NotFound` → `NotFound`, `Filesystem` → `Io`, `InvalidPath` →
+`InvalidPath`.
+
+### `artifact_entry.rs`
+
+```rust
+pub struct ArtifactEntry {
+ pub id: ArtifactId,
+ pub location: ArtifactLocation,
+ pub refcount: u32,
+ pub revision: Revision,
+ pub read_state: ArtifactReadState,
+}
+```
+
+Use `lpc_model::Revision`.
+
+### Tests
+
+In `artifact_location.rs`:
+
+- Path specifier resolves to `File`.
+- Lib locator returns `Resolution` error.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/03-artifact-store-core.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/03-artifact-store-core.md
new file mode 100644
index 000000000..b39e52dd2
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/03-artifact-store-core.md
@@ -0,0 +1,82 @@
+# Phase 03 — ArtifactStore acquire, release, fs changes
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Implement `ArtifactStore` with:
+
+- `new`, `acquire_location`, `acquire_specifier`, `release`
+- `apply_fs_changes`
+- `revision`, `entry`, `refcount` accessors
+
+**Out of scope:** `read_bytes` (phase 04). No `lpc-engine` edits.
+
+## Code Organization Reminders
+
+- Store impl in `artifact_store.rs`; entry points at top, private helpers at bottom.
+- Tests at bottom of `artifact_store.rs`.
+- Field name **`revision`**, not `content_frame`.
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Requester-owned model: entries only from acquire; fs never creates entries.
+- Report deviations.
+
+## Implementation Details
+
+### `ArtifactStore` fields
+
+```rust
+pub struct ArtifactStore {
+ by_handle: BTreeMap,
+ location_to_handle: BTreeMap,
+ next_handle: u32,
+}
+```
+
+### `acquire_location(location, frame: Revision) -> ArtifactId`
+
+- If location exists: increment `refcount`, return same id.
+- Else: allocate handle, insert entry with:
+ - `refcount = 1`
+ - `revision = frame`
+ - `read_state = Unread`
+
+### `acquire_specifier(locator, frame) -> Result`
+
+Resolve via `ArtifactLocation::try_from_specifier`, then `acquire_location`.
+
+### `release(id, _frame) -> Result<(), ArtifactError>`
+
+- Decrement `refcount`; error if unknown handle or already zero.
+- At **refcount 0**: remove entry from `by_handle` and `location_to_handle`.
+
+### `apply_fs_changes(changes: &[FsChange], frame: Revision)`
+
+For each change, find entry by **path match** on `ArtifactLocation::File(path)`:
+
+- **No matching acquired entry** → skip (fs does not register artifacts).
+- **`Modify` / `Create`**: set `revision = frame`, `read_state = Unread`.
+- **`Delete`**: set `revision = frame`, `read_state = Failed(Deleted)`.
+
+Path match: compare `FsChange.path` to entry's file path (`LpPathBuf` equality).
+
+### Tests (required)
+
+1. `acquire_same_location_reuses_handle_and_increments_refcount`
+2. `release_at_zero_removes_entry`
+3. `fs_modify_bumps_revision_and_sets_unread` — acquire, apply Modify, assert revision + Unread
+4. `fs_change_on_unacquired_path_is_noop` — apply change before acquire, then acquire gets fresh revision from acquire frame only
+5. `fs_delete_sets_deleted_failure_while_entry_held`
+6. `acquire_specifier_rejects_lib`
+
+Use `lpfs::FsChange`, `ChangeType`, `LpPathBuf`.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/04-transient-read.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/04-transient-read.md
new file mode 100644
index 000000000..e30cbd888
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/04-transient-read.md
@@ -0,0 +1,60 @@
+# Phase 04 — Transient read_bytes
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Add `ArtifactStore::read_bytes` — read file content through `LpFs` without
+retaining bytes on the entry.
+
+**Out of scope:** TOML parsing, registry integration.
+
+## Code Organization Reminders
+
+- Add method to existing `artifact_store.rs`.
+- Tests at bottom of file using `lpfs::LpFsMemory`.
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Returned `Vec` must not be stored on `ArtifactEntry`.
+- Fix warnings properly.
+
+## Implementation Details
+
+### `read_bytes(&mut self, id: &ArtifactId, fs: &dyn LpFs) -> Result, ArtifactError>`
+
+1. Look up entry; `UnknownHandle` if missing.
+2. Extract `File(path)` from location; internal error if not file.
+3. Call `fs.read_file(path)` (or equivalent `LpFs` API).
+4. On **Ok(bytes)**: set `read_state = ReadOk`; return bytes.
+5. On **Err(e)**: set `read_state = Failed(ArtifactReadFailure::from_fs_error(e))`;
+ return matching `ArtifactError::Read(failure)`.
+
+Reading does **not** bump `revision`.
+
+If entry already has `ReadError` from fs delete, read attempt may still run (file
+missing) — either outcome is acceptable; prefer updating read_state from actual
+read result.
+
+### Tests
+
+Use `LpFsMemory`:
+
+1. **`read_bytes_success_sets_read_ok`** — write file to mem fs, acquire, read,
+ assert `ReadOk`, drop returned vec, assert entry still has no payload field.
+2. **`read_bytes_missing_file_sets_not_found`** — acquire path not in fs, read fails,
+ `Failed(NotFound)` set, entry still exists with refcount.
+3. **`read_after_fs_modify_requires_unread_or_reread`** — acquire, read Ok, apply
+ Modify fs change (Unread), read again gets new content.
+
+Enable `std` feature on crate for test if mem fs needs it (already default for tests).
+
+Check `LpFs` trait in `lp-base/lpfs/src/lp_fs.rs` for correct read method name.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/05-cleanup-validation.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/05-cleanup-validation.md
new file mode 100644
index 000000000..815b2ff63
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/05-cleanup-validation.md
@@ -0,0 +1,64 @@
+# Phase 05 — Cleanup and validation
+
+**Dispatch:** [sub-agent: supervised, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+- Re-export primary artifact types from `lib.rs` for downstream crates.
+- Grep diff for debug prints, stray TODOs, `content_frame` naming.
+- Run full validation commands.
+- Write `summary.md` per plan process.
+
+**Out of scope:** New features, M2 work.
+
+## Code Organization Reminders
+
+- `lib.rs` re-exports: `ArtifactStore`, `ArtifactId`, `ArtifactLocation`,
+ `ArtifactError`, `ArtifactReadState`, `ArtifactEntry`.
+
+## Sub-agent Reminders
+
+- Do **not** commit (plan commits once at end).
+- Fix all clippy warnings in `lpc-node-registry`.
+
+## Implementation Details
+
+### `lib.rs` re-exports
+
+```rust
+pub use artifact::{
+ ArtifactEntry, ArtifactError, ArtifactId, ArtifactLocation, ArtifactReadState,
+ ArtifactStore,
+};
+```
+
+### Update milestone cross-reference (optional doc touch)
+
+If `docs/roadmaps/.../m1-artifact-store.md` still says `content_frame`, update to
+`revision` and requester-owned acquire/release (only if editing docs in this phase).
+
+### `summary.md`
+
+Create plan summary with **What was built** and **Decisions for future reference**
+(ownership model, revision naming, fs does not register, release-at-zero removes entry).
+
+### Grep checks
+
+```bash
+rg "content_frame" lp-core/lpc-node-registry/
+rg "TODO|dbg!|println!" lp-core/lpc-node-registry/
+rg "lpc-slot-mockup" Cargo.toml lp-core/
+```
+
+Fix any findings.
+
+## Validate
+
+```bash
+cargo +nightly fmt --all
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+cargo check -p lpc-node-registry --no-default-features
+```
+
+All must pass.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/summary.md
new file mode 100644
index 000000000..364ab098a
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/summary.md
@@ -0,0 +1,34 @@
+# M1 Summary — `lpc-node-registry` + ArtifactStore
+
+### What was built
+
+- Added `lp-core/lpc-node-registry` crate (`no_std` + alloc) with stub modules for M2–M5.
+- Removed defunct `lp-core/lpc-slot-mockup` from workspace.
+- Implemented requester-owned `ArtifactStore`: acquire/release refcount, `revision`, `apply_fs_changes`.
+- Added structured read outcomes: `ArtifactReadState` + `ArtifactReadFailure` (`Deleted`, `NotFound`, `Io`, `InvalidPath`).
+- Transient `read_bytes` via `LpFs` (no cached payload on entries).
+- 11 unit tests covering acquire, release, fs invalidation, and read paths.
+
+### Decisions for future reference
+
+#### Requester-owned artifacts (not filesystem-registered)
+
+- **Decision:** Entries exist only after `acquire`; fs changes invalidate held paths; `release` at refcount 0 removes entry.
+- **Why:** Client/registry drives identity; fs is an invalidation source, not registration.
+- **Rejected alternatives:** Auto-register all project files on fs events; no refcount.
+- **Revisit when:** Unlikely for this boundary.
+
+#### Structured `ArtifactReadFailure` vs string errors
+
+- **Decision:** `Failed(Deleted | NotFound | Io | InvalidPath)` distinct from `ArtifactError::Resolution` at acquire.
+- **Why:** Registry/engine need typed outcomes; `FsError` mapping preserved.
+- **Rejected alternatives:** Single `ReadError { message }` string.
+- **Revisit when:** M5 overlay may add non-fs read paths (AssetView).
+
+#### Field name `revision` not `content_frame`
+
+- **Decision:** `ArtifactEntry.revision` uses `lpc_model::Revision`.
+- **Why:** Content-generation marker; aligns with sync vocabulary, distinct from engine's legacy name.
+- **Revisit when:** M6 cutover may map engine tick context naming.
+
+Plan: docs/roadmaps/2026-05-21-artifact-routed-file-reload/m1-artifact-store/
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md
new file mode 100644
index 000000000..18fec49fe
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md
@@ -0,0 +1,88 @@
+# Milestone 10: Slot Resolution Probes
+
+## Title And Goal
+
+Implement **on-demand provenance** via **project-read probes** — not on the
+normal value read path. When the client wants to know how a consumed slot
+resolved, it attaches an `ExplainSlot` probe to a `project_read` request (or
+re-derives locally on the host when it already holds bindings + ChangeSet).
+
+Runtime ticks and ordinary slot reads stay lean: **value only**, no provenance
+structs on `Production` or registry hot paths.
+
+## Prerequisites
+
+- **M6** engine cutover — consumed-slot resolution reads effective defs from
+ `NodeDefRegistry` (bindings → registry def).
+- **ChangeSet roadmap** green — overlay + commit in mainline registry.
+- Resolver trace events shaped for explain output (see M3.5 resolver notes).
+
+## Existing hooks
+
+Wire types already exist; engine returns `Unsupported` today:
+
+- `lpc-wire`: `ExplainSlotProbeRequest { node, slot, include_trace }`,
+ `ExplainSlotProbeResult`, `SlotExplanation { value, trace }`
+- `ProjectProbeRequest::ExplainSlot` — piggybacks on `project_read` beside
+ normal queries (same pattern as `RenderProduct` probe)
+- `Engine::read_project_explain_slot_probe` — stub in `project_read_probes.rs`
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client/`
+
+## Scope
+
+In scope:
+
+- **Implement `ExplainSlot` probe** in engine: run resolver for `(node, slot)`,
+ collect effective value + optional trace (`include_trace`).
+- Trace steps cover the resolution cascade:
+ **binding(s) / merge** → **effective registry def** (overlay ∪ base) →
+ produced-slot fallback when applicable.
+- Host **`lpa-client` helper** to attach explain probes to project reads and
+ parse `SlotExplanation` for UI badges.
+- **Optional local re-derive** in host client when it holds bindings +
+ ChangeSet + overlay membership — same cascade, no round-trip (inspector /
+ offline editing).
+
+Out of scope:
+
+- Provenance on every consumed-slot resolve or tick read (ESP32 memory).
+- Dedicated server-side provenance logic in `lpa-server` beyond forwarding
+ `project_read` probes to engine.
+- Registry `explain_slot()` API — defer unless probe + local re-derive prove
+ insufficient.
+- Full inspector UI — hooks only.
+
+## Key Decisions
+
+- **Probe, not pollute reads:** Normal reads deliver effective values; explain
+ is a separate request-scoped probe.
+- **Client chooses when:** UI adds `ExplainSlot` probe only for inspected slots
+ (or re-derives locally on host).
+- **No lpa-server v1 logic:** Server forwards probes; engine executes explain.
+- **May revisit:** Thin/remote clients without local ChangeSet may need richer
+ wire explain or registry helpers later.
+
+## Deliverables
+
+- `read_project_explain_slot_probe` implemented (resolver + trace).
+- `lpa-client` probe attachment + optional local `SlotProvenance` helper.
+- Tests: binding wins, overlay wins over committed def, merge contributors in
+ trace when `include_trace`.
+
+## Dependencies
+
+- M6 engine cutover.
+- ChangeSet roadmap complete (overlay + effective reads in registry).
+
+## Execution Strategy
+
+Small plan after M6/M7 stabilize. Primary work is engine probe implementation +
+client wiring; no ChangeSet roadmap changes.
+
+Suggested chat opener:
+
+> M10: implement ExplainSlot probe (wire exists, engine stub) — bindings then
+> effective def then trace. Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md
new file mode 100644
index 000000000..698da2600
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md
@@ -0,0 +1,72 @@
+# Milestone 2: NodeDefRegistry + NodeDefUpdates
+
+## Title And Goal
+
+Introduce **`NodeDefRegistry`** in the existing **`lpc-node-registry`** crate
+(M1 bootstrap) as the owner of parsed node definitions, with **`NodeDefUpdates`**
+reporting when artifacts change.
+
+**Prerequisite:** M1 crate + `ArtifactStore` complete and tested in isolation.
+
+## Parallel Build
+
+**M2 does not modify `lpc-engine`.** Registry and update logic are new code in
+`lpc-node-registry`, tested against the M1 artifact store and real `NodeDef`
+parse paths — not wired into `ProjectLoader`.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/`
+
+## Scope
+
+In scope:
+
+- `NodeDefId` opaque handle (same pattern as `ArtifactId`).
+- Bootstrap: **`load_root(root_path)`** — any node-definition TOML (project is
+ convention); registry walks and registers all defs.
+- Steady state: driver **`apply_fs_changes`** on store, then **`sync`** →
+ `NodeDefUpdates`.
+- Registry entry source `{ artifact_id, path_in_artifact: SlotPath }` (root =
+ `SlotPath::root()`).
+- Identity separate from content: `Loaded` / `ParseError` / `ValidationError`.
+- **`sync`** → `NodeDefUpdates` (driver applies fs to store first).
+- Inline defs derived from parent artifact at non-root paths.
+- Def content change does not mark parent def changed when only a child inline
+ def changed.
+- Stub **`NodeDefView`** read path (base registry only; **ChangeSet overlay in M5**).
+- Unit tests: artifact re-parse scenarios, inline child isolation, add/remove.
+
+Out of scope:
+
+- Production `Engine` / `ProjectLoader` cutover (**M6**).
+- `SourceFileSlot` (M3).
+- Engine tree mutation from updates (M4 harness).
+
+## Key Decisions
+
+- **Parallel crate only** — no `lpc-engine` / `ProjectLoader` edits.
+- Registry is testable in isolation: artifact change in, `NodeDefUpdates` out.
+- v1 may recreate def entries wholesale (no stable id preservation yet).
+- Graph wiring changes surface as parent def `changed` but tree mutation is
+ engine responsibility (**M8**).
+
+## Deliverables
+
+- `lp-core/lpc-node-registry/src/registry/` (registry, updates, view stub).
+- Parser integration hook (read artifact bytes → `NodeDef` or error).
+- Tests covering leaf TOML change, inline child edit, child add/remove.
+
+## Dependencies
+
+- M1 ArtifactStore freshness model.
+
+## Execution Strategy
+
+Full plan. Registry entry lifecycle, inline path scheme, and update diff logic
+need design doc before implementation.
+
+Suggested chat opener:
+
+> This milestone needs a full plan — NodeDefRegistry, NodeDefUpdates diff rules,
+> and inline def paths. I'll run the plan process then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-design.md
new file mode 100644
index 000000000..6c68a6027
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-design.md
@@ -0,0 +1,306 @@
+# M2 Design — NodeDefRegistry + NodeDefUpdates
+
+## Scope
+
+Implement **`NodeDefRegistry`** in `lpc-node-registry`: parsed `NodeDef`
+storage keyed by `NodeDefId`, driven by M1 `ArtifactStore` freshness, reporting
+**`NodeDefUpdates`** on artifact changes. Stub **`NodeDefView`** for future
+ChangeSet overlay (M5).
+
+No `lpc-engine` edits.
+
+## Ownership model
+
+```
+┌─────────────┐
+│ Driver │ tests / engine (M6) / M4 harness
+│ owns fs + │
+│ ArtifactStore│
+└──────┬──────┘
+ │
+ │ bootstrap (once): load_root("/project.toml") // any root .toml kind
+ │ loop: store.apply_fs_changes(...); registry.sync(...) → NodeDefUpdates
+ ▼
+┌──────────────────┐ acquire/release ┌────────────────┐
+│ NodeDefRegistry │ ◄──────────────────────►│ ArtifactStore │
+└──────────────────┘ └────────────────┘
+ │
+ │ M5: ChangeSet commit → same NodeDefUpdates shape
+ ▼
+ NodeDefUpdates { added, changed, removed }
+```
+
+- **Driver** owns `LpFs` and **`ArtifactStore`**. Registry does **not** call
+ `apply_fs_changes` — driver applies fs (or future ChangeSet commit) to the
+ store first, then calls **`sync`**.
+- Registry is the **requester** for file artifacts backing defs it tracks.
+- **Inline defs** live at non-root `SlotPath` within a **parent file artifact**
+ — no `ArtifactLocation::InlineNode`.
+- When the last registry entry referencing a file artifact is removed, registry
+ **`release`s** that artifact.
+
+## Driver API (public contract)
+
+Two entry points for M2. Everything else is private.
+
+### Bootstrap — `load_root`
+
+```rust
+pub fn load_root(
+ &mut self,
+ store: &mut ArtifactStore,
+ fs: &dyn LpFs,
+ root_path: &LpPath, // absolute, e.g. "/project.toml"
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+) -> Result
+```
+
+- Called **once** per load (or full reload).
+- **`root_path`** points at any node-definition TOML. Convention is
+ `project.toml`, but kind is **not** enforced — `load_root("/playlist.toml")`
+ is valid.
+- Acquires the file artifact, parses root def, walks invocations recursively,
+ registers all discovered defs.
+- Returns the **`NodeDefId`** for the root entry (`SlotPath::root()` on that
+ artifact).
+
+### Steady state — `sync`
+
+```rust
+pub fn sync(
+ &mut self,
+ store: &mut ArtifactStore,
+ fs: &dyn LpFs,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+) -> NodeDefUpdates
+```
+
+- Called after driver has applied **`store.apply_fs_changes(changes, frame)`**
+ (M2) or equivalent bumps from ChangeSet commit (M5).
+- Re-derives defs for artifacts whose **`revision`** advanced; returns
+ **`NodeDefUpdates`**.
+- Safe to call with no pending changes — returns empty sets.
+
+### Private helpers (not public)
+
+- `register_file_at_path` — recursive registration for path-backed
+ `NodeInvocation` refs (internal to walk).
+- `derive_artifact_inventory`, `register_invocations`, etc.
+
+
+## File structure
+
+```
+lp-core/lpc-node-registry/src/
+├── lib.rs # re-export registry + view types
+├── registry/
+│ ├── mod.rs
+│ ├── node_def_id.rs # opaque NodeDefId
+│ ├── def_source.rs # { artifact_id, path: SlotPath }
+│ ├── node_def_state.rs # Loaded | ParseError | ValidationError
+│ ├── node_def_entry.rs # source + state + last_seen_revision
+│ ├── node_def_updates.rs # { added, changed, removed }
+│ ├── registry_error.rs
+│ ├── parse_ctx.rs # ParseCtx { shapes: &SlotShapeRegistry }
+│ ├── def_shell.rs # shell equality (inline → kind stub)
+│ ├── def_walker.rs # walk Project + Playlist invocations
+│ └── node_def_registry.rs # load_root, sync (+ private walk/register)
+└── view/
+ ├── mod.rs
+ └── node_def_view.rs # stub: base registry lookup
+```
+
+## Core types
+
+### DefSource
+
+```rust
+pub struct DefSource {
+ pub artifact_id: ArtifactId,
+ pub path: SlotPath, // SlotPath::root() = artifact root file def
+}
+```
+
+Maps 1:1 to future engine `NodeDefHandle` (M6 rename).
+
+### NodeDefState
+
+```rust
+pub enum NodeDefState {
+ Loaded(NodeDef),
+ ParseError(NodeDefParseError),
+ ValidationError(/* reserved — unused in M2 */),
+}
+```
+
+- Entry **always exists** when registry knows a def should exist at a source
+ (identity separate from content — roadmap error semantics).
+- Artifact read failure during derive → entry enters `ParseError` (or dedicated
+ read error variant wrapped for display); no last-good retention.
+
+### NodeDefUpdates
+
+```rust
+pub struct NodeDefUpdates {
+ pub added: BTreeSet,
+ pub changed: BTreeSet,
+ pub removed: BTreeSet,
+}
+```
+
+Def-level deltas only. Engine tree mutation is out of scope (M4/M8).
+
+### ParseCtx
+
+```rust
+pub struct ParseCtx<'a> {
+ pub shapes: &'a SlotShapeRegistry,
+}
+```
+
+Tests use `SlotShapeRegistry::default()` (same as engine).
+
+## Derive pipeline
+
+### load_root (public)
+
+1. Require **absolute** `root_path` (driver responsibility).
+2. `store.acquire_location(ArtifactLocation::file(root_path), frame)`.
+3. `read_bytes` → `NodeDef::read_toml(ctx.shapes, text)`.
+4. Register root entry at `{ artifact_id, SlotPath::root() }`.
+5. **`def_walker`**: discover nested defs (see below).
+6. Record `last_seen_revision = store.revision(artifact_id)`.
+
+Returns root `NodeDefId`. Store **`root_path`** on registry (optional field) for
+driver introspection; walker uses per-artifact paths from `artifact_root_path`
+map.
+
+### def_walker (private, invoked from load_root / sync derive)
+
+Invocation sites in v1 model:
+
+| Parent `NodeDef` | Field | Child path suffix |
+|------------------|-------|-------------------|
+| `Project` | `nodes.{name}` | `nodes.{name}` (value is `NodeInvocation`) |
+| `Playlist` | `entries.{key}.node` | `entries.{key}.node` |
+
+For each `NodeInvocation`:
+
+| `NodeDefRef` | Action |
+|--------------|--------|
+| `Path(locator)` | Resolve relative to **containing artifact file path** → recursive **`register_file_at_path`** → child at **child artifact root** |
+| `Inline(def)` | Register entry at `{ parent_artifact_id, path }` with full body; recurse walker into inline `NodeDef` body for nested invocations |
+
+### sync (public)
+
+For each **tracked file artifact** where `store.revision(id)` differs from
+last-derived revision for that artifact:
+
+1. Re-read bytes and re-derive full def inventory for that artifact subtree.
+2. Diff old vs new `DefSource → entry` maps:
+ - In old, not new → **`removed`**
+ - In new, not old → **`added`**
+ - In both → compare for **`changed`** (rules below)
+3. Update `last_seen_revision` on success.
+4. Release artifacts no longer referenced.
+
+**Driver sequence (M2 tests and M6 engine):**
+
+```rust
+store.apply_fs_changes(&changes, frame);
+let updates = registry.sync(&mut store, fs, frame, ctx);
+// apply updates to node tree (engine M6 / harness M4)
+```
+
+## Changed detection: shell vs body
+
+### Body equality (leaf + inline child entries)
+
+Full `NodeDef` equality at the entry's source path. Used for defs that **are**
+the invoked definition (artifact root file defs, inline child defs).
+
+### Shell equality (parent entries that contain invocations)
+
+For parent defs (`Project`, `Playlist`, or any future container), compute a
+**shell** view:
+
+- Walk the `NodeDef`; at each `NodeInvocation` with `NodeDefRef::Inline(_)`,
+ replace the inline body with a **kind-only stub** (`NodeDef` of same
+ `NodeKind`, default/minimal payload).
+- Path-backed invocations compare by **resolved locator string** (and presence).
+
+Parent is **`changed`** when shell differs. Inline **child body** edit without
+shell change → child **`changed`**, parent **not** **`changed`**.
+
+### Kind change (engine contract)
+
+**Any `NodeKind` change on a bound def requires runtime delete/recreate** (M6).
+Registry reports the existing `NodeDefId` in **`changed`**; engine must not
+treat kind change as in-place slot refresh.
+
+Kind change effects on updates:
+
+| Scenario | Registry report |
+|----------|-------------------|
+| Inline child kind flip (e.g. `Shader` → `Clock`) | Child **`changed`**; parent shell includes invocation kind → parent **`changed`** |
+| Root file def kind flip | Root **`changed`** |
+| Path-backed child file kind flip | Child root **`changed`**; parent shell unchanged if locator string unchanged |
+
+M2 tests must cover at least one kind-change scenario.
+
+## NodeDefView (stub)
+
+```rust
+pub struct NodeDefView<'a> {
+ registry: &'a NodeDefRegistry,
+}
+
+impl<'a> NodeDefView<'a> {
+ pub fn get(&self, id: &NodeDefId) -> Option<&NodeDefEntry>;
+}
+```
+
+No ChangeSet overlay (M5). No mutation API.
+
+## Test scenarios (gate)
+
+| # | Scenario | Setup | Expected updates |
+|---|----------|-------|------------------|
+| T1 | Leaf file edit | `load_root("/clock.toml")`; modify file; `apply_fs_changes` + `sync` | Root in `changed` |
+| T2 | Inline child edit | `load_root("/playlist.toml")` with inline entry; edit inline shader slot | Child `changed`; playlist **not** `changed` |
+| T3 | Entry add/remove | `load_root("/playlist.toml")`; add/remove `[entries.N]` | New/removed child ids; playlist in `changed` |
+| T4 | Path-backed child file edit | `load_root("/playlist.toml")` with path entry; modify child file | Child `changed`; playlist **not** `changed` |
+| T5 | Inline child kind change | `load_root("/playlist.toml")`; flip inline kind | Child + playlist in `changed` |
+
+Use `LpFsMemory` + inline TOML fixtures (adapt patterns from
+`playlist_entry.rs` tests). Optionally load paths under `examples/basic/`.
+
+## Validation
+
+```bash
+cargo +nightly fmt --all
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
+
+## Out of scope
+
+- `lpc-engine` cutover (M6).
+- `SourceFileSlot` / asset artifacts (M3).
+- ChangeSet overlay (M5).
+- Stable `NodeDefId` across ambiguous edits.
+- Semantic `ValidationError` population.
+
+## Plan phases (dispatch)
+
+| # | Phase | Dispatch |
+|---|-------|----------|
+| 01 | Registry types + ParseCtx | [sub-agent: yes, model: **composer-2.5-fast**] |
+| 02 | Def walker + shell helpers | [sub-agent: yes, model: **composer-2.5-fast**] |
+| 03 | NodeDefRegistry `load_root` + artifact tracking | [sub-agent: yes, model: **composer-2.5-fast**] |
+| 04 | `sync` + diff rules | [sub-agent: yes, model: **composer-2.5-fast**] |
+| 05 | NodeDefView stub + gate tests + cleanup | [sub-agent: **supervised**, model: **composer-2.5-fast**] |
+
+Phases run sequentially. Single commit at end of plan per `/implement`.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-notes.md
new file mode 100644
index 000000000..dfea16db2
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/00-notes.md
@@ -0,0 +1,165 @@
+# M2 Plan Notes — NodeDefRegistry + NodeDefUpdates
+
+## Scope of work
+
+Implement **`NodeDefRegistry`** in `lpc-node-registry` as the owner of parsed node
+definitions, with **`NodeDefUpdates`** reporting def-level deltas when held
+artifacts change.
+
+In scope:
+
+- `NodeDefId` opaque handle (same pattern as `ArtifactId`).
+- Registry entry source `{ artifact_id, path_in_artifact: SlotPath }` (root =
+ `SlotPath::root()`).
+- Entry state: `Loaded(NodeDef)` / `ParseError` / `ValidationError` (variant
+ reserved; M2 only populates `Loaded` and `ParseError`).
+- Registry **acquires file artifacts** via M1 `ArtifactStore` for every
+ file-backed def it tracks; **releases** when no defs reference that artifact.
+- Inline defs live at non-root paths within a **parent file artifact** — no
+ `ArtifactLocation::InlineNode` (rejects old engine pattern).
+- **`load_root(root_path)`** bootstrap + **`sync(...)`** steady state →
+ `NodeDefUpdates { added, changed, removed }`. Driver owns fs + store;
+ applies `apply_fs_changes` before `sync`.
+- Parent def **not** marked `changed` when only an inline **child body** edits;
+ parent **is** marked `changed` when its shell changes (entry add/remove,
+ path↔inline ref flip, non-child slot edits).
+- Stub **`NodeDefView`** — reads base registry only (ChangeSet overlay in M5).
+- Unit tests: leaf TOML edit, inline child isolation, playlist entry add/remove.
+
+Out of scope:
+
+- `lpc-engine` / `ProjectLoader` edits (**M6**).
+- `SourceFileSlot` / GLSL file artifacts (**M3**).
+- Engine tree mutation from updates (**M4** harness, **M8** graph).
+- Stable `NodeDefId` preservation across ambiguous edits (future).
+- Semantic validation beyond TOML parse (**ValidationError** stub only).
+
+## Current state
+
+### M1 (done)
+
+`lpc-node-registry` crate exists with requester-owned `ArtifactStore`:
+
+- `acquire_location` / `acquire_specifier` / `release`
+- `apply_fs_changes` bumps `revision` on held entries
+- Transient `read_bytes(id, fs)` — no cached bytes on entries
+
+`registry/`, `view/`, `source/`, `change/` modules are stubs.
+
+### Engine reference (do not modify in M2)
+
+`lpc-engine` today conflates artifacts + defs:
+
+- `NodeDefHandle { artifact, path: SlotPath }` — non-root paths reserved but
+ unused; all defs at artifact root.
+- Inline defs use `ArtifactLocation::InlineNode` + synthetic path
+ `{project}#nodes.{name}` — **M2 rejects this**; inline defs are registry
+ paths only.
+
+### Model parsing
+
+- `NodeDef::read_toml(registry, text) -> Result`
+- `SlotShapeRegistry::default()` sufficient for tests (same as engine).
+- `NodeInvocation` appears in:
+ - `ProjectDef.nodes` — map key is node name; path = `nodes.{name}`
+ - `PlaylistEntry.node` — path = `entries.{key}.node`
+
+### Test fixtures
+
+- `examples/basic/` — leaf file edits (e.g. `clock.toml`).
+- `examples/button-playlist/` — path-backed playlist entries today; tests will
+ use inline variants (see `playlist_entry.rs` inline test TOML).
+
+Paths must be project-root relative with leading `/` for `LpFsMemory`.
+
+## Architecture sketch (driver model)
+
+```
+Driver (tests / engine)
+ │
+ ├─ load_root("/project.toml") // any root node .toml; project is convention
+ │ └─► registry walks tree, acquires artifacts, registers all defs
+ │
+ └─ loop:
+ store.apply_fs_changes(changes, frame) // driver-owned
+ updates = registry.sync(store, fs, frame, ctx)
+ // M5: ChangeSet commit bumps store similarly → same sync
+ apply NodeDefUpdates to node tree (M4/M6)
+```
+
+Internal walk uses private `register_file_at_path` for path-backed
+`NodeInvocation` refs.
+
+### Parent vs child `changed` (shell rule)
+
+Compare two views per def path:
+
+| View | Used for | Inline child body |
+|------|----------|---------------------|
+| **Shell** | Parent `changed` | Replaced with `Inline { kind }` stub (no nested payload) |
+| **Body** | Leaf / inline-child `changed` | Full `NodeDef` equality |
+
+Example: edit inline shader slots under `entries.2.node` → child `changed`,
+playlist shell unchanged → parent **not** `changed`.
+
+Example: add `entries.3` → shell changes → parent **changed** + new child
+`added`.
+
+## Questions — resolved
+
+### Confirmation batch (Q1–Q7)
+
+| # | Decision |
+|---|----------|
+| Q1 | Registry **acquires/releases** file artifacts it tracks; inline defs share parent artifact |
+| Q2 | Def source = `{ artifact_id, SlotPath }` (`nodes.{name}`, `entries.{key}.node`, root = file def) |
+| Q3 | Path-backed children → separate file artifact + root def; child file change → child `changed` only |
+| Q4 | `NodeDefUpdates { added, changed, removed: BTreeSet }` — no extra inventory field in M2 |
+| Q5 | Shell/body split for parent vs inline-child `changed` detection |
+| Q6 | `ValidationError` variant exists; only `ParseError` populated in M2 |
+| Q7 | Tests use `load_root` on leaf/playlist fixtures; project root supported |
+
+### Q10 — Driver-owned registration (user)
+
+**Decision:**
+
+- **Public API:** `load_root(absolute_path)` once; `sync(...)` after driver
+ applies fs changes to `ArtifactStore`.
+- Root kind **not** enforced — `project.toml` is convention only.
+- Registry does **not** call `apply_fs_changes`; driver owns store + fs.
+- M5 ChangeSet commit follows same pattern → `NodeDefUpdates`.
+- `register_file_at_path` is **private** (internal path-ref recursion during walk).
+
+### Q8 — ProjectDef registration
+
+**Decision:** Walker supports `Project` + `Playlist`; gate tests focus on leaf
+file + playlist inline/path scenarios.
+
+### Q9 — Kind change semantics (user)
+
+**Context:** `NodeKind` is part of node identity for the engine runtime. A def
+whose kind changes (e.g. `Shader` → `Clock`) cannot be refreshed in place.
+
+**Decision:**
+
+- Registry still reports the same `NodeDefId` in **`changed`** when kind flips
+ (v1 id preservation is best-effort; M6 engine decides recreate either way).
+- **Shell stubs** for inline invocations include **`NodeKind`** — a kind flip at
+ an invocation site changes the parent shell → parent **`changed`**.
+- **Engine contract (M6, document now):** kind change on a bound def → **delete
+ and recreate** the runtime node; do not attempt in-place slot refresh.
+- M2 tests: include at least one case where inline/path child **kind** changes
+ and assert child (and parent shell when applicable) land in `changed`.
+
+## Resolved decisions (roadmap — no re-litigation)
+
+- Parallel build in `lpc-node-registry`; no `lpc-engine` edits until M6.
+- ArtifactStore = freshness only; registry = parsed defs.
+- No last-good on parse failure — error state on entry (engine cascade in M6).
+- Inline child edit must not imply parent `changed` (decisions.md).
+- v1 may recreate entries wholesale — stable id preservation deferred.
+
+## Notes from prior discussion
+
+- User agreed to full M2 plan before implementation.
+- M1 commit: `bfd0945d` — artifact store complete, 11 tests passing.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/01-registry-types.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/01-registry-types.md
new file mode 100644
index 000000000..0754563d1
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/01-registry-types.md
@@ -0,0 +1,140 @@
+# Phase 01 — Registry types + ParseCtx
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Add core registry types and wire module exports. No walker, no store integration
+yet.
+
+**In scope:**
+
+- `NodeDefId` (opaque handle, mirror `ArtifactId` pattern)
+- `DefSource { artifact_id, path: SlotPath }`
+- `NodeDefState` (`Loaded` / `ParseError` / `ValidationError` stub)
+- `NodeDefEntry` (source, state, `last_seen_revision: Revision`)
+- `NodeDefUpdates { added, changed, removed: BTreeSet }`
+- `RegistryError`
+- `ParseCtx<'a> { shapes: &'a SlotShapeRegistry }`
+- `registry/mod.rs` re-exports; update `lib.rs` to pub-export registry types
+
+**Out of scope:** Walker, registry impl, view, tests beyond type smoke if needed.
+
+## Code Organization Reminders
+
+- One concept per file under `registry/` (match M1 `artifact/` layout).
+- Public types / re-exports at top of `mod.rs`; impls in dedicated files.
+- Tests at bottom of files only if trivial; gate tests live in phase 05.
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Do **not** edit `lpc-engine`.
+- Report deviations.
+
+## Implementation Details
+
+### `node_def_id.rs`
+
+Mirror `artifact/artifact_id.rs`:
+
+```rust
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct NodeDefId(u32);
+
+impl NodeDefId {
+ pub fn from_raw(raw: u32) -> Self;
+ pub fn raw(self) -> u32;
+}
+```
+
+### `def_source.rs`
+
+```rust
+pub struct DefSource {
+ pub artifact_id: ArtifactId,
+ pub path: SlotPath,
+}
+```
+
+Implement `Eq`, `Hash`, `Ord` (derive or manual — must be stable map key).
+
+### `node_def_state.rs`
+
+```rust
+pub enum NodeDefState {
+ Loaded(NodeDef),
+ ParseError(NodeDefParseError),
+ ValidationError(ValidationErrorPlaceholder), // or unit/newtype stub
+}
+```
+
+Add accessor helpers: `is_loaded()`, `kind() -> Option` (from loaded
+def only).
+
+`ValidationError` is a placeholder enum/struct — document with `// M2: unused`
+comment; no semantic validator yet.
+
+### `node_def_entry.rs`
+
+```rust
+pub struct NodeDefEntry {
+ pub id: NodeDefId,
+ pub source: DefSource,
+ pub state: NodeDefState,
+ pub last_seen_revision: Revision,
+}
+```
+
+### `node_def_updates.rs`
+
+```rust
+#[derive(Default)]
+pub struct NodeDefUpdates {
+ pub added: BTreeSet,
+ pub changed: BTreeSet,
+ pub removed: BTreeSet,
+}
+
+impl NodeDefUpdates {
+ pub fn is_empty(&self) -> bool;
+ pub fn merge(&mut self, other: Self); // optional helper for tests
+}
+```
+
+### `registry_error.rs`
+
+Cover at minimum:
+
+- Locator/path resolution failure
+- Duplicate `DefSource` registration
+- Unknown `NodeDefId` lookup
+- Wrap `ArtifactError` from store ops
+
+### `parse_ctx.rs`
+
+```rust
+pub struct ParseCtx<'a> {
+ pub shapes: &'a SlotShapeRegistry,
+}
+```
+
+### `lib.rs`
+
+```rust
+pub mod registry;
+pub use registry::{
+ DefSource, NodeDefEntry, NodeDefId, NodeDefRegistry, NodeDefState,
+ NodeDefUpdates, ParseCtx, RegistryError,
+};
+```
+
+`NodeDefRegistry` can be an empty struct stub in `node_def_registry.rs` for now
+(phase 03 fills impl).
+
+## Validate
+
+```bash
+cargo check -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/02-def-walker-shell.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/02-def-walker-shell.md
new file mode 100644
index 000000000..e1434a5d7
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/02-def-walker-shell.md
@@ -0,0 +1,124 @@
+# Phase 02 — Def walker + shell helpers
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Implement static discovery of nested node defs and shell comparison for parent
+`changed` detection.
+
+**In scope:**
+
+- `def_walker.rs` — enumerate `NodeInvocation` sites from parsed `NodeDef`
+- `def_shell.rs` — shell view with inline bodies replaced by kind-only stubs
+- Unit tests for walker paths and shell/body distinction
+
+**Out of scope:** `NodeDefRegistry` store integration, `sync`.
+
+## Code Organization Reminders
+
+- Walker and shell are pure functions over `NodeDef` / `lpc-model` types.
+- Tests at bottom of each file.
+- Helper functions below tests in test module.
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Do **not** edit `lpc-engine` or `lpc-model`.
+- Report deviations.
+
+## Implementation Details
+
+### Walker output type
+
+```rust
+pub struct InvocationSite {
+ pub path: SlotPath, // path to the NodeInvocation field
+ pub invocation: NodeInvocation, // clone or ref in API — clone is fine for M2
+}
+```
+
+```rust
+pub fn collect_invocations(def: &NodeDef, base: &SlotPath) -> Vec;
+```
+
+**Traversal rules:**
+
+| `NodeDef` variant | Walk |
+|-------------------|------|
+| `Project` | For each `(name, inv)` in `nodes.entries`: path = `base.join_field(name)` |
+| `Playlist` | For each `(key, entry)` in `entries.entries`: path = `base.join_field("entries").join_key(key).join_field("node")` |
+| Other | No nested invocations (empty) |
+
+Use existing `SlotPath` APIs (`join_field`, `join_key`, or equivalent helpers
+from `lpc-model`). If no join helpers exist, extend path by pushing
+`SlotPathSegment` directly in this crate only — do not modify `lpc-model`.
+
+For **`Inline` defs** registered at a path: after registering inline body,
+recurse `collect_invocations(inline_def, &path)` to find nested invocations
+(e.g. inline playlist with inline entries).
+
+### Path resolution helper
+
+```rust
+pub fn resolve_node_specifier(
+ containing_file: &LpPath,
+ locator: &ArtifactSpecifier,
+) -> Result;
+```
+
+Mirror engine `resolve_path_specifier_from_dir` logic:
+
+- `ArtifactSpecifier::Path`: absolute as-is; relative joined to containing file's
+ parent directory.
+- `ArtifactSpecifier::Lib`: `RegistryError` (unsupported).
+
+Use `lpfs::LpPath` / `LpPathBuf`. Reference:
+`lpc-engine/src/engine/project_loader.rs` `resolve_path_specifier_from_dir` (read
+only — do not edit engine).
+
+### Shell helpers (`def_shell.rs`)
+
+```rust
+/// Parent-facing view: inline invocation bodies replaced with kind-only stubs.
+pub fn def_shell(def: &NodeDef) -> NodeDef;
+
+/// True when full bodies differ (for leaf / inline-child entries).
+pub fn body_changed(before: &NodeDef, after: &NodeDef) -> bool;
+
+/// True when parent shells differ.
+pub fn shell_changed(before: &NodeDef, after: &NodeDef) -> bool;
+```
+
+**Shell stub rule for inline invocations:**
+
+- Replace `NodeDefRef::Inline(full)` with `NodeDefRef::Inline(Box::new(kind_stub))`
+ where `kind_stub` is a minimal `NodeDef` of the same `NodeKind` (default
+ variant / empty struct — match pattern used in model tests).
+- Path invocations: compare locator string (`ArtifactSpecifier` display / path
+ field).
+
+**Kind in shell:** stub carries `NodeKind` — kind flip at invocation site
+must make `shell_changed` return true (feeds parent `changed` in phase 04).
+
+### Tests (this phase)
+
+1. **Project invocation paths** — parse minimal project TOML via
+ `NodeDef::from_toml_str`, assert sites at `nodes.clock`, `nodes.shader`.
+2. **Playlist inline path** — use inline child TOML from
+ `playlist_entry.rs` test; assert site at `entries.2.node`.
+3. **Shell vs body** — two playlist defs differing only in inline shader slot
+ → `body_changed` true on inline child, `shell_changed` false on playlist.
+4. **Kind flip** — inline child kind `Shader` → `Clock` → `shell_changed` true
+ on playlist.
+
+Keep tests under ~20 lines each; extract TOML fixtures to helper fns at bottom
+of test module.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry def_walker
+cargo test -p lpc-node-registry def_shell
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/03-registry-register.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/03-registry-register.md
new file mode 100644
index 000000000..6bac9144b
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/03-registry-register.md
@@ -0,0 +1,160 @@
+# Phase 03 — NodeDefRegistry `load_root` + artifact tracking
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Implement `NodeDefRegistry` bootstrap via **`load_root`**, internal entry storage,
+artifact acquire/release tracking, and recursive registration via walker.
+
+**In scope:**
+
+- `NodeDefRegistry` fields and **`load_root`** (public)
+- Private **`register_file_at_path`** for path-backed invocation recursion
+- Parse artifact bytes → entries at root + walked children
+- Track which `ArtifactId`s registry holds (for `sync` in phase 04)
+
+**Out of scope:** `sync`, diff/`NodeDefUpdates` emission, `NodeDefView`.
+
+## Code Organization Reminders
+
+- `node_def_registry.rs`: public API top (`load_root`), private helpers bottom,
+ tests at end (registration smoke tests only — full gate in phase 05).
+- Use phase 01 types and phase 02 walker/shell/resolve helpers.
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Do **not** edit `lpc-engine`.
+- Registry acquires/releases via M1 `ArtifactStore` only.
+- **`load_root`** is the only public bootstrap entry point.
+- Report deviations.
+
+## Implementation Details
+
+### `NodeDefRegistry` fields
+
+```rust
+pub struct NodeDefRegistry {
+ entries: BTreeMap,
+ source_index: BTreeMap,
+ artifact_refs: BTreeMap,
+ artifact_root_path: BTreeMap,
+ root_id: Option, // id from load_root, if any
+ next_id: u32,
+}
+```
+
+### `load_root` (public)
+
+```rust
+pub fn load_root(
+ &mut self,
+ store: &mut ArtifactStore,
+ fs: &dyn LpFs,
+ root_path: &LpPath,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+) -> Result
+```
+
+**Steps:**
+
+1. Require absolute `root_path` — return `RegistryError::InvalidPath` if not
+ absolute (or normalize if `LpPath` API supports it clearly).
+2. `path_buf = root_path.to_path_buf()` (or equivalent).
+3. `artifact_id = store.acquire_location(ArtifactLocation::file(path_buf.clone()), frame)`.
+4. `registry_acquire_artifact(artifact_id, &path_buf)` — increment
+ `artifact_refs`, record `artifact_root_path`.
+5. `load_and_register_root(store, fs, artifact_id, &path_buf, frame, ctx)?`.
+6. Store `root_id = Some(id)`; return root `NodeDefId`.
+
+Registry must be **empty** on first `load_root` in M2 (return error if not
+empty — or document `clear`/reload policy; prefer error on non-empty for M2).
+
+### `register_file_at_path` (private)
+
+Used when walker hits `NodeDefRef::Path(locator)`:
+
+```rust
+fn register_file_at_path(
+ &mut self,
+ store: &mut ArtifactStore,
+ fs: &dyn LpFs,
+ locator: &ArtifactSpecifier,
+ containing_file: &LpPath,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+) -> Result
+```
+
+1. `resolve_node_specifier(containing_file, locator)?` → absolute path.
+2. Acquire artifact + register root (same as steps in `load_root` for that file).
+3. Return child root `NodeDefId`.
+
+Do **not** expose locator/containing_dir on public API.
+
+### `load_and_register_root` (private)
+
+1. `store.read_bytes(&artifact_id, fs)?` → UTF-8 string.
+2. `NodeDef::read_toml(ctx.shapes, &text)` → `NodeDefState`.
+3. Insert entry at `DefSource { artifact_id, path: SlotPath::root() }`.
+4. If `Loaded`, call `register_invocations(...)` with walker output.
+
+### `register_invocations` (private, recursive)
+
+For each `InvocationSite` from `collect_invocations`:
+
+| `NodeDefRef` | Action |
+|--------------|--------|
+| `Path(loc)` | `register_file_at_path(store, fs, loc, parent_file, frame, ctx)` |
+| `Inline(body)` | Insert entry at `{ parent_artifact_id, site.path }`; recurse into inline body |
+
+**Duplicate `DefSource`:** `RegistryError::DuplicateSource`.
+
+### Entry insertion
+
+```rust
+fn insert_entry(
+ &mut self,
+ source: DefSource,
+ state: NodeDefState,
+ revision: Revision,
+) -> Result;
+```
+
+### Read failure handling
+
+If `read_bytes` fails or parse fails:
+
+- Still create entry at intended source with error state.
+- Do not skip identity creation (roadmap: identity separate from content).
+
+### `release_artifact_if_unused` (private, phase 04)
+
+Decrement `artifact_refs`; at zero call `store.release` and remove path maps.
+
+### Public accessors (for view + tests)
+
+```rust
+pub fn root_id(&self) -> Option;
+pub fn get(&self, id: &NodeDefId) -> Option<&NodeDefEntry>;
+pub fn get_by_source(&self, source: &DefSource) -> Option<&NodeDefEntry>;
+```
+
+### Smoke tests (this phase)
+
+Use **`load_root`** only (not internal helpers):
+
+1. `load_root("/playlist.toml")` with inline child fixture — root + inline child
+ in `source_index`.
+2. `load_root("/project.toml")` with path-backed nodes — multiple artifacts in
+ `artifact_refs`.
+3. Second `load_root` on non-empty registry → error.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry node_def_registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/04-update-diff.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/04-update-diff.md
new file mode 100644
index 000000000..b4ed377a1
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/04-update-diff.md
@@ -0,0 +1,124 @@
+# Phase 04 — `sync` + diff rules
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Implement **`sync`**: detect artifact revision bumps (after driver applies fs
+changes to store), re-derive def inventory, emit `NodeDefUpdates` with
+shell/body rules and kind-change behavior.
+
+**In scope:**
+
+- **`sync`** on `NodeDefRegistry` (public)
+- Per-artifact re-derive and diff against previous inventory
+- Shell vs body `changed` classification
+- Artifact release for removed defs
+
+**Out of scope:** `NodeDefView`, full gate test suite (phase 05).
+
+## Code Organization Reminders
+
+- Diff logic in `node_def_registry.rs` or `registry/def_diff.rs` if file grows
+ large.
+- Tests use driver pattern: `apply_fs_changes` then `sync`.
+- Tests at bottom of registry module for T1–T4 (T5 in phase 05).
+
+## Sub-agent Reminders
+
+- Do **not** commit.
+- Do **not** edit `lpc-engine`.
+- Registry must **not** call `store.apply_fs_changes` — tests/driver do that first.
+- **Kind change:** report in `changed`; comment documents M6 delete/recreate.
+- Report deviations.
+
+## Implementation Details
+
+### `sync` (public)
+
+```rust
+pub fn sync(
+ &mut self,
+ store: &mut ArtifactStore,
+ fs: &dyn LpFs,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+) -> NodeDefUpdates
+```
+
+### Driver sequence (document in module doc)
+
+```rust
+store.apply_fs_changes(&changes, frame);
+let updates = registry.sync(&mut store, fs, frame, ctx);
+```
+
+Registry reads `store.revision(id)` only — does not accept `FsChange` slice in M2.
+
+### Algorithm
+
+1. Collect tracked `ArtifactId`s from `artifact_refs`.
+2. For each artifact where `store.revision(id)` differs from last-derived
+ revision for that artifact:
+ - Build **new inventory** via `derive_artifact_inventory` (shared with phase 03).
+3. Build **old inventory** subset for that artifact from current `entries`.
+4. Diff:
+ - Keys only in old → `removed` (+ schedule artifact ref decrement)
+ - Keys only in new → `added`
+ - Keys in both → **changed** rules below
+5. Apply inventory to `entries` / `source_index`.
+6. Release artifacts whose ref count hit zero.
+7. Return merged `NodeDefUpdates`.
+
+Call with no pending revision bumps → empty updates.
+
+### Changed rules
+
+| Entry role | Comparison | → `changed` when |
+|------------|------------|------------------|
+| Root file def / inline leaf | **Body** (`body_changed`) | Full `NodeDef` differs, error state differs, or kind differs |
+| Container (`Project`, `Playlist`, …) | **Shell** (`shell_changed`) | Shell differs (inline invocation **kind** stub included) |
+
+**Inline child body edit:** child in `changed` only; parent not in `changed`.
+
+**Entry add/remove:** `added`/`removed` + parent in `changed` when shell differs.
+
+**Kind change:** child in `changed`; parent in `changed` when shell includes that invocation.
+
+**Parse error transitions:** `changed`.
+
+### Shared derive helper
+
+```rust
+fn derive_artifact_inventory(
+ store: &mut ArtifactStore,
+ fs: &dyn LpFs,
+ artifact_id: ArtifactId,
+ root_path: &LpPath,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+) -> Result, RegistryError>;
+```
+
+Used by `load_root` path and `sync` re-derive.
+
+### Tests (this phase)
+
+All tests: **`load_root`** first, then driver loop.
+
+1. **T1** — `load_root("/clock.toml")`; modify file; `apply_fs_changes`; `sync`
+ → root in `changed` only.
+2. **T2** — `load_root("/playlist.toml")` inline; edit inline shader slot → child
+ `changed`, playlist not.
+3. **T3** — add `[entries.3]` → `added` + playlist `changed`.
+4. **T4** — path-backed entry; modify child file → child `changed`, playlist not.
+
+Use `LpFsMemory`, `Revision` frames; reference M1 `apply_fs_changes` tests.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry sync
+cargo test -p lpc-node-registry node_def_registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/05-view-tests-cleanup.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/05-view-tests-cleanup.md
new file mode 100644
index 000000000..389cf1ee7
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/05-view-tests-cleanup.md
@@ -0,0 +1,103 @@
+# Phase 05 — NodeDefView stub + gate tests + cleanup
+
+**Dispatch:** [sub-agent: supervised, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Add `NodeDefView` stub, complete gate test coverage (including kind change),
+cleanup, fmt, clippy, and write `summary.md`.
+
+**In scope:**
+
+- `view/node_def_view.rs` stub
+- Gate tests T1–T5 (consolidate/deduplicate with phase 04 tests if needed)
+- `lib.rs` exports for view
+- `summary.md`
+- Remove stray TODOs; ensure warnings clean
+
+**Out of scope:** `lpc-engine` edits, ChangeSet overlay (M5).
+
+## Code Organization Reminders
+
+- View module stays minimal — lookup only.
+- Gate tests may live in `node_def_registry.rs` test module or
+ `registry/integration_tests.rs` if cleaner; prefer single test module at bottom
+ of `node_def_registry.rs` unless file is too large.
+- Test helpers at bottom of test module.
+
+## Sub-agent Reminders
+
+- Do **not** commit (supervisor commits after full plan validation).
+- Do **not** expand scope into engine or M3.
+- Fix warnings; do not suppress lints.
+- Report what changed and validation results.
+
+## Implementation Details
+
+### `NodeDefView` stub
+
+```rust
+pub struct NodeDefView<'a> {
+ registry: &'a NodeDefRegistry,
+}
+
+impl<'a> NodeDefView<'a> {
+ pub fn new(registry: &'a NodeDefRegistry) -> Self;
+ pub fn get(&self, id: &NodeDefId) -> Option<&NodeDefEntry>;
+ pub fn state(&self, id: &NodeDefId) -> Option<&NodeDefState>;
+}
+```
+
+Document in module doc: M5 adds ChangeSet overlay; M6 engine reads defs through
+view only.
+
+Update `lib.rs`:
+
+```rust
+pub mod view;
+pub use view::NodeDefView;
+```
+
+### Gate tests T1–T5
+
+| Test | Assert |
+|------|--------|
+| T1 `leaf_file_edit_marks_root_changed` | Root in `changed`; sets empty |
+| T2 `inline_child_edit_does_not_mark_parent_changed` | Child in `changed`; parent not |
+| T3 `playlist_entry_add_and_remove` | `added`/`removed` + parent `changed` |
+| T4 `path_child_file_edit_isolated` | Child `changed`; playlist not |
+| T5 `inline_child_kind_change_marks_child_and_parent_changed` | Both in `changed`; comment documents M6 delete/recreate |
+
+Use concise TOML fixtures; `LpFsMemory` with `/`-rooted paths. Every gate test
+follows **`load_root` → (optional fs mutate) → `apply_fs_changes` → `sync`**.
+
+Optional: one test loading a real file from `examples/basic/clock.toml` if
+repo-relative path in test is stable (use `include_str!` or env-free path from
+manifest dir via `CARGO_MANIFEST_DIR`).
+
+### `summary.md`
+
+Brief bullet summary:
+
+- Types added
+- API surface (`load_root`, `sync`, `NodeDefView`)
+- Test count
+- Kind-change engine contract note for M6
+
+### Cleanup checklist
+
+- [ ] No `TODO` without ticket/milestone reference
+- [ ] `cargo +nightly fmt --all`
+- [ ] All `lpc-node-registry` tests pass
+- [ ] Clippy clean
+
+## Validate
+
+```bash
+cargo +nightly fmt --all
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets -- -D warnings
+```
+
+Supervisor after all phases: single commit per `/implement` — not in this phase
+unless user requests.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/summary.md
new file mode 100644
index 000000000..f0b770ed4
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/summary.md
@@ -0,0 +1,32 @@
+# M2 Summary — NodeDefRegistry + NodeDefUpdates
+
+## What was built
+
+- **`NodeDefRegistry`** in `lpc-node-registry` with driver API:
+ - `load_root(store, fs, root_path, frame, ctx)` — bootstrap from any root node TOML
+ - `sync(store, fs, frame, ctx)` — re-derive after driver `apply_fs_changes`
+- Core types: `NodeDefId`, `DefSource`, `NodeDefState`, `NodeDefEntry`, `NodeDefUpdates`,
+ `ParseCtx`, `RegistryError`
+- **Def walker** for `Project` + `Playlist` invocations (`nodes[key]`, `entries[key].node`)
+- **Shell/body diff** — inline child edits do not mark parent `changed`; kind flips do
+- **`NodeDefView`** stub (base registry lookup; ChangeSet overlay deferred to M5)
+- **22 tests** (`cargo test -p lpc-node-registry`), including gate scenarios T1–T5
+
+## Decisions for future reference
+
+#### Driver-owned store + load_root/sync
+
+- **Decision:** Driver applies `ArtifactStore::apply_fs_changes`; registry exposes
+ `load_root` + `sync` only.
+- **Why:** Same loop for fs reload (M2/M4) and ChangeSet commit (M5).
+- **Rejected alternatives:** Public per-file `register_file`; registry calling
+ `apply_fs_changes` internally.
+
+#### Kind change → delete/recreate (M6)
+
+- **Decision:** Registry reports `changed`; engine must delete/recreate runtime
+ nodes when bound def kind flips.
+- **Why:** Kind determines runtime type and lifecycle.
+- **Revisit when:** M6 engine cutover.
+
+Plan: `docs/roadmaps/2026-05-21-artifact-routed-file-reload/m2-node-def-registry/`
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md
new file mode 100644
index 000000000..405370597
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md
@@ -0,0 +1,72 @@
+# Milestone 3: SourceFileSlot + SourceFileRef
+
+## Title And Goal
+
+Add **`SourceFileSlot`** / **`SourceFileRef`** and materialize API in the
+**parallel stack** — authored slot type, resolved ref, on-demand text — without
+switching production `ShaderDef` / nodes until **M6**.
+
+## Parallel Build
+
+- **`SourceFileSlot`** lands in **`lpc-model`** (new type + custom codec) — additive;
+ existing `ShaderSource` / `ShaderDef.source` **unchanged until M6**.
+- **`SourceFileRef` + materialize** land in **`lpc-node-registry`** — used by
+ registry parse/harness tests, not by `lpc-engine` nodes yet.
+- Fixture/shader **production defs** unchanged until **M6**; M5 uses harness defs only.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/`
+
+## Scope
+
+In scope:
+
+- `SourceFileSlot` custom codec in `lpc-model` (`$path`, shorthand string,
+ extension-key inline tables: `glsl`, `svg`, …).
+- `SourceFileRef` enum (file artifact / inline / future URL stub) in
+ `lpc-node-registry`.
+- Materialize API in `lpc-node-registry`: `{ version, text, diagnostic_name }`
+ on demand; register file paths in M1 `ArtifactStore` when resolving refs.
+- Tests in **`lpc-node-registry`** (+ `lpc-model` codec round-trips): TOML
+ encode/decode, materialize version for file bump vs inline edit, diagnostic
+ names.
+- Harness-only or test-only defs using `SourceFileSlot` where needed to exercise
+ the parallel stack (not production `ShaderDef` yet).
+
+Out of scope:
+
+- Replacing `ShaderSource` on production `ShaderDef` / `ComputeShaderDef` (**M6**).
+- Replacing fixture `MappingConfig` SVG fields on production defs (**M6**).
+- `ShaderNode` / `FixtureNode` compile path wiring (**M6**).
+- Any `lpc-engine` changes (**M6**).
+
+## Key Decisions
+
+- **Parallel:** new slot type exists alongside old; production unchanged until **M6**.
+- Nodes never store source text long-term; refs only in resolved slot data.
+- Effective version = f(slot revision, file artifact version) for file mode.
+- TOML encoding hard cut for **new** `SourceFileSlot`; all example projects at **M6**.
+
+## Deliverables
+
+- `lpc-model/src/slots/source_file.rs` (+ codec).
+- `lpc-node-registry/src/source/` — `SourceFileRef`, materialize.
+- Unit + registry integration tests.
+- `ShaderSource` remains until **M6**.
+
+## Dependencies
+
+- M1 ArtifactStore in `lpc-node-registry`.
+- M2 NodeDefRegistry (optional for end-to-end materialize tests).
+
+## Execution Strategy
+
+Full plan. Custom codec, ref resolution, and materialize span model + registry;
+clarify parallel vs **M6** migration in phase notes.
+
+Suggested chat opener:
+
+> This milestone needs a full plan — SourceFileSlot codec and materialize in the
+> parallel lpc-node-registry stack, without touching production ShaderDef until
+> M6. I'll run the plan process then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-design.md
new file mode 100644
index 000000000..6d7c99105
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-design.md
@@ -0,0 +1,92 @@
+# M3 Design — SourceFileSlot + SourceFileRef
+
+## Scope
+
+Add **`SourceFileSlot`** (`lpc-model`) and **`SourceFileRef` + materialize**
+(`lpc-node-registry`) in the parallel stack. Production `ShaderSource` unchanged
+until M6.
+
+## Driver flow
+
+```
+TOML → SourceFileSlot
+ ↓ resolve_source_file(store, containing_file, slot, frame)
+ SourceFileRef (no text)
+ ↓ materialize_source(store, fs, ref, slot, ctx)
+ MaterializedSource { version, text, diagnostic_name }
+```
+
+Driver owns `ArtifactStore` + `LpFs`. Resolve acquires file artifacts; materialize
+reads bytes transiently via `read_bytes`.
+
+## File structure
+
+```
+lp-core/lpc-model/src/slots/
+ source_file.rs # SourceFileSlot, backing, FieldSlot, codec hooks
+
+lp-core/lpc-node-registry/src/source/
+ mod.rs
+ source_file_ref.rs
+ materialized_source.rs
+ resolve.rs
+ materialize.rs
+```
+
+## Types
+
+### SourceFileSlot (authored)
+
+```rust
+pub enum SourceFileBacking {
+ Path(SourcePath),
+ Inline { extension: String, text: String },
+}
+
+pub struct SourceFileSlot { backing, revision }
+```
+
+Custom codec id: `lp::slots::SourceFileCodec`.
+
+TOML forms: `$path`, shorthand string, extension-key inline table.
+
+### SourceFileRef (resolved)
+
+```rust
+pub enum SourceFileRef {
+ File { artifact_id, authored_path, resolved_path, extension },
+ Inline { extension, slot_revision },
+ Url { .. }, // stub → Unsupported in M3
+}
+```
+
+### MaterializedSource
+
+```rust
+pub struct MaterializedSource {
+ pub version: Revision,
+ pub text: String,
+ pub diagnostic_name: String,
+}
+```
+
+**Effective version:** `max(slot.revision(), artifact.revision())` for file mode;
+slot revision only for inline.
+
+## Validation
+
+```bash
+cargo +nightly fmt --all
+cargo test -p lpc-model source_file
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+```
+
+## Plan phases
+
+| # | Phase | Dispatch |
+|---|-------|----------|
+| 01 | SourceFileSlot type + codec | composer-2.5-fast |
+| 02 | SourceFileRef + resolve | composer-2.5-fast |
+| 03 | Materialize + version tests | composer-2.5-fast |
+| 04 | Cleanup + summary | supervised |
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-notes.md
new file mode 100644
index 000000000..8304fe898
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/00-notes.md
@@ -0,0 +1,165 @@
+# M3 Plan Notes — SourceFileSlot + SourceFileRef
+
+## Scope of work
+
+Add **`SourceFileSlot`** (authored, `lpc-model`) and **`SourceFileRef` +
+materialize** (resolved, `lpc-node-registry`) in the **parallel stack**. Production
+`ShaderSource` / `ShaderDef.source` / fixture mapping fields **unchanged until M6**.
+
+In scope:
+
+- `SourceFileSlot` custom codec: `$path`, shorthand string, extension-key inline
+ (`glsl`, `svg`, …).
+- `SourceFileRef` enum: file artifact / inline / URL stub (stub only in M3).
+- **Resolve** authored slot → ref (acquire file artifact in M1 store).
+- **Materialize** ref (+ authored slot for inline body) →
+ `{ version, text, diagnostic_name }`.
+- Tests: codec round-trips (`lpc-model`); resolve + materialize + version bump
+ (`lpc-node-registry`).
+- Optional harness-only test def shape exercising `SourceFileSlot` in a parsed
+ `NodeDef` (not production `ShaderDef`).
+
+Out of scope:
+
+- Production `ShaderDef` / `ComputeShaderDef` / fixture `MappingConfig` migration
+ (**M6**).
+- `lpc-engine` / node compile paths (**M6**).
+- `BinaryFileSlot` (**future**).
+- ChangeSet / AssetView (**M5**).
+
+## Current state
+
+### Authored sources today
+
+- `ShaderSource` enum: `Path(SourcePathSlot)` | `Glsl(ValueSlot)` in
+ `lpc-model/src/nodes/shader/shader_source.rs`.
+- Fixture mapping uses separate `MappingConfig` variants (`SvgPath`, etc.).
+- Engine reads bytes at load time (`read_shader_source`, `resolve_fixture_mapping`).
+
+### Slot infrastructure
+
+- Custom codec dispatch in `slot_codec/custom_slot_codec.rs` (pattern:
+ `NodeInvocation` + `NODE_INVOCATION_CODEC_ID`).
+- Semantic leaves live under `lpc-model/src/slots/`.
+- `SourcePath` / `SourcePathSlot` exist for path-only leaves.
+
+### Parallel stack (M1–M2 done)
+
+- `ArtifactStore`: acquire/release, `read_bytes`, `revision`, `apply_fs_changes`.
+- `NodeDefRegistry`: `load_root`, `sync`, `NodeDefUpdates`.
+- `lpc-node-registry/src/source/` is a stub.
+
+### Roadmap encoding (agreed)
+
+```toml
+# file
+source = { $path = "./shader.glsl" }
+source = "./shader.glsl"
+
+# inline
+[source]
+glsl = """
+void main() {}
+"""
+```
+
+Exactly one of `$path`, shorthand string, or extension-key inline table.
+
+## Architecture sketch (proposed)
+
+```
+TOML parse ──► SourceFileSlot (authored backing in NodeDef)
+ │
+ ▼ resolve_source_file(store, containing_file, slot, frame)
+ SourceFileRef (handle — no text)
+ │
+ ▼ materialize_source(store, fs, ref, slot, diagnostic_ctx)
+ MaterializedSource { version, text, diagnostic_name }
+```
+
+- **File-backed:** resolve acquires `ArtifactLocation::file(resolved_path)`;
+ ref holds `ArtifactId` + authored relative path + optional extension hint.
+- **Inline:** ref holds extension + slot revision; text read from authored slot
+ at materialize (not stored in ref).
+- **Effective version:** combine slot revision + artifact revision for file mode;
+ slot revision only for inline. M4 uses version change without def TOML change.
+
+## Open questions
+
+### Q1 — Authored vs resolved split
+
+**Context:** Roadmap: nodes hold `SourceFileRef`, not text. Authored defs hold
+`SourceFileSlot`.
+
+**Suggested:** M3 implements both types. **Resolve** is explicit (not implicit at
+parse). **Materialize** takes ref + authored slot (for inline body + slot revision).
+
+### Q2 — Extension keys
+
+**Context:** Inline format identified by table key (`glsl`, `svg`, `wgsl`, …).
+
+**Suggested:** M3 accepts any non-reserved string key as inline extension; no
+fixed allowlist yet. `$path` is reserved; `path` field name left free for future
+`.path` artifact type.
+
+### Q3 — Diagnostic names
+
+**Context:** Compile errors need stable labels.
+
+**Suggested:**
+
+| Backing | `diagnostic_name` |
+|---------|---------------------|
+| File | Project-relative path as authored (e.g. `./shader.glsl`) |
+| Inline | `{containing_file}:{slot_path}.{ext}` (e.g. `/shader.toml:source.glsl`) |
+
+Pass `containing_file` + optional `SlotPath` into materialize/resolve context.
+
+### Q4 — Test def shape
+
+**Context:** Milestone allows harness-only defs; production `ShaderDef` untouched.
+
+**Suggested:** M3 gate tests use:
+
+1. Direct `SourceFileSlot` codec round-trips in `lpc-model`.
+2. `lpc-node-registry` tests with inline TOML fixtures embedding a minimal
+ **`TestSourceDef`** (`kind = "TestSource"`) registered only for tests — **or**
+ standalone resolve/materialize tests without a new `NodeKind` if we can avoid
+ registry coupling.
+
+Prefer **standalone materialize tests** + codec tests; add `TestSourceDef` only if
+needed for end-to-end `load_root` integration.
+
+### Q5 — URL stub
+
+**Context:** `SourceFileRef` includes future URL variant.
+
+**Suggested:** `SourceFileRef::Url { .. }` stub variant; resolve returns
+`MaterializeError::Unsupported` in M3.
+
+### Q6 — Shorthand string disambiguation
+
+**Context:** `source = "./shader.glsl"` must not collide with inline string that
+looks like a path.
+
+**Suggested:** Shorthand string form is **only** valid when the TOML value is a
+**string scalar** (not inline table). Inline must use extension-key table form.
+
+## Resolved decisions (roadmap)
+
+- Parallel build; no `lpc-engine` edits until M6.
+- No long-lived source text in refs or slot resolved values.
+- File artifact registration via M1 store on resolve.
+- Hard cut TOML encoding for new slot; example project migration at M6.
+
+## Dependencies
+
+- M1 `ArtifactStore` (done).
+- M2 `NodeDefRegistry` (done) — optional for integrated tests.
+
+## Notes
+
+- M4 scenario: GLSL file edit bumps artifact → materialize version changes,
+ `NodeDefUpdates` empty if node TOML unchanged.
+- M5 `AssetView` will feed materialize for uncommitted asset replaces; M3 uses
+ store + fs only.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/01-source-file-slot-codec.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/01-source-file-slot-codec.md
new file mode 100644
index 000000000..3fd263bcd
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/01-source-file-slot-codec.md
@@ -0,0 +1,48 @@
+# Phase 01 — SourceFileSlot type + codec
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Add authored **`SourceFileSlot`** in `lpc-model` with custom codec dispatch.
+
+**In scope:**
+
+- `SourceFileBacking` (`Path` / `Inline { extension, text }`)
+- `SourceFileSlot` with `revision()`, `path_value()`, `inline_value()`
+- Custom codec id `lp::slots::SourceFileCodec`
+- TOML forms: shorthand string, `$path` table, extension-key inline table
+- Wire into `custom_slot_codec.rs` (read / write json / write toml / snapshot)
+- Export from `slots/mod.rs` and `lib.rs`
+- `ValueReader::is_string_scalar()` for shorthand disambiguation
+- Unit tests in `source_file.rs`
+
+**Out of scope:** `SourceFileRef`, resolve/materialize, production `ShaderDef` migration.
+
+## Implementation Details
+
+### `slots/source_file.rs`
+
+- `SOURCE_FILE_CODEC_ID` = `SlotShapeId::from_static_name("lp::slots::SourceFileCodec")`
+- Reserved key: `$path` (not `path`)
+- Inline table: exactly one extension key (any non-reserved string)
+- `FieldSlot` + `SlotCustomAccess` impls (mirror `NodeInvocation` pattern)
+
+### `slot_codec/slot_reader.rs`
+
+Add `ValueReader::is_string_scalar()` using `peek_event()` so path shorthand
+does not collide with inline table parsing.
+
+### Tests
+
+- Parse `./shader.glsl` shorthand
+- Parse `$path = "./shader.glsl"`
+- Parse `glsl = "void main() {}"`
+- Round-trip path shorthand to TOML
+
+## Validate
+
+```bash
+cargo test -p lpc-model source_file
+cargo check -p lpc-model
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/02-source-file-ref-resolve.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/02-source-file-ref-resolve.md
new file mode 100644
index 000000000..2015fa978
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/02-source-file-ref-resolve.md
@@ -0,0 +1,44 @@
+# Phase 02 — SourceFileRef + resolve
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Add resolved handle type and explicit resolve step in `lpc-node-registry`.
+
+**In scope:**
+
+- `SourceFileRef` enum (`File` / `Inline` / `Url` stub)
+- `ResolveError`
+- `resolve_source_file(store, containing_file, slot, frame)`
+- Path resolve via `resolve_node_specifier` (reuse registry helper)
+- File mode acquires `ArtifactLocation::file(resolved_path)` in store
+- `pub(crate) use def_walker::resolve_node_specifier` from registry
+- Unit tests: path acquire + inline revision
+
+**Out of scope:** Reading bytes, version combine, `ShaderDef` cutover.
+
+## Implementation Details
+
+### `source/source_file_ref.rs`
+
+```rust
+pub enum SourceFileRef {
+ File { artifact_id, authored_path, resolved_path, extension },
+ Inline { extension, slot_revision },
+ Url { url },
+}
+```
+
+### `source/resolve.rs`
+
+- `SourceFileBacking::Path` → resolve relative to `containing_file`, acquire artifact
+- `SourceFileBacking::Inline` → no store acquire; carry slot revision + extension
+- Extension hint from file path suffix for `File` variant
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry resolve
+cargo check -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/03-materialize-version-tests.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/03-materialize-version-tests.md
new file mode 100644
index 000000000..556708a18
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/03-materialize-version-tests.md
@@ -0,0 +1,48 @@
+# Phase 03 — Materialize + version tests
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Read transient UTF-8 text and compute effective revision for M4 file-bump scenarios.
+
+**In scope:**
+
+- `MaterializedSource { version, text, diagnostic_name }`
+- `SourceDiagnosticCtx { containing_file, slot_path }`
+- `MaterializeError` (`Unsupported`, `Utf8`, `MissingInlineBody`, `Artifact`)
+- `materialize_source(store, fs, ref, slot, ctx)`
+- Effective version: `max(slot.revision(), artifact.revision())` for file;
+ `slot.revision()` for inline
+- Diagnostic names: authored path (file); `{containing_file}:source.{ext}` (inline)
+- `Url` ref → `MaterializeError::Unsupported`
+- Tests: file read, inline read, fs modify bump without slot edit
+
+**Out of scope:** ChangeSet / AssetView (M5), engine integration (M6).
+
+## Implementation Details
+
+### File mode
+
+1. `store.read_bytes(artifact_id, fs)`
+2. UTF-8 decode
+3. `version = slot.revision().max(store.revision(artifact_id))`
+4. `diagnostic_name = authored_path`
+
+### Inline mode
+
+1. Text from `slot.inline_value()` at materialize time
+2. `version = slot.revision()`
+3. `diagnostic_name` from `SourceDiagnosticCtx`
+
+### Gate test (M4 preview)
+
+File content changes via `apply_fs_changes` → materialize version increases while
+authored slot revision unchanged.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry materialize
+cargo test -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/04-cleanup-summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/04-cleanup-summary.md
new file mode 100644
index 000000000..0b34d1d44
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/04-cleanup-summary.md
@@ -0,0 +1,35 @@
+# Phase 04 — Cleanup + summary
+
+**Dispatch:** [sub-agent: no, supervised]
+
+## Scope of phase
+
+Finalize M3 exports, formatting, clippy, and milestone summary.
+
+**In scope:**
+
+- `lpc-node-registry/src/lib.rs` re-exports for source API
+- `cargo +nightly fmt --all`
+- Clippy on touched crates
+- `summary.md` with decisions for M4/M6
+- Confirm production `ShaderSource` / `lpc-engine` untouched
+
+**Out of scope:** M4 harness, M6 cutover.
+
+## Validate
+
+```bash
+cargo +nightly fmt --all
+cargo test -p lpc-model source_file
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+cargo clippy -p lpc-model --all-targets --no-deps -- -D warnings
+```
+
+## Summary checklist
+
+- [x] `SourceFileSlot` codec round-trips
+- [x] `resolve_source_file` acquires file artifacts
+- [x] `materialize_source` combines revisions correctly
+- [x] File bump test passes without def TOML change
+- [x] No edits under `lpc-engine`
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/summary.md
new file mode 100644
index 000000000..9c39eea4f
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/summary.md
@@ -0,0 +1,35 @@
+# M3 Summary — SourceFileSlot + SourceFileRef
+
+## What was built
+
+- **`SourceFileSlot`** (`lpc-model`): authored file-or-inline UTF-8 source with custom
+ codec (`$path`, shorthand string, extension-key inline table).
+- **`SourceFileRef`** + **`resolve_source_file`**: handle-only resolved refs; file mode
+ acquires artifacts in M1 `ArtifactStore`.
+- **`MaterializedSource`** + **`materialize_source`**: transient UTF-8 read via
+ `read_bytes`; effective version `max(slot, artifact)` for files, slot-only for inline.
+- **`SourceDiagnosticCtx`**: stable diagnostic labels for compile errors.
+- Tests in `lpc-model` (codec) and `lpc-node-registry` (resolve, materialize, file bump).
+
+Production `ShaderSource` / `lpc-engine` paths unchanged (M6 cutover).
+
+## Decisions for future reference
+
+#### Explicit resolve vs parse-time acquire
+
+- **Decision:** Driver calls `resolve_source_file` after parse; refs hold handles only.
+- **Why:** Separates authored shape from artifact lifecycle; same pattern as M2 registry.
+- **Revisit when:** M6 engine wires resolve into node load.
+
+#### Effective version = max(slot, artifact) for files
+
+- **Decision:** File edits bump artifact revision even when node TOML unchanged.
+- **Why:** M4 fs-change scenario depends on version change without def diff.
+- **Revisit when:** M5 AssetView may add a third revision source.
+
+#### URL stub unsupported in M3
+
+- **Decision:** `SourceFileRef::Url` exists; materialize returns `Unsupported`.
+- **Why:** Reserve enum shape without committing fetch/cache semantics.
+
+Plan: `docs/roadmaps/2026-05-21-artifact-routed-file-reload/m3-source-file-slot/`
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md
new file mode 100644
index 000000000..386e83519
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md
@@ -0,0 +1,61 @@
+# Milestone 4: Fs-Change Semantics Harness
+
+## Title And Goal
+
+Prove end-to-end **reload semantics** in **`lpc-node-registry` test harness**
+only — simulated `FsChange` → artifact bumps → `NodeDefUpdates` → expected
+node lifecycle actions. **Production `lpc-engine` unchanged.**
+
+## Parallel Build
+
+The harness proves **`NodeDefRegistry::sync(changes) -> SyncResult`** in tests
+only. Production `lpc-engine` unchanged until M6.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/`
+
+## Scope
+
+In scope:
+
+- **API refactor:** registry owns state; `sync` takes `RegistryChange` batch
+ (fs in M4), applies, returns **`SyncResult`** (factual diff).
+- Harness fixtures + scenario tests S1–S6.
+- **`engine-policy-v1.md`** — how M6 engine would react (not registry output).
+
+Out of scope:
+
+- Production engine cutover (**M6**).
+- `RegistryChange::ChangeSet` variants — [ChangeSet M5](../2026-05-21-changeset-change-management/m5-commit-promotion.md); enum stub OK.
+- ChangeSet / client change management ([promoted roadmap](../2026-05-21-changeset-change-management/overview.md)).
+- Server `LpServer` fs routing (**M7**).
+- `project.toml` topology changes (**M8**).
+- Any edits to `lpc-engine` or `lpa-server`.
+
+## Key Decisions
+
+- Harness is prerequisite for [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md); **M4 + ChangeSet M6** gate **M6**.
+- v1 node refresh rule may be coarse (recreate all nodes bound to changed defs).
+- Error propagation: no last-good; def error → node destroy → parent error.
+
+## Deliverables
+
+- **`sync(changes) -> SyncResult`** API on `NodeDefRegistry`
+- Scenario tests S1–S6
+- `engine-policy-v1.md` for M6
+
+## Dependencies
+
+- M1, M2, M3 complete.
+
+## Execution Strategy
+
+Full plan. Multiple scenarios, harness API, and action expectations need a
+design pass before tests are written.
+
+Suggested chat opener:
+
+> This milestone needs a full plan — fs-change harness, scenario matrix, and
+> expected engine action assertions. I'll run the plan process then implement.
+> Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-design.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-design.md
new file mode 100644
index 000000000..ab48e4b7f
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-design.md
@@ -0,0 +1,151 @@
+# M4 Design — Fs-Change Semantics Harness
+
+## Scope
+
+Refine **`NodeDefRegistry`** to a simple stateful API: **apply changes → update
+state → return diff**. Prove semantics in tests. No `lpc-engine` edits.
+
+**Registry = past tense.** Engine policy documented in `engine-policy-v1.md` only.
+
+## Core API (revised from M2)
+
+M2 split `apply_fs_changes` + `sync` across driver and store. M4 consolidates:
+
+**Registry owns its state** (including `ArtifactStore`). Caller does not orchestrate
+store bumps separately.
+
+```rust
+impl NodeDefRegistry {
+ /// Bootstrap once. Registry acquires artifacts and registers defs.
+ pub fn load_root(
+ &mut self,
+ fs: &dyn LpFs,
+ root_path: &LpPath,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+ ) -> Result;
+
+ /// Apply incoming changes, update internal state, return summary.
+ pub fn sync(
+ &mut self,
+ fs: &dyn LpFs,
+ changes: &[RegistryChange],
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+ ) -> SyncResult;
+}
+```
+
+M4: `RegistryChange::Fs(FsChange)` only.
+M5: extend enum with ChangeSet commit ops — **same `sync` entry point**.
+
+### `sync` contract (functional)
+
+1. Snapshot minimal **before** state needed for diff (def states, source versions).
+2. **Apply** each change to internal artifact store / registry state.
+3. **Re-derive** affected defs (paths in change batch + dependents).
+4. **Diff** before vs after → `SyncResult`.
+5. Return summary; updated state remains in registry.
+
+No separate public `ArtifactStore`. No caller-side `apply_fs_changes`. No
+harness-side source index with cross-call snapshots.
+
+Internal bookkeeping (e.g. which artifact backs which def) is implementation
+detail — not a second API surface.
+
+## File structure
+
+```
+lp-core/lpc-node-registry/src/
+├── registry/
+│ ├── node_def_registry.rs # owns ArtifactStore; load_root + sync
+│ ├── sync_result.rs # SyncResult, DefChangeDetail, SourceRevisionBump
+│ ├── registry_change.rs # RegistryChange enum (Fs in M4)
+│ ├── source_deps.rs # per-entry source dep + version (internal)
+│ └── source_bridge.rs # ShaderDef/SvgPath → M3 materialize (internal)
+├── harness/ # #[cfg(test)] — fixtures + helpers only
+│ ├── fixtures.rs
+│ └── bindings.rs
+└── tests/
+ └── fs_change_semantics.rs
+```
+
+## Types
+
+### `RegistryChange`
+
+```rust
+pub enum RegistryChange {
+ Fs(FsChange),
+ // M5: ChangeSetCommit(...), AssetReplace(...), etc.
+}
+```
+
+### `SyncResult`
+
+```rust
+pub struct SourceRevisionBump {
+ pub def_id: NodeDefId,
+ pub before: Revision,
+ pub after: Revision,
+}
+
+pub enum DefChangeDetail {
+ Content,
+ KindChanged { from: NodeKind, to: NodeKind },
+ EnteredError,
+ LeftError,
+}
+
+pub struct SyncResult {
+ pub def_updates: NodeDefUpdates,
+ pub source_revisions: Vec,
+ pub change_details: Vec<(NodeDefId, DefChangeDetail)>,
+}
+```
+
+`NodeDefUpdates` fields → **`Vec`** (embedded-friendly).
+
+### Source revisions (inside `sync`, not harness)
+
+When a fs change touches a file artifact (e.g. `/shader.glsl`):
+
+- Registry finds defs with that source dependency (recorded on entry at load/re-derive).
+- Re-materializes via M3 bridge; if version increased → push `SourceRevisionBump`.
+- Def TOML unchanged → def **not** in `def_updates.changed`.
+
+## Memory / `lp-collection`
+
+Cold path — prefer RAM over CPU. `Vec` in `SyncResult`. Consider `DenseIdMap` for
+registry entries when refactoring internals. `lp-collection` optional; add when used.
+
+## Scenario matrix (gate)
+
+| ID | Scenario | `sync(changes)` → |
+|----|----------|-------------------|
+| S1 | Leaf TOML edit | root in `def_updates.changed` |
+| S2 | GLSL edit only | empty def updates; shader in `source_revisions` |
+| S3 | SVG edit only | empty def updates; fixture in `source_revisions` |
+| S4 | Inline child edit | child changed; parent not |
+| S5a | Leaf parse error | root changed; `EnteredError` |
+| S5b | Inline parse error | child changed; `EnteredError` |
+| S6 | Kind change | both changed; `KindChanged` |
+
+## Validation
+
+```bash
+cargo +nightly fmt --all
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test fs_change_semantics
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+```
+
+## Plan phases
+
+| # | Phase | Dispatch |
+|---|-------|----------|
+| 01 | Unified sync API + SyncResult + Vec updates | composer-2.5-fast |
+| 02 | Source deps + revisions inside sync | composer-2.5-fast |
+| 03 | DefChangeDetail diff in sync | composer-2.5-fast |
+| 04 | Scenario tests S1–S6 | composer-2.5-fast |
+| 05 | M2 test migration + summary + cleanup | supervised |
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-notes.md
new file mode 100644
index 000000000..0ca681464
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/00-notes.md
@@ -0,0 +1,67 @@
+# M4 Plan Notes — Fs-Change Semantics Harness
+
+## Scope
+
+Refactor registry to **simple stateful sync**: apply changes → update state →
+return **`SyncResult`**. Prove fs-change semantics in tests. No `lpc-engine` edits.
+
+## API direction (user-aligned)
+
+**Before (M2):** caller owns `ArtifactStore`, calls `apply_fs_changes`, then
+`registry.sync()` with no changes parameter.
+
+**After (M4):** registry owns state; one call:
+
+```rust
+let result = registry.sync(fs, changes, frame, ctx);
+// result.def_updates, result.source_revisions, result.change_details
+```
+
+- Functional: state in, changes in, summary out.
+- No external tracking layers or harness-side diff indexes.
+- M5 extends `RegistryChange` with ChangeSet ops — same `sync`.
+
+Engine interprets `SyncResult`; registry does not emit `RefreshNode` etc.
+
+## What `sync` does internally
+
+1. Snapshot before (def state + source versions) — **inside** sync only.
+2. Apply `RegistryChange` batch to artifact store.
+3. Re-derive defs for affected artifacts.
+4. Diff → `SyncResult`.
+
+Remove or fold **`artifact_last_revision`** — change batch drives what to
+re-derive (plus source-dependency lookup for file-only edits like GLSL).
+
+## Source file bumps (S2/S3)
+
+Not a separate harness index. Registry records source dependencies on entries
+(at load / re-derive). When sync applies a change to `/shader.glsl`, registry
+finds dependent defs, re-materializes, emits `source_revisions` if version bumped.
+
+Uses internal `source_bridge` (production `ShaderDef` / fixture `SvgPath` → M3).
+
+## Memory
+
+- `SyncResult` and `NodeDefUpdates` use **`Vec`**, not `BTreeSet`/`BTreeMap`.
+- Cold path: RAM over CPU; `lp-collection` when it helps (`DenseIdMap` for entries).
+
+## Open questions — resolved
+
+| Q | Resolution |
+|---|------------|
+| ReloadReport name | **`SyncResult`** — nothing "reloads" at registry layer |
+| Two-step apply + sync | **Single `sync(changes)`** |
+| Harness source index | **Inside registry** as entry deps, not harness module |
+| Engine actions | **`engine-policy-v1.md` only** |
+| Public ArtifactStore | **Owned by registry**; not caller-facing in sync path |
+
+## Out of scope
+
+- `lpc-engine` cutover (M6)
+- ChangeSet variant on `RegistryChange` (M5 — enum stub OK in M4)
+- Registry internal map rewrite to `DenseIdMap` (optional stretch)
+
+## Dependencies
+
+M1–M3 complete. M4 refactors M2 public API (breaking for tests; same crate).
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/01-unified-sync-api.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/01-unified-sync-api.md
new file mode 100644
index 000000000..1fa883fac
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/01-unified-sync-api.md
@@ -0,0 +1,67 @@
+# Phase 01 — Unified sync API + SyncResult
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Consolidate M2 two-step flow into **`NodeDefRegistry::sync(changes) -> SyncResult`**.
+
+**In scope:**
+
+- Registry **owns** `ArtifactStore` (move field inside `NodeDefRegistry`)
+- `registry_change.rs` — `RegistryChange::Fs(FsChange)`
+- `sync_result.rs` — `SyncResult { def_updates, source_revisions, change_details }`
+ (source/details filled in later phases; empty vecs OK initially)
+- New `sync(fs, changes, frame, ctx) -> SyncResult`:
+ - apply fs changes to internal store
+ - re-derive affected defs (by changed paths)
+ - diff → `NodeDefUpdates` in result
+- `NodeDefUpdates` → **`Vec`** per field
+- Update `load_root` signature: no external `store` param
+- Migrate existing M2 unit tests to new API
+- `harness/fixtures.rs` — TOML constants for scenarios
+
+**Out of scope:** Source revision bumps, DefChangeDetail, integration test file.
+
+## API migration
+
+```rust
+// Before
+store.apply_fs_changes(&changes, frame);
+let updates = registry.sync(&mut store, fs, frame, ctx);
+
+// After
+let result = registry.sync(fs, &changes.iter().map(RegistryChange::Fs).collect(), frame, ctx);
+let updates = result.def_updates;
+```
+
+For M4 ergonomics, also accept:
+
+```rust
+pub fn sync_fs(&mut self, fs, changes: &[FsChange], frame, ctx) -> SyncResult
+```
+
+as thin wrapper over `sync` with `RegistryChange::Fs` mapping.
+
+## Internal simplification
+
+- Drive re-derive from **paths in `changes`**, not `artifact_last_revision` map.
+- Keep `entries` / `source_index` maps until DenseIdMap pass (optional).
+
+## Memory
+
+- `NodeDefUpdates`: `Vec` not `BTreeSet`
+- `SyncResult`: `Vec` fields
+
+## Sub-agent Reminders
+
+- Do not commit.
+- Breaking API change within crate is OK; fix all tests.
+- No `set_current_revision` in tests.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo check -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/02-source-deps-in-sync.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/02-source-deps-in-sync.md
new file mode 100644
index 000000000..29145fe80
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/02-source-deps-in-sync.md
@@ -0,0 +1,48 @@
+# Phase 02 — Source deps + revisions inside sync
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+GLSL/SVG file edits produce **`source_revisions`** from **`sync` itself** — no
+harness index.
+
+**In scope:**
+
+- `registry/source_deps.rs` — record resolved source paths per def on load/re-derive
+- `registry/source_bridge.rs` — internal; Shader/SvgPath → M3 materialize version
+- During `sync`: when a changed path is a **source file** (not def TOML), find
+ dependent defs, re-materialize, append `SourceRevisionBump` to result
+- Test: S2 shape — glsl change only → empty `def_updates`, non-empty `source_revisions`
+
+**Out of scope:** DefChangeDetail, integration scenarios, ChangeSet variant.
+
+## Design
+
+On each loaded/re-derived def entry, store:
+
+```rust
+struct SourceDep {
+ resolved_path: LpPathBuf, // or ArtifactId after acquire
+ last_version: Revision,
+}
+// Vec or small inline vec on entry — file-backed sources only
+```
+
+When `sync` applies `FsChange` to path P:
+
+1. Re-derive defs if P is a def artifact (existing logic)
+2. Else if P matches any entry's `SourceDep.resolved_path`, re-materialize and
+ compare version → maybe push bump
+
+## Memory
+
+- Small **`Vec`** of deps per def (typically 0–1 for M4 fixtures)
+- `source_revisions` in result: sparse `Vec`
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry source
+cargo test -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/03-sync-change-details.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/03-sync-change-details.md
new file mode 100644
index 000000000..ba9ac0254
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/03-sync-change-details.md
@@ -0,0 +1,22 @@
+# Phase 03 — DefChangeDetail in sync diff
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+`sync` populates **`change_details`** by diffing def state snapshot (inside sync).
+
+**In scope:**
+
+- `DefChangeDetail` enum in `sync_result.rs`
+- During sync: snapshot def states before apply; after re-derive, classify each
+ `changed` id → `Content`, `KindChanged`, `EnteredError`, `LeftError`
+- Unit tests for detail classification
+
+**Out of scope:** Integration file, engine policy.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry sync
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/04-scenario-tests.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/04-scenario-tests.md
new file mode 100644
index 000000000..98de5a00d
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/04-scenario-tests.md
@@ -0,0 +1,33 @@
+# Phase 04 — Scenario tests S1–S6
+
+**Dispatch:** [sub-agent: yes, model: composer-2.5-fast, parallel: -]
+
+## Scope of phase
+
+Integration tests calling **`registry.sync`** directly.
+
+**In scope:**
+
+- `tests/fs_change_semantics.rs`
+- `harness/fixtures.rs` — load files into `LpFsMemory`, `load_root`, then `sync_fs`
+- Assert `SyncResult` fields only (no engine actions)
+
+## Example
+
+```rust
+let mut registry = NodeDefRegistry::new();
+let fs = fixtures::load_shader_project();
+registry.load_root(&fs, LpPath::new("/shader.toml"), frame, &ctx).unwrap();
+
+fixtures::write_file(&mut fs, "/shader.glsl", NEW_GLSL);
+let result = registry.sync_fs(&fs, &[fs_change("/shader.glsl")], frame, &ctx);
+
+assert!(result.def_updates.is_empty());
+assert!(result.source_revisions.iter().any(|b| b.def_id == shader_id));
+```
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry --test fs_change_semantics
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/05-cleanup-summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/05-cleanup-summary.md
new file mode 100644
index 000000000..1c0d499b0
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/05-cleanup-summary.md
@@ -0,0 +1,23 @@
+# Phase 05 — Summary + cleanup
+
+**Dispatch:** [sub-agent: no, supervised]
+
+## Scope
+
+- `summary.md` — API + scenario matrix
+- `engine-policy-v1.md` — inputs now `SyncResult`
+- Remove obsolete plan files / stale M2 driver docs references
+- Clippy + fmt
+
+## Checklist
+
+- [ ] `sync(changes) -> SyncResult` is the public steady-state API
+- [ ] No public two-step apply + sync
+- [ ] S1–S6 pass
+- [ ] M2 T1–T5 migrated
+
+## Validate
+
+```bash
+just check # or cargo test -p lpc-node-registry && clippy
+```
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md
new file mode 100644
index 000000000..497e4c1ba
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md
@@ -0,0 +1,27 @@
+# Engine Policy v1 (M6 reference)
+
+**Not registry output.** How **`lpc-engine`** interprets **`SyncResult`**.
+
+## Input
+
+```rust
+// After: let result = registry.sync(fs, changes, frame, ctx);
+result.def_updates // added / changed / removed def ids
+result.source_revisions // file-backed source version bumps
+result.change_details // KindChanged, EnteredError, etc.
+```
+
+Plus engine-owned runtime binding graph (def id → live node).
+
+## Suggested v1 policy
+
+| `SyncResult` signal | Engine effect |
+|---------------------|---------------|
+| `def_updates.added` | Attach / create node |
+| `def_updates.removed` | Destroy node |
+| `changed` + `KindChanged` | Delete + recreate node |
+| `changed` + `Content` / other | Refresh / re-prepare |
+| `changed` + `EnteredError` | Destroy node; cascade parent error |
+| `source_revisions` (def not in `changed`) | Re-prepare products (recompile GLSL, etc.) |
+
+M4 tests **`SyncResult` only** — not these effects.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/summary.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/summary.md
new file mode 100644
index 000000000..78efdf98a
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/summary.md
@@ -0,0 +1,27 @@
+# M4 Summary — Fs-Change Semantics Harness
+
+
+
+## API (target)
+
+```rust
+registry.load_root(fs, root_path, frame, ctx)? ;
+let result = registry.sync(fs, changes, frame, ctx);
+// result: SyncResult { def_updates, source_revisions, change_details }
+```
+
+Registry owns state. **`sync`** applies changes, updates state, returns factual diff.
+M5 adds ChangeSet variants to `RegistryChange`.
+
+## Scenarios (gate)
+
+| ID | Input | `SyncResult` |
+|----|-------|--------------|
+| S1 | def TOML edit | def changed |
+| S2 | GLSL only | source_revisions |
+| S3 | SVG only | source_revisions |
+| S4 | inline child edit | child changed |
+| S5 | parse error | EnteredError |
+| S6 | kind flip | KindChanged |
+
+Engine policy: `engine-policy-v1.md`.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m5-changeset-change-management.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m5-changeset-change-management.md
new file mode 100644
index 000000000..c98cb55cb
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m5-changeset-change-management.md
@@ -0,0 +1,22 @@
+# Milestone 5: ChangeSet / Change Management
+
+**Promoted to standalone roadmap:**
+
+[`docs/roadmaps/2026-05-21-changeset-change-management/overview.md`](../2026-05-21-changeset-change-management/overview.md)
+
+ChangeSet change management is no longer tracked as an inline milestone plan here.
+Use the promoted roadmap for milestones M1–M7, user stories, and implementation
+plans.
+
+## Gate for this roadmap
+
+**M6 (engine cutover)** below may proceed — prerequisites are green:
+
+- **M4** (fs-change harness) here — green
+- **M6 (diff + equivalence gate)** on the [ChangeSet roadmap](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md) — green ([ChangeSet summary](../2026-05-21-changeset-change-management/summary.md))
+
+## Historical note
+
+Use [`change-language.md`](../2026-05-21-changeset-change-management/change-language.md)
+for the v1 edit vocabulary (`ArtifactChange`, implicit create on `Path`, slot
+ops).
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md
new file mode 100644
index 000000000..4bb1c1186
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md
@@ -0,0 +1,20 @@
+# Milestone 6: Engine + Node Cutover
+
+**Promoted to standalone roadmap:**
+
+[`docs/roadmaps/2026-05-21-engine-registry-cutover/`](../2026-05-21-engine-registry-cutover/overview.md)
+
+Engine switchover, wire edit messages, `lpc-model` vocabulary, server registry
+apply, SyncResult policy, graph reconciliation, and server fs wire-up are tracked
+there (M1–M8).
+
+## Gate (unchanged)
+
+- **M4** fs-change harness here — green
+- **[ChangeSet M6 diff gate](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md)** — green
+
+## Historical note
+
+Original M6 scope: delete old `lpc-engine` artifact path; `ProjectLoader` +
+`Engine` → `lpc-node-registry` + ChangeSet. See promoted roadmap for expanded
+milestones (model + wire + server prerequisites).
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md
new file mode 100644
index 000000000..fee9e5795
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md
@@ -0,0 +1,39 @@
+# Milestone 7: Server Fs-Change Wire-Up
+
+## Title And Goal
+
+Route filesystem notifications to **`Engine::handle_fs_changes`** on the
+**post-M6** stack and stop **`Project::reload()`** on watcher events.
+
+## Prerequisites
+
+Parallel build ends at **M6**. Engine uses `lpc-node-registry` + ChangeSet model
+from M5.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup/`
+
+## Scope
+
+In scope:
+
+- `LpServer` → `Engine::handle_fs_changes`.
+- End-to-end single-file reload (TOML, assets).
+- Explicit `Project::reload()` retained for user-initiated full reload.
+
+Out of scope:
+
+- `project.toml` graph reconciliation (**M8**).
+
+## Dependencies
+
+- M6 engine cutover.
+
+## Execution Strategy
+
+Small plan. Narrow server→engine wiring.
+
+Suggested chat opener:
+
+> I suggest a small plan for server fs-change wire-up, then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md
new file mode 100644
index 000000000..eaa5ce4dd
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md
@@ -0,0 +1,34 @@
+# Milestone 8: project.toml / Graph Reconciliation
+
+## Title And Goal
+
+Support **`project.toml`** and invocation wiring changes: engine tree mutation
+when parent child lists change.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation/`
+
+## Scope
+
+In scope:
+
+- Detect invocation diffs; engine add/remove/repoint children.
+- Tests for add/remove top-level nodes, playlist inline children.
+- ChangeSet may express graph-level node add/remove (extends M5).
+
+Out of scope:
+
+- Optimal minimal diff for every edit shape.
+
+## Dependencies
+
+- M7 server wire-up (or M6 engine apply path minimum).
+
+## Execution Strategy
+
+Full plan.
+
+Suggested chat opener:
+
+> M8 graph reconciliation needs a full plan. Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation.md
new file mode 100644
index 000000000..5f68e66bc
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation.md
@@ -0,0 +1,30 @@
+# Milestone 9: Cleanup + Validation
+
+## Title And Goal
+
+Integration validation, remove scaffolding, CI / firmware gates.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-artifact-routed-file-reload/m9-cleanup-validation/`
+
+## Scope
+
+In scope:
+
+- Remove parallel-build leftovers; confirm old artifact path gone (M6).
+- Example projects → `SourceFileSlot` TOML.
+- M4/M5 harness tests remain as `lpc-node-registry` regression suite.
+- `just check`, fw-esp32 validation.
+
+## Dependencies
+
+- M1–M8 complete.
+
+## Execution Strategy
+
+Direct execution.
+
+Suggested chat opener:
+
+> I can implement M9 cleanup without a separate plan. Agree?
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/notes.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/notes.md
new file mode 100644
index 000000000..ce59f7200
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/notes.md
@@ -0,0 +1,316 @@
+# Artifact-Routed File Reload — Roadmap Notes
+
+## Scope
+
+Deliver incremental file reload for a running LightPlayer project: changed files update only the artifacts and nodes they affect, without `Project::reload()` or reconstructing the whole `Engine`.
+
+**Immediate goal:** single-file reload routed through the artifact layer.
+
+**Design constraint:** structure the artifact / definition / runtime stack so
+**ChangeSets** sit between registry base and effective reads — proven in the
+[promoted ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md)
+before engine cutover (M6).
+
+Target stack (bottom → top):
+
+```
+Filesystem / library sources
+ ↓
+ArtifactStore — source identity + freshness (path, version); no long-lived file bytes
+ ↓
+NodeDefRegistry — parsed NodeDef storage (file-backed + inline), keyed by NodeDefId
+ ↓
+ChangeSet layer — see [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md)
+ ↓
+NodeDefView + AssetView — effective reads (base + active ChangeSets)
+ ↓
+Engine node tree — live NodeRuntime instances + dependency index
+```
+
+In scope for this roadmap:
+
+- Replace server file-change path that calls `Project::reload()`.
+- Split **ArtifactStore** (sources) from **NodeDefRegistry** (parsed definitions).
+- Register node-to-artifact dependencies during project load.
+- Track GLSL, SVG, and node TOML as file-backed artifacts (metadata only for source files).
+- Let shader and fixture nodes react to changed dependent artifacts.
+- Handle node TOML changes by reloading/repreparing affected nodes in place.
+- Error propagation on reload/parse failure (no last-good retention in v1).
+- ESP32 heap discipline: no cached source text, no duplicate engines, staged compile/prepare.
+
+Out of scope (this roadmap):
+
+- Full **project diff → ChangeSet** automation (see ChangeSet roadmap
+ [`future.md`](../2026-05-21-changeset-change-management/future.md)).
+- Full optimal graph diff for arbitrary `project.toml` edits in the first slice.
+- Library artifact specifiers.
+- Host precompilation or any weakening of on-device GLSL JIT.
+- Byte-level artifact diffing / digest in the first pass.
+
+## Current State
+
+### Server reload path (wrong boundary)
+
+- `lp-app/lpa-server/src/server.rs` — `LpServer::tick` collects project-relative `FsChange`s and calls `project.reload()` for any batch.
+- `lp-app/lpa-server/src/project.rs` — `Project::reload()` drops `Engine` and runs `ProjectLoader::load_from_root` again.
+- Useful for explicit full reload; must not run on filesystem watcher events.
+
+### Engine stub
+
+- `lp-core/lpc-engine/src/engine/engine.rs` — `Engine::handle_fs_changes` is a no-op.
+- Engine owns `ArtifactStore`, `artifact_nodes` (`HashMap`), `demand_roots`, node tree.
+- `TickContext` exposes only the **owning node-definition artifact** (`artifact_ref`, `artifact_content_frame`, `artifact_changed_since`) — not dependent source artifacts (GLSL, SVG).
+
+### ArtifactStore today = de facto NodeDef registry
+
+There is no separate `NodeRegistry` / `NodeDefRegistry` type. Parsed definitions live inside `ArtifactStore`:
+
+| Piece | Location | Role today |
+|-------|----------|------------|
+| `ArtifactStore` | `artifact/artifact_store.rs` | Maps `ArtifactLocation` → refcounted entry; `load_with` loads **`NodeDef` only** |
+| `ArtifactState` | `artifact/artifact_state.rs` | Payload is always `NodeDef` when loaded |
+| `ArtifactLocation` | `artifact/artifact_location.rs` | `File(path)` or `InlineNode { owner, name }` |
+| `NodeDefHandle` | `node/node_def_handle.rs` | `(ArtifactId, SlotPath)` — points into artifact store, not a registry index |
+| `NodeEntry` | `node/node_entry.rs` | `def_handle` + runtime; `artifact()` → owning artifact id |
+
+Historical context: milestone `m2.7-node-def-handandles-and-slot-views/02-concrete-artifact-store` intentionally made `ArtifactStore` own `NodeDef` payloads directly. That was correct for the slot-domain cutover; it is now the wrong boundary for file reload + overlays.
+
+Smells:
+
+- `ArtifactLocation::InlineNode` — inline defs are derived from an owning artifact, not separately loadable sources.
+- `ArtifactStore::load_with` — closure returns `NodeDef`; no generic source-file artifact type.
+- Load failure **replaces** payload with error state (no last-good + error split).
+- No content version / freshness API beyond `content_frame` bump on successful load.
+
+### ProjectLoader bypasses artifacts for dependent sources
+
+`lp-core/lpc-engine/src/engine/project_loader.rs`:
+
+- Node TOML → `acquire_location(File)` → `load_with` → `NodeDef` in artifact store ✓
+- `ShaderSource::Path` → `read_shader_source` → `String` passed to `ShaderNode::new` ✗ (not tracked)
+- `MappingConfig::SvgPath` → `resolve_fixture_mapping` at attach ✗ (not tracked)
+
+After load, GLSL/SVG paths are invisible to artifact invalidation.
+
+### Nodes hold inlined source / resolved mapping
+
+- `shader_node.rs` — long-lived `glsl_source: String`; compile failure drops `self.shader` (no last-good compile).
+- `fixture_node.rs` — `SvgPath` resolved at load; runtime sync ignores path changes.
+
+### Closest existing “incremental update” pattern
+
+- `slot_mutation.rs` — mutates `NodeDef` in place inside `ArtifactStore`, bumps `content_frame`; nodes observe via `TickContext::artifact_changed_since`. Wire-driven, not filesystem-driven.
+
+### Overlay prototype (other branch, not ported)
+
+`lightplayer-app-ui/lp-core/lpc-slot-mockup` explored:
+
+- `ArtifactStoreModel` — base authored slot roots keyed by path
+- `OverlayStore` — UI change sets (`SlotOp`, create/delete root)
+- `OverlayProjector` — merges enabled overlays → `ProjectedSlotRoots`
+- `commit_overlay` — promote overlay into base store
+
+That mockup predates current engine structure. Concept (project base + overlay → effective defs) is still the target for UI edits, but implementation will likely be rewritten to fit `NodeDefRegistry` + engine revision model.
+
+## User Notes
+
+- File reloads should only reload what they need; routed through artifact manager.
+- Server/file-watcher must not special-case GLSL or SVG — they are file artifacts.
+- Artifacts are sources; `NodeDef` is derived, not the artifact itself.
+- `InlineNode` artifact locations are probably wrong.
+- Acceptable initially: reload child nodes when a node file changes; avoid whole-project reload.
+- No transactional whole-engine reload (two engines in memory) on ESP32 (~300 KB heap).
+- Source-file artifacts: metadata only (path + version); lazy read during prepare/compile.
+- Preserve on-device GLSL JIT.
+
+## Resolved Decisions (design discussion)
+
+### NodeDefRegistry — core reload orchestrator
+
+The registry (user: "NodeRegistry"; holds **defs**, not live runtimes) is the bounded, testable center of reload logic.
+
+**Entry shape** — similar to current artifact entry:
+
+- Every `NodeDefId` maps to an entry with **source** `{ artifact_id, path }` where `path` is location within the artifact (`SlotPath::root()` / empty path = artifact root).
+- **Identity separate from content:** if we know a def should exist, create the entry even when parse/validation fails (`ParseError`, `ValidationError`, etc.).
+- Parsed payload lives in entry state when healthy; error state when not.
+
+**Update protocol:**
+
+```
+Engine --fs changes to store--> store.apply_fs_changes
+Engine --sync--> NodeDefRegistry::sync(&ArtifactStore, …)
+NodeDefRegistry --report--> NodeDefUpdates { added, changed, removed, ... }
+Engine --applies report--> attach/detach/destroy nodes, propagate parent errors
+```
+
+- Registry knows which defs came from which artifacts; on artifact change it re-derives affected defs.
+- Report is **def-level**: `added` / `changed` / `removed` (name: **`NodeDefUpdates`** or similar).
+- **Def content change ≠ child inventory change:** editing an inline child def does not necessarily mark the parent def `changed` — only the child def entry.
+- Distinguish (conceptually, may surface in report or engine follow-up):
+ - **Def payload changed** — same identity, new parsed content or error state.
+ - **Child defs added/removed** — artifact now derives a different set of inline `NodeDefId`s (e.g. playlist entries).
+ - **Graph wiring changed** — parent's invocation map / child references changed (engine tree mutation; own milestone).
+- **v1 simplification:** when a node binds multiple defs, may refresh all referenced defs — acceptable short-term, not ideal long-term.
+- **Later:** stable `NodeDefId` preservation when edits are unambiguous.
+
+Registry is unit-testable in isolation: feed artifact change → assert `NodeDefUpdates`. Engine response tested separately.
+
+**Build order:** stand up new `ArtifactStore` + `NodeDefRegistry` and nail `NodeDefUpdates` semantics **before** cutting over loader/engine and fs reload. Graph/`project.toml` reconciliation is big enough for **its own milestone** after foundation.
+
+**Def access:** expose defs only through a **view** (projection/effective def), not raw registry entries — overlay/change layer may live inside or adjacent to registry; update report may eventually reflect projected defs. Exact change-layer integration TBD.
+
+### Error semantics (no last-good in v1)
+
+Propagate errors; do **not** retain last-good parsed defs or last-good compiled runtimes on failure.
+
+- Parse/validation error on a def → entry enters error state → **nodes bound to that def are destroyed**.
+- Parent nodes referencing the def → reference fails → parent enters error state (cascade).
+- Same identity/content split for artifacts: artifact entry exists with error state when load/parse of source fails.
+
+Last-good retention is explicit future work if needed.
+
+### Node-facing source model
+
+**Authored:** `SourceFileSlot` on defs (see TOML encoding below).
+
+**Resolved:** slot values carry a **`SourceFileRef`** handle (inline / file artifact / future URL), **not** file contents — same principle as resources/products (no big data in slot values).
+
+- Node/fixture runtime holds parsed **products** (compiled shader, mapping points), not source text.
+- To compile/prepare: ask context to materialize from `SourceFileRef` → `{ version, text, diagnostic_name }`.
+- Fixture: state holds resolved `PathPoints`; SVG text materialized only during mapping prepare.
+
+Node tracks `last_seen_version` per source slot; recompile/reprepare when resolved version bumps.
+
+Deprecate / replace: `ShaderSource`, standalone `SourcePathSlot` for this use case.
+
+**Slot family (naming agreed):**
+
+- **`SourceFileSlot`** — authored UTF-8 file-or-inline.
+- **`BinaryFileSlot`** — future sibling; inline base64.
+- **`SourceFileRef`** — resolved handle in slot data; materialize via context.
+
+### Change layer placement (refined)
+
+Between registry base defs and engine node tree. May be **inside** `NodeDefRegistry` with the update report / def view reflecting projection — exact wiring TBD. Registry def **view** is the hook either way.
+
+### Hard cut, not incremental facade
+
+App is in active dev — do the full ArtifactStore / NodeDefRegistry split now. No temporary facade over the old model. `NodeDefHandle` → **`NodeDefId`** (opaque id, same pattern as `ArtifactId`).
+
+### SourceFileSlot / BinaryFileSlot TOML encoding (agreed direction)
+
+File-or-inline slots use a **custom slot codec** (existing `SlotDataAccess::Custom` path in `slot_codec/`). Acceptable because file sources are fundamental.
+
+**File reference** — `$path` leaves `path` free in the namespace for a future `.path` artifact type:
+
+```toml
+source = { $path = "./shader.glsl" }
+# shorthand (also valid):
+source = "./shader.glsl"
+```
+
+**Inline text** — extension key names the inline format (GLSL vs WGSL vs SVG, etc.):
+
+```toml
+[source]
+glsl = """
+vec4 render(vec2 pos) { ... }
+"""
+```
+
+Exactly one of `$path` or an extension key must be present. Inline table form and inline-table `$path` form are both valid for the same slot.
+
+**`BinaryFileSlot`** (future) — same `$path` for file; inline uses extension key + **base64** payload (`png = "..."`, `jpeg = "..."`). Useful for small embedded assets.
+
+**Authored model (conceptual):**
+
+```rust
+enum SourceFileBacking {
+ Path(SourcePath), // from $path
+ Inline { ext: String, text: String }, // ext from table key: glsl, svg, wgsl, ...
+}
+```
+
+Engine resolves to UTF-8 text + effective version regardless of backing. Extension may inform compile/prepare path (GLSL vs WGSL) without nodes caring about file vs inline.
+
+**Resolved source includes a diagnostic label** for compile/load errors and logging — exact format flexible, e.g.:
+
+- File-backed: project-relative path as authored (`mapping1.svg`, `./shader.glsl`)
+- Inline: synthetic label anchored to def location (`[shader.toml:56].glsl`, `[shader.toml:source].svg`)
+
+Engine resolves `SourceFileRef` → UTF-8 text + effective version on materialize (not stored in slot value). Extension may inform compile/prepare path.
+
+**Resolved materialization includes a diagnostic label** for compile/load errors and logging — exact format flexible, e.g.:
+
+- File-backed: project-relative path as authored (`mapping1.svg`, `./shader.glsl`)
+- Inline: synthetic label anchored to def location (`[shader.toml:56].glsl`, `[shader.toml:source].svg`)
+
+**Migration:** Hard cut — replace `ShaderSource` `{ path = ... }` / `[source] glsl = ...` with `$path` / extension-key forms.
+
+Registry/engine responsibilities (not node runtime):
+- Register file artifacts for file-mode slots at load.
+- Resolved slot values carry `SourceFileRef`; combine slot revision + file artifact version → effective version on materialize.
+
+### SourceRef sketch (superseded)
+
+Runtime `SourceRef` on nodes — superseded by **`SourceFileRef`** in resolved slot data + context materialization.
+
+### Naming
+
+| Today | Target |
+|-------|--------|
+| `NodeDefHandle` `(ArtifactId, SlotPath)` | `NodeDefId` opaque registry index |
+| `ArtifactStore` holds `NodeDef` | `ArtifactStore` holds source freshness only |
+| `NodeEntry.def_handle` | `NodeEntry.def_id: NodeDefId` |
+| De facto registry in `ArtifactStore` | `NodeDefRegistry` + `NodeDefUpdates` report |
+
+### Execution order (agreed direction)
+
+**Prove semantics with tests before cutover.** The new stores exist to support fs-change (and later projections); switching loader/engine over before that shape is validated has little value.
+
+Phases:
+
+1. **Parallel build (M1–M4 + ChangeSet roadmap, no `lpc-engine` changes)** —
+ registry + fs harness (M4) + [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md).
+2. **Cutover (M6)** — delete old path; engine → `lpc-node-registry`.
+3. **Wire-up (M7)** — server fs-change.
+4. **Graph reconciliation (M8)**.
+5. **Cleanup (M9).**
+
+## Change management (promoted roadmap)
+
+See [ChangeSet change management](../2026-05-21-changeset-change-management/overview.md).
+**ChangeSet**: ordered, id'd, in-memory until commit. User stories drive harness
+acceptance:
+
+- **Compose** — blank project → any `examples/*` project via ChangeSets.
+- **Morph** — any example → any other, one edit at a time, never crashing.
+- **Actions** — CRUD defs/slots, inline node authoring, inline↔standalone def,
+ inline↔asset source refactor.
+
+**Asset** = non-node file; **artifact** = store identity.
+
+Longer term: **project diff → ChangeSet stream** and **replay stress harness**
+(host / emu / device) — see ChangeSet roadmap `future.md`. Story IDs are the
+manual seed; automated diff and full-engine replay follow M6.
+
+## Open Questions
+
+- **Q1:** `project.toml` graph reconciliation details — M8.
+
+(Q8 NodeChange vs AssetChange layering — **resolved**: single ChangeSet stream,
+`NodeChange` / `AssetChange` variants; see [ChangeSet roadmap](../2026-05-21-changeset-change-management/decisions.md).)
+
+## Roadmap Artifacts
+
+`overview.md`, `m1`–`m9`, `decisions.md`, `future.md`.
+
+## Build Location
+
+- **`lpc-node-registry`** — **M1** crate bootstrap + `ArtifactStore`; **M2–M4**
+ registry + source; **ChangeSet roadmap** fills change + view. No `lpc-engine`
+ edits until M6.
+- **Delete `lpc-slot-mockup`** at M1 start.
+- **`lpc-model`** — `SourceFileSlot` additive in M3; production `ShaderDef` at **M6**.
+- **`lpc-engine`** — **M6** cutover only.
diff --git a/docs/roadmaps/2026-05-21-artifact-routed-file-reload/overview.md b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/overview.md
new file mode 100644
index 000000000..2c83d14d5
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-artifact-routed-file-reload/overview.md
@@ -0,0 +1,129 @@
+# Artifact-Routed File Reload
+
+## Motivation
+
+Today any filesystem change triggers `Project::reload()`: the server drops the
+running `Engine` and rebuilds it from scratch via `ProjectLoader`. That is the
+wrong boundary for hot reload — it spikes memory on ESP32, reloads unrelated
+nodes, and bypasses the artifact/refcount model the engine already has.
+
+The immediate goal is **single-file reload**: a changed TOML, GLSL, or SVG
+updates only the artifacts and node defs it affects. The structural goal is a
+stack that supports **client-driven ChangeSets** (temporary in-memory edits until
+commit) **and** filesystem reload without another rewrite.
+
+ChangeSets also become the **universal edit vocabulary**: the same ordered op
+stream that powers the UI can be generated by **diffing two projects**, replayed
+as many small steps, and used to stress the full engine on host, RV32 emulator,
+or device — surfacing panics, OOMs, and heap fragmentation that whole-project
+reload tests miss.
+
+Current pain:
+
+- `ArtifactStore` holds `NodeDef` payloads — artifacts and defs are conflated.
+- GLSL/SVG are read once at attach; file paths are not tracked as artifacts.
+- `Engine::handle_fs_changes` is a no-op; the server calls `Project::reload()`.
+- Shader nodes retain long-lived source strings; fixtures pre-resolve SVG at load.
+- Wire `slot_mutation` mutates defs in place — no ChangeSet / commit model.
+
+## Architecture
+
+### Parallel build (M1–M4 + ChangeSet roadmap)
+
+M1–**M4** implement the **registry base + fs reload** stack in
+`lpc-node-registry`. **ChangeSet change management** is a
+[separate promoted roadmap](../2026-05-21-changeset-change-management/overview.md)
+(prerequisite for **M6**). **`lpc-engine` is unchanged** until M6 cutover.
+
+```text
+M1–M4 (this roadmap):
+
+ lpc-node-registry/ NEW — unit + harness tests
+ ├── artifact/ freshness-only store (all files incl. assets)
+ ├── registry/ NodeDefRegistry, sync → SyncResult
+ └── source/ SourceFileRef materialize
+
+ChangeSet roadmap (parallel, gates M6):
+
+ lpc-node-registry/
+ ├── change/ ChangeSet, commit/discard
+ └── view/ NodeDefView + AssetView
+
+ lpc-engine/ OLD — unchanged until M6
+
+M6: delete old path; lpc-engine → lpc-node-registry
+```
+
+### Target stack (post-M6)
+
+```text
+Filesystem + client ChangeSets (uncommitted)
+ ↓
+NodeDefRegistry
+ ├── ArtifactStore — committed bytes + freshness
+ ├── ChangeOverlay — pending artifact/slot mutations
+ └── entries / indexes — committed parse cache
+ ↓
+NodeDefView — effective reads (overlay ∪ base)
+ ↓
+Engine node tree — bindings → effective def → value
+```
+
+**ChangeSet** — ordered, id'd client edits: slot patches on defs, add/remove
+defs, add/replace/delete **assets** (GLSL, SVG, … — non-node files). Commit
+promotes to base; discard drops overlay.
+
+**Asset** — user-facing name for dependency files that are not node definition
+TOMLs. Store entries remain **artifacts** (freshness identity).
+
+```text
+lp-core/lpc-node-registry/
+├── artifact/ # M1 — crate bootstrap + freshness store
+├── registry/ # M2
+├── source/ # M3
+├── change/ # ChangeSet roadmap
+└── view/ # ChangeSet roadmap
+
+lp-core/lpc-engine/ # M6 cutover
+lp-core/lpc-model/slots/ # M3 SourceFileSlot
+```
+
+Delete **`lpc-slot-mockup`** at M1 start.
+
+## Alternatives Considered
+
+- **Build parallel in `lpc-node-registry` first** — in-place `lpc-engine` refactor
+ rejected before semantics proven; cutover at M6 only.
+- **Defer ChangeSet to post-cutover** — rejected; client edit path is core
+ architecture; must be proven in [ChangeSet roadmap](../2026-05-21-changeset-change-management/overview.md) before M6.
+- **Last-good on reload failure** — rejected for v1; errors propagate.
+
+## Risks
+
+- **ChangeSet roadmap scope** — node patches + asset ops + story harness; phased
+ in [promoted roadmap](../2026-05-21-changeset-change-management/overview.md).
+- **M6 cutover churn** — cross-cutting after M4 + ChangeSet M6 gate contract clear.
+- **ESP32 heap** — ChangeSets and asset overlays must not retain duplicate file
+ bytes long-term.
+
+## Scope Estimate
+
+Nine milestones in this roadmap (M1–M4, M6–M10). **ChangeSet** is a separate
+roadmap gating **M6**. M6 cutover only after **M4** here + **ChangeSet M6**
+(diff + equivalence gate) green. **M10** (ExplainSlot probes) follows parent M6;
+not a cutover gate.
+
+## Milestones
+
+| # | Milestone | Gate |
+|---|-----------|------|
+| M1 | **`lpc-node-registry` + ArtifactStore** | Crate bootstrap; freshness-only store; lpc-engine untouched |
+| M2 | NodeDefRegistry + NodeDefUpdates | Unit tests; lpc-engine untouched |
+| M3 | SourceFileSlot + SourceFileRef | Unit tests; production defs unchanged until **M6** |
+| M4 | Fs-change semantics harness | Harness; no cutover |
+| — | **[ChangeSet change management](../2026-05-21-changeset-change-management/overview.md)** | **Separate roadmap; gates M6** |
+| M6 | Engine + node cutover | M4 + [ChangeSet M6](../2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md) green |
+| M7 | Server fs-change wire-up | E2E reload |
+| M8 | project.toml / graph reconciliation | Graph hot reload |
+| M9 | Cleanup + validation | CI |
+| M10 | [Slot resolution probes](m10-slot-provenance-client.md) | Post-M6; `ExplainSlot` probe on demand; not on tick read path |
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/change-language.md b/docs/roadmaps/2026-05-21-changeset-change-management/change-language.md
new file mode 100644
index 000000000..ca1045f23
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/change-language.md
@@ -0,0 +1,146 @@
+# ChangeSet Change Language (v1)
+
+Canonical edit vocabulary for client-driven changes. Lives in
+`lpc-node-registry/src/edit/` — **serde types, not part of the slot system**.
+Apply uses slot mut access + overlay tables; ops themselves are not `SlotData`.
+
+## Top level
+
+```text
+EditBatch { id, edits: Vec }
+```
+
+Changes are **grouped by artifact**. Each block targets one file and is either
+**slot-structured** (`.toml` defs) or **asset/opaque** (GLSL, SVG, delete, TOML
+import escape hatch) — never both in one block.
+
+## Target
+
+```rust
+enum EditTarget {
+ Id(ArtifactId), // committed artifact (optional; harness/wire rarely)
+ Path(LpPathBuf), // absolute project path — primary authoring form
+}
+```
+
+**Implicit create:** resolving `Path(p)` get-or-creates a pending overlay entry
+when `p` is not in base or overlay. No explicit `Create` op.
+
+Overlay does not use base-store refcount rules; pending paths exist until commit
+or discard.
+
+## Artifact blocks
+
+```rust
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum ArtifactEdit {
+ Slot { target: EditTarget, ops: Vec },
+ Asset { target: EditTarget, ops: Vec },
+}
+```
+
+Wire example:
+
+```json
+{ "kind": "slot", "target": { "path": "/shader.toml" }, "ops": [ … ] }
+{ "kind": "asset", "target": { "path": "/shader.glsl" }, "ops": [ … ] }
+```
+
+### Slot ops (`SlotEdit`)
+
+Node defs are slots. All normal node editing is slot ops at a `SlotPath`
+**within** the target `.toml` artifact:
+
+| Op | Use |
+|----|-----|
+| `UseEnumVariant { path, variant }` | Enum variant switch (node kind, `Unset`/`Ref`/`Def`, nested enums) |
+| `AssignValue { path, value }` | Value leaves only (scalars, path strings, etc.) |
+| `MapInsert { path, key, … }` | Map entry |
+| `MapRemove { path, key }` | Map entry |
+| `UseOption { path, present }` | Option some/none (`present = true` → shape default) |
+
+Examples:
+
+- Standalone shader file: `UseEnumVariant(root, "Shader")` then scalar `AssignValue`s on `/shader.toml`
+- Inline child: ops under `entries[2].node.def.Shader…` on `/playlist.toml`
+- Wire child to file: `UseEnumVariant(nodes[shader], "Ref")` + `AssignValue(nodes[shader].ref, "./shader.toml")`
+
+Relative locators in slot values resolve against the **containing artifact path**
+(same as `resolve_node_specifier` today).
+
+### Asset ops (`AssetEdit`)
+
+Path-level file body edits:
+
+| Op | Use |
+|----|-----|
+| `Delete` | Remove this path on commit |
+| `ReplaceBody(text)` | Whole-file body — GLSL, SVG, etc.; optional TOML import escape hatch |
+
+Normal **node TOML** bodies are **not** authored with `ReplaceBody`. They come from
+slot ops + slot codec serialize on commit.
+
+## Node invocation TOML (authored)
+
+```toml
+[nodes.placeholder]
+unset = {}
+
+[nodes.shader]
+ref = "./shader.toml"
+
+[nodes.clock.def]
+kind = "Clock"
+```
+
+Legacy `def = { path = … }` is rejected.
+
+## Node TOML vs assets
+
+Same `ArtifactEdit` envelope; **`kind`** selects the op vocabulary:
+
+- **`.toml`** — `kind: "slot"`; serialize to text on commit
+- **`.glsl`, `.svg`, …** — `kind: "asset"` with `ReplaceBody` / `Delete`
+
+## Creatability
+
+Every `examples/*` project must be reachable from blank via a finite
+`EditBatch` sequence using only:
+
+- `ArtifactEdit::Slot` / `ArtifactEdit::Asset` (implicit create via `Path`)
+- No `CreateDef`; no pre-populated def blobs as the primary path
+
+New node at artifact root: `UseEnumVariant(root, "Shader")` (applies variant default),
+then patch value leaves with `AssignValue`.
+
+## Apply / commit
+
+1. **Apply** — merge each `ArtifactEdit` into path-keyed overlay; base untouched
+2. **View** — `NodeDefView` / read API resolves `(path, slot_path)` over overlay ∪ base
+3. **Commit** — serialize overlay TOML + assets → store/fs; registry re-derive →
+ `SyncResult`
+4. **Discard** — drop overlay; reads = base
+
+Dangling path refs (wire before target file exists) are OK mid-sequence.
+
+Unreferenced overlay paths may exist on disk after commit; registry only registers
+defs reachable from root (same as filesystem reality).
+
+## Example (add shader to project)
+
+```text
+ArtifactEdit::Asset(Path("/shader.glsl"), [ ReplaceBody("…") ])
+
+ArtifactEdit::Slot(Path("/shader.toml"), [
+ UseEnumVariant(root, "Shader"),
+ AssignValue(root.source.path, "./shader.glsl"),
+ …
+])
+
+ArtifactEdit::Slot(Path("/project.toml"), [
+ UseEnumVariant(nodes[shader], "Ref"),
+ AssignValue(nodes[shader].ref, "./shader.toml"),
+])
+```
+
+Order of blocks may vary.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/decisions.md b/docs/roadmaps/2026-05-21-changeset-change-management/decisions.md
new file mode 100644
index 000000000..41fee9d9a
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/decisions.md
@@ -0,0 +1,121 @@
+# ChangeSet Change Management — Decisions
+
+#### Promoted standalone roadmap
+
+- **Decision:** ChangeSet work lives in
+ `docs/roadmaps/2026-05-21-changeset-change-management/`.
+- **Why:** Scope warrants full roadmap process; gates parent M6.
+- **Revisit when:** Unlikely.
+
+#### Parent M6 gate unchanged
+
+- **Decision:** Parent **M6** starts when this roadmap **M6 (diff + equivalence
+ gate)** + parent **M4** are green.
+- **Why:** Both fs-reload and client-edit paths must be proven before cutover.
+
+#### Change language: grouped by artifact
+
+- **Decision:** `ChangeSet` is `Vec`. Each block has
+ `target + ops` for one file.
+- **Why:** Natural authoring unit; avoids repeating artifact ref on every op.
+- **Rejected alternatives:** Flat `ChangeOp` stream with per-op target.
+
+#### Artifact target: Id or Path; implicit create
+
+- **Decision:** `ArtifactTarget::Path(p)` get-or-creates overlay entry if absent.
+ `ArtifactTarget::Id` for committed artifacts only.
+- **Why:** Compose-from-blank never needs ids; no explicit Create op.
+- **Rejected alternatives:** Explicit `New { path }`; require reference before create.
+
+#### Node edits are slot ops only
+
+- **Decision:** Node defs are slots. No `CreateDef`. Author via slot ops at
+ `SlotPath` within the target artifact (`root()` or inline paths like
+ `entries[2].node`). Wiring = slot ops on invocation fields.
+- **Why:** Matches `lpc-model` and registry `DefSource { artifact, path }`.
+- **Rejected alternatives:** Separate invocation ops; pre-populated def on create.
+
+#### Change ops are not slot-system types
+
+- **Decision:** `ArtifactOp` / `ChangeSet` types live in `lpc-node-registry/change/`,
+ serde-serialized. Not part of `SlotData` or slot codec.
+- **Why:** Edit vocabulary ≠ payload model; wire/diff/replay friendly.
+- **Rejected alternatives:** Embedding ops in slot shapes.
+
+#### File ops vs slot ops
+
+- **Decision:** Per artifact: `Delete`, `SetBytes` (whole-file — assets + TOML
+ import escape hatch); slot ops for normal `.toml` authoring.
+- **Why:** TOML node files serialize from slot tree on commit.
+
+#### Defaults + follow-up slot ops
+
+- **Decision:** New def at a locus = set kind (slot op) → `KindDef::default()`,
+ then patch slots. No bundled initial TOML as primary path.
+- **Why:** Smaller op set; matches compose/morph stories.
+
+#### Creatability requirement
+
+- **Decision:** Op vocabulary must reach any `examples/*` from blank via finite
+ `ChangeSet` (implicit create + slot ops + `SetBytes`).
+- **Why:** Universal edit vocabulary for UI, diff, replay.
+
+#### Overlay vs base refcount
+
+- **Decision:** Overlay is path-keyed scratch space; no base-store refcount.
+ Commit writes overlay paths; registry registers defs reachable from root.
+- **Why:** Orphans on disk OK; dangling refs mid-sequence OK.
+
+#### No lpc-engine edits until parent M6
+
+- **Decision:** M1–M6 here touch `lpc-node-registry` + tests only.
+
+#### Client edits never mutate base directly
+
+- **Decision:** ChangeSet → view → (optional) commit → `registry.sync`.
+
+#### v1 whole-file asset edits only
+
+- **Decision:** `SetBytes` / `Delete` for text assets; no byte-range patches.
+
+#### Fs vs overlay precedence (v1)
+
+- **Decision:** Uncommitted overlay wins on overlaid paths until commit/discard.
+- **Revisit when:** Remote fs sync.
+
+#### M6 diff gate scope
+
+- **Decision:** Gate parent M6 on **A1 + B1 + D1–D3 + D5**, core slot + file ops —
+ via **diff + equivalence** (`diff(∅, basic)`, `diff(basic, basic2)`), not
+ hand-curated op lists. Full A2–A4 / B2–B4 / C3 matrix deferred.
+
+#### DefView is sole read path
+
+- **Decision:** Effective reads go through view/overlay resolution before M6
+ engine cutover. Public reads are **effective only**; `entries` is committed
+ cache updated on commit/sync.
+
+#### No provenance on registry read path
+
+- **Decision:** Registry and engine hot reads return **values only** — no
+ per-field provenance, no `Production` attribution on tick path.
+- **Why:** ESP32 memory; client holds ChangeSet and bindings for UI badges.
+- **Provenance later:** Parent roadmap **M10** — `ExplainSlot` probe on
+ `project_read` (wire exists) or host client local re-derive. See
+ [`m10-slot-provenance-client.md`](../2026-05-21-artifact-routed-file-reload/m10-slot-provenance-client.md).
+
+#### Overlay inside NodeDefRegistry
+
+- **Decision:** `ChangeOverlay` lives inside `NodeDefRegistry`, between
+ `ArtifactStore` and parsed `entries`. Internal artifact reads go through
+ overlay; commit promotes to store and re-derives entries → `SyncResult`.
+- **Why:** Mutations are artifact-level; registry entries are derived state.
+- **Rejected alternatives:** Separate ChangeRegistry; overlay above committed
+ entries only.
+
+#### Engine cutover: minimal def-read swap
+
+- **Decision:** Parent M6 changes engine consumed-slot def fallback to read
+ effective registry defs. Binding cascade unchanged.
+- **Why:** Same resolution shape; overlay invisible to resolver except via
+ effective def reads.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/dependencies.md b/docs/roadmaps/2026-05-21-changeset-change-management/dependencies.md
new file mode 100644
index 000000000..cf6ceac45
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/dependencies.md
@@ -0,0 +1,27 @@
+# External Dependencies
+
+This roadmap depends on the **artifact-routed file reload** parallel stack
+(M1–M4). Do not start M1 here until those are complete and passing.
+
+## Required (parent roadmap)
+
+| Milestone | Roadmap | Key APIs |
+|-----------|---------|----------|
+| M1 | [ArtifactStore](../2026-05-21-artifact-routed-file-reload/m1-artifact-store.md) | `acquire_location`, `apply_fs_changes`, `read_bytes` |
+| M2 | [NodeDefRegistry](../2026-05-21-artifact-routed-file-reload/m2-node-def-registry.md) | `load_root`, `NodeDefId`, `DefSource`, `NodeDefUpdates` |
+| M3 | [SourceFileSlot](../2026-05-21-artifact-routed-file-reload/m3-source-file-slot.md) | `resolve_source_file`, `materialize_source` |
+| M4 | [Fs-change harness](../2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness.md) | `sync` → `SyncResult`, `RegistryChange::Fs` |
+
+Validation baseline:
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test fs_change_semantics
+```
+
+## Downstream (blocks)
+
+| Milestone | Roadmap | Requires from here |
+|-----------|---------|-------------------|
+| M6 | [Engine cutover](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md) | M6 diff + equivalence gate green |
+| M7+ | Server / graph / cleanup | M6 |
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/future.md
new file mode 100644
index 000000000..80490d179
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/future.md
@@ -0,0 +1,40 @@
+# Future Work — ChangeSet
+
+## Project diff → ChangeSet stream
+
+- **Tracked as [M6](m6-diff-equivalence-gate.md)** in this roadmap — primary
+ parent M6 gate (`diff(∅, basic)`, `diff(basic, basic2)`).
+- Hand-written morph fixtures remain useful as diff regression inputs.
+
+## ChangeSet replay stress harness (host / emu / device)
+
+- **Idea:** Replay ChangeSet log through full engine (post parent M6) at
+ configurable granularity.
+- **Why not now:** Requires engine on ChangeSet path; this roadmap proves
+ registry harness only.
+- **Useful context:** Parent `future.md`; catches OOM/fragmentation whole-reload
+ tests miss.
+
+## ChangeSet wire protocol + CRDT merge
+
+- **Idea:** Full `lpc-wire` messages; concurrent edit merge.
+- **Why not now:** v1 is ordered in-memory ChangeSet + commit/discard.
+- **Useful context:** `lightplayer-app-ui` SlotOp mockup.
+
+## C3 inline ↔ standalone refactor
+
+- **Idea:** Extract/inline playlist entry defs; round-trip harness.
+- **Why not now:** High complexity; not required for A1/B1 gate.
+- **Useful context:** User story IDs C3a–c in `notes.md`.
+
+## Multi-ChangeSet replay (D4)
+
+- **Idea:** Stable op ids; replay ordered stack of ChangeSets.
+- **Why not now:** Single active ChangeSet sufficient for v1 gate.
+- **Revisit when:** Wire batching or undo stacks.
+
+## Binary file assets
+
+- **Idea:** `BinaryFileSlot` sibling to M3 text sources.
+- **Why not now:** v1 whole-file text assets only.
+- **Useful context:** Parent roadmap `future.md`.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay.md
new file mode 100644
index 000000000..e8b06985b
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay.md
@@ -0,0 +1,76 @@
+# Milestone 1: Change Language + Overlay Lifecycle
+
+## Title And Goal
+
+Introduce the v1 **change language** ([`change-language.md`](change-language.md)),
+**`ChangeOverlay`** inside **`NodeDefRegistry`**, and **apply / discard**
+lifecycle. Bootstrap with a **single `ArtifactChange`** (one op is enough to
+start). Prove **D1** and **D3**.
+
+## Parallel Build
+
+This milestone touches **`lpc-node-registry` only**. **`lpc-engine` unchanged**
+until parent artifact-routed **M6**.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/`
+
+## Scope
+
+In scope:
+
+- `change/` module — serde types:
+ - `ChangeSet`, `ChangeSetId`
+ - `ArtifactChange { target, ops }`
+ - `ArtifactTarget` (`Id` | `Path`)
+ - `ArtifactOp` enum shell (variants filled in M3/M4)
+- `overlay/` — path-keyed pending state on `NodeDefRegistry`
+- `apply(ArtifactChange)` — implicit create on `Path`; bootstrap op e.g.
+ `SetBytes` shell that stores bytes in overlay
+- `apply(ChangeSet)` — `for change in changes { apply(change) }` when batching
+ needed
+- `discard()` — clear overlay; committed `entries` and `ArtifactStore` unchanged
+- Harness: overlay populated after apply; base unchanged; discard clears overlay
+
+Out of scope:
+
+- Effective projection / `NodeDefView` (**M2**)
+- Full `SetBytes` / `Delete` semantics (**M3**)
+- Slot op apply (**M4**)
+- Commit (**M5**)
+- `RegistryChange::ChangeSet` sync variant (stub OK; wired at M5)
+
+## Key Decisions
+
+- **Overlay inside registry** — between `ArtifactStore` and `entries`; not a
+ separate ChangeRegistry.
+- **Grouped by artifact** — `ChangeSet` is `Vec`.
+- **Implicit create** — `ArtifactTarget::Path(p)` get-or-creates overlay entry.
+- **Single-change bootstrap** — full envelope optional until UI/diff need it.
+
+## User Stories / Gate
+
+| ID | Story | Covered |
+|----|-------|---------|
+| D1 | Apply → pending state visible in overlay | **Yes** |
+| D3 | Discard → base unchanged | **Yes** |
+
+## Deliverables
+
+- `lpc-node-registry/src/change/`
+- `lpc-node-registry/src/overlay/` (or `registry/overlay.rs`)
+- `NodeDefRegistry::apply_change`, `discard_overlay` (names TBD in plan)
+- Unit tests for D1, D3
+
+## Dependencies
+
+- Parent artifact-routed M1–M4 complete ([`dependencies.md`](dependencies.md))
+
+## Execution Strategy
+
+Full plan. Overlay ownership and apply pipeline before projection and op semantics.
+
+Suggested chat opener:
+
+> M1 plan: change types, ChangeOverlay in registry, apply/discard. Full plan then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-design.md
new file mode 100644
index 000000000..33a5bd9c5
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-design.md
@@ -0,0 +1,108 @@
+# M1 Design — Change Language + Overlay Lifecycle
+
+## Scope
+
+Add change vocabulary types and path-keyed overlay with apply/discard on
+`NodeDefRegistry`. **`lpc-engine` untouched.**
+
+Spec: [`../change-language.md`](../change-language.md).
+
+## File structure
+
+```
+lp-core/lpc-node-registry/
+├── Cargo.toml # + serde (alloc)
+├── src/
+│ ├── lib.rs # re-export change + ChangeError
+│ ├── change/
+│ │ mod.rs
+│ │ change_set.rs # ChangeSet, ChangeSetId
+│ │ artifact_change.rs # ArtifactChange
+│ │ artifact_target.rs # ArtifactTarget
+│ │ artifact_op.rs # ArtifactOp (+ SetBytes, Delete shell)
+│ │ change_error.rs # ChangeError
+│ │ apply.rs # apply_change(s) on &mut ChangeOverlay
+│ │ overlay.rs # ChangeOverlay, OverlayEntry
+│ └── registry/
+│ node_def_registry.rs # + overlay field; apply/discard methods
+└── tests/
+ └── overlay_lifecycle.rs # D1, D3 (+ serde round-trip in unit tests)
+```
+
+## Architecture
+
+```text
+apply(ArtifactChange | ChangeSet)
+ │
+ ▼
+ChangeOverlay (path → OverlayEntry)
+ │
+ ├─ Deleted (M1: apply Delete sets flag; read in M2/M3)
+ └─ Bytes(Vec) (M1: SetBytes)
+
+NodeDefRegistry
+ store: ArtifactStore ← unchanged on apply/discard
+ overlay: ChangeOverlay ← mutated on apply/discard
+ entries: ... ← unchanged on apply/discard (D1/D3)
+```
+
+### `OverlayEntry` (M1)
+
+```rust
+enum OverlayEntry {
+ Deleted,
+ Bytes(alloc::vec::Vec),
+ // SlotDraft { ... } — M4
+}
+```
+
+### Apply pipeline
+
+1. Resolve `ArtifactTarget` → absolute `LpPathBuf` key.
+2. `overlay.entry_or_insert(path)` — implicit create.
+3. For each op: `SetBytes(b)` → `OverlayEntry::Bytes(b)`; `Delete` → `Deleted`.
+4. Other op variants → `ChangeError::UnsupportedOp` in M1.
+
+### Discard
+
+`overlay.clear()` — no touch to `store` or `entries`.
+
+### Registry API (public)
+
+```rust
+impl NodeDefRegistry {
+ pub fn apply_change(&mut self, change: &ArtifactChange) -> Result<(), ChangeError>;
+ pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), ChangeError>;
+ pub fn discard_overlay(&mut self);
+ pub fn overlay_active(&self) -> bool;
+ pub fn overlay_contains_path(&self, path: &LpPath) -> bool;
+}
+```
+
+Names may adjust in phase implementation; semantics fixed.
+
+## Types (summary)
+
+See [`../change-language.md`](../change-language.md). M1 implements full enum
+shell; only file ops `SetBytes`/`Delete` apply; slot op variants exist for serde
+stability but return `UnsupportedOp` if applied.
+
+## Tests
+
+| Test | Story |
+|------|-------|
+| `serde_roundtrip_changeset` | Types (unit, in change module) |
+| `d1_apply_populates_overlay_base_unchanged` | D1 |
+| `d3_discard_clears_overlay_entries_unchanged` | D3 |
+| `apply_rejects_relative_path` | Path invariant |
+| `apply_setbytes_on_unloaded_path` | Implicit create |
+
+Fixtures: empty registry + `load_root` from existing harness (`clock` or
+`shader_project`) to prove `entries` stable across apply/discard.
+
+## Non-goals (M1)
+
+- `NodeDefView` effective reads
+- `read_effective_bytes`
+- Commit / `SyncResult`
+- `RegistryChange::ChangeSet`
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-notes.md
new file mode 100644
index 000000000..c2842948c
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/00-notes.md
@@ -0,0 +1,80 @@
+# M1 Notes — Change Language + Overlay Lifecycle
+
+## Scope
+
+Introduce v1 change-language **serde types**, **`ChangeOverlay`** on
+`NodeDefRegistry`, and **apply / discard** lifecycle. Bootstrap with a single
+`ArtifactChange` + `SetBytes` op. Prove **D1** and **D3**.
+
+**Out of scope:** effective projection (M2), full file op semantics (M3), slot
+ops (M4), commit (M5).
+
+## Current codebase
+
+```
+lp-core/lpc-node-registry/src/
+├── registry/node_def_registry.rs # owns ArtifactStore + entries; no overlay
+├── registry/registry_change.rs # RegistryChange::Fs only
+├── change/mod.rs # stub comment only
+└── view/node_def_view.rs # passthrough committed entries
+```
+
+Parent M1–M4 complete. Tests use `NodeDefRegistry::load_root` + `sync_fs` with
+`LpFsMemory` fixtures (`tests/fs_change_semantics.rs`, `harness/fixtures.rs`).
+
+## Resolved design (2026-05-21)
+
+- **Overlay inside `NodeDefRegistry`** — field between store and entries.
+- **`ChangeSet`** = `Vec` grouped by artifact path/id.
+- **Implicit create** on `ArtifactTarget::Path`.
+- **M1 op surface:** `SetBytes` only (shell apply); `Delete` and slot ops stubbed
+ or return `ChangeError::UnsupportedOp` until M3/M4.
+- **No effective read path in M1** — tests inspect overlay via test-only or public
+ introspection helpers (`overlay_has_path`, `overlay_bytes`); M2 wires reads.
+- **Serde on types** — `serde` with `alloc` (no_std-compatible); round-trip unit
+ tests under `#[cfg(test)]`.
+
+## Open questions
+
+### Q1: Overlay introspection API for tests / client badges
+
+- **Context:** D1/D3 need to observe overlay without M2 projection. Client may
+ query `overlay.contains_path` later.
+- **Suggested answer:** Public read-only helpers on registry:
+ `overlay_is_active()`, `overlay_contains_path(&LpPath)`, optional
+ `overlay_entry_state(path)` for tests. No slot-level introspection until M4.
+
+### Q2: Path key normalization
+
+- **Context:** `ArtifactTarget::Path(LpPathBuf)` must match registry
+ `artifact_path_to_id` keys (string paths).
+- **Suggested answer:** Normalize to absolute path string via `LpPathBuf` as stored
+ in registry; reject relative paths in `apply` with `ChangeError::InvalidPath`.
+
+### Q3: `ArtifactTarget::Id` in M1
+
+- **Context:** Change language supports `Id(ArtifactId)` for committed targets.
+- **Suggested answer:** Implement resolve `Id → path` via `artifact_root_path` map;
+ if unknown id, error. Needed for parity; tests can use `Path` only initially.
+
+### Q4: Multiple ops in one `ArtifactChange`
+
+- **Context:** Language allows `ops: Vec`.
+- **Suggested answer:** M1 apply loops ops sequentially on same overlay entry;
+ `SetBytes` replaces bytes; unknown ops error.
+
+## User stories (this milestone)
+
+| ID | Story | How |
+|----|-------|-----|
+| D1 | Apply → pending visible | Overlay has entry; `entries` unchanged |
+| D3 | Discard → base unchanged | Overlay empty; `entries` bit-identical |
+
+## Validation baseline
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test fs_change_semantics
+```
+
+Must remain green after M1.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/01-change-language-types.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/01-change-language-types.md
new file mode 100644
index 000000000..a8405fca9
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/01-change-language-types.md
@@ -0,0 +1,67 @@
+# Phase 01 — Change Language Types
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Add serde change vocabulary under `src/change/` per
+[`change-language.md`](../../change-language.md).
+
+**In scope:**
+
+- Add `serde = { workspace = true, features = ["derive"] }` to
+ `lpc-node-registry/Cargo.toml` (alloc-only, no_std OK).
+- Split types one concept per file (see `00-design.md`).
+- `ArtifactOp` includes all v1 variants from spec; slot variants are data-only
+ in M1 (no apply logic here).
+- `ChangeError` enum: `InvalidPath`, `UnknownArtifact`, `UnsupportedOp`, …
+- `#[cfg(test)]` serde round-trip tests for `ChangeSet` / `ArtifactChange`.
+- Replace `change/mod.rs` stub; export types from `lib.rs`.
+
+**Out of scope:** overlay, registry methods, apply logic.
+
+## Code organization reminders
+
+- Public types at top of each file; `#[cfg(test)] mod tests` at bottom.
+- Use `LpPathBuf` from `lpfs` for paths.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Do not expand scope into overlay/apply.
+- Fix warnings; no allow without reason.
+
+## Implementation details
+
+**Files to create:**
+
+| File | Contents |
+|------|----------|
+| `change_set.rs` | `ChangeSetId(u64 or String)`, `ChangeSet { id, changes }` |
+| `artifact_target.rs` | `ArtifactTarget::Id(ArtifactId)` \| `Path(LpPathBuf)` |
+| `artifact_op.rs` | `Delete`, `SetBytes(String)`, slot op structs per spec |
+| `artifact_change.rs` | `{ target, ops }` |
+| `change_error.rs` | `Display` + error type |
+
+Use `String` for `SetBytes` body (text assets + TOML escape hatch).
+
+**Slot op payloads:** minimal structs with `SlotPath` + value placeholders
+(`LpValue` or serde-friendly owned form from `lpc-model` — use existing types
+where already serde-enabled).
+
+**lib.rs exports:**
+
+```rust
+pub use change::{
+ ArtifactChange, ArtifactOp, ArtifactTarget, ChangeError, ChangeSet, ChangeSetId,
+};
+```
+
+Keep `change` as `pub mod change` or re-export only — match crate style.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry change::
+cargo check -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/02-overlay-store.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/02-overlay-store.md
new file mode 100644
index 000000000..7eaea3856
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/02-overlay-store.md
@@ -0,0 +1,42 @@
+# Phase 02 — ChangeOverlay Store
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Implement path-keyed **`ChangeOverlay`** and attach to **`NodeDefRegistry`**.
+
+**In scope:**
+
+- `change/overlay.rs` — `ChangeOverlay`, `OverlayEntry::{Deleted, Bytes}`
+- `BTreeMap` keyed by absolute path string (match
+ `artifact_path_to_id` convention)
+- `ChangeOverlay::clear`, `is_empty`, `contains`, `get`
+- Add `overlay: ChangeOverlay` field to `NodeDefRegistry::new()`
+- Introspection methods on registry (see design): `overlay_active`,
+ `overlay_contains_path`
+- Unit tests: empty overlay; manual insert/get in overlay tests only
+
+**Out of scope:** apply pipeline, target resolution, registry `apply_change`.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Do not mutate `store` or `entries` from overlay module.
+
+## Implementation details
+
+**Path key:** use `LpPathBuf::as_str()` or existing registry helper for stable
+keys. Document that keys must be absolute.
+
+**Registry field:** private `overlay` with public read-only introspection;
+mutation only via apply/discard in phase 03.
+
+**Default:** `ChangeOverlay::default()` empty map.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry overlay
+cargo check -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/03-apply-discard-lifecycle.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/03-apply-discard-lifecycle.md
new file mode 100644
index 000000000..5c7049db5
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/03-apply-discard-lifecycle.md
@@ -0,0 +1,62 @@
+# Phase 03 — Apply + Discard Lifecycle
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Wire **apply** and **discard** from registry to overlay; implement target
+resolution and M1 op semantics.
+
+**In scope:**
+
+- `change/apply.rs` — `apply_change(overlay, registry, change)` resolving targets:
+ - `Path(p)` — require absolute; implicit create overlay entry
+ - `Id(id)` — resolve via `NodeDefRegistry` internal `artifact_root_path`
+- Op handling: `SetBytes`, `Delete` only; other ops → `ChangeError::UnsupportedOp`
+- Multiple ops in one `ArtifactChange` applied in order
+- Registry methods:
+ - `apply_change(&mut self, change: &ArtifactChange) -> Result<(), ChangeError>`
+ - `apply_changeset(&mut self, cs: &ChangeSet) -> Result<(), ChangeError>`
+ - `discard_overlay(&mut self)`
+- Integration tests in `tests/overlay_lifecycle.rs`:
+ - **D1** — load project, snapshot `entries` (ids + states), apply `SetBytes` to
+ new path `/pending.glsl`, assert overlay contains path, entries unchanged
+ - **D3** — after D1, discard, overlay empty, entries still match snapshot
+ - implicit create on path not in store
+ - relative path rejected
+
+**Out of scope:** effective bytes read (M2), commit (M5).
+
+## Sub-agent reminders
+
+- Do not commit.
+- Do not re-parse defs on apply.
+- Reuse `harness/fixtures` where possible.
+
+## Implementation details
+
+**D1 test sketch:**
+
+```rust
+let snapshot = registry.entries_snapshot_for_test(); // or clone key fields
+registry.apply_change(&ArtifactChange {
+ target: ArtifactTarget::Path(LpPathBuf::from("/pending.glsl")),
+ ops: vec![ArtifactOp::SetBytes("void main() {}".into())],
+})?;
+assert!(registry.overlay_contains_path(LpPath::new("/pending.glsl")));
+assert_entries_unchanged(®istry, &snapshot);
+```
+
+If no test snapshot helper exists, compare `entries.len()`, root `NodeDefState`,
+and `source_index` len — avoid brittle full clone unless easy.
+
+**ChangeSet batch:** `apply_changeset` applies each `ArtifactChange` in vec order;
+first error aborts (all-or-nothing apply).
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test overlay_lifecycle
+cargo test -p lpc-node-registry --test fs_change_semantics
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/04-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/04-cleanup-validation.md
new file mode 100644
index 000000000..cde88671d
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/04-cleanup-validation.md
@@ -0,0 +1,41 @@
+# Phase 04 — Cleanup + Validation
+
+**Dispatch:** sub-agent: main | parallel: -
+
+## Scope of phase
+
+Final polish for M1: docs, exports, clippy, remove stubs.
+
+**In scope:**
+
+- Update `change/mod.rs` module docs; remove "milestone M5" stub comment in any
+ remaining files
+- Ensure `lib.rs` exports are complete and documented
+- `cargo +nightly fmt` on touched files
+- Run full crate validation
+- Add brief `summary.md` in this plan folder with validation commands + files
+ touched
+
+**Out of scope:** M2 projection work.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test fs_change_semantics
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+```
+
+If workspace gate needed before push:
+
+```bash
+just check # optional; run if agent touches wider surface
+```
+
+## Milestone sign-off
+
+- [ ] D1 test green
+- [ ] D3 test green
+- [ ] Serde round-trip tests green
+- [ ] No `lpc-engine` changes
+- [ ] Parent fs-change tests still green
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/summary.md
new file mode 100644
index 000000000..4a12881ab
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m1-change-language-overlay/summary.md
@@ -0,0 +1,25 @@
+# M1 Summary — Change Language + Overlay Lifecycle
+
+## Status
+
+Implemented on branch `codex/incremental-artifact-reload`.
+
+## Delivered
+
+- `src/change/` — `ChangeSet`, `ArtifactChange`, `ArtifactOp`, `ArtifactTarget`, `ChangeError`
+- `ChangeOverlay` on `NodeDefRegistry` with apply/discard API
+- M1 op apply: `SetBytes`, `Delete`; slot ops return `UnsupportedOp`
+- Tests: D1, D3 + serde round-trip + path/implicit-create coverage
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test fs_change_semantics
+cargo test -p lpc-node-registry --test overlay_lifecycle
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+```
+
+## Next
+
+M2 effective projection — wire `NodeDefView` and artifact reads through overlay.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split.md
new file mode 100644
index 000000000..afb6c15e6
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split.md
@@ -0,0 +1,22 @@
+# M10 — SlotEdit / AssetEdit Split
+
+Split `EditOp` into `SlotEdit` + `AssetEdit` and make `ArtifactEdit` a tagged
+union. Prerequisite for clean asset-side evolution (partial diffs later).
+
+**Plan:** [`00-design.md`](00-design.md) | **Notes:** [`00-notes.md`](00-notes.md)
+
+## Phases
+
+| # | File | Summary |
+|---|------|---------|
+| 01 | [01-edit-types-serde.md](01-edit-types-serde.md) | New enums, delete `edit_op.rs`, serde tests |
+| 02 | [02-apply-pipeline.md](02-apply-pipeline.md) | `apply.rs`, `slot_apply`, registry dispatch |
+| 03 | [03-diff-project-diff.md](03-diff-project-diff.md) | `def_diff`, `project_diff` return shapes |
+| 04 | [04-tests-and-docs.md](04-tests-and-docs.md) | Harness tests, `change-language.md` |
+| 05 | [05-cleanup-validation.md](05-cleanup-validation.md) | fmt, clippy, CI gate, summary |
+
+**Parallel:** 02 ∥ 03 after 01.
+
+## Status
+
+Complete — types, apply, diff, tests, and docs migrated.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-design.md
new file mode 100644
index 000000000..60a8b367b
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-design.md
@@ -0,0 +1,171 @@
+# M10 Design — SlotEdit / AssetEdit Split
+
+## Scope
+
+Replace monolithic `EditOp` with two op enums and a tagged `ArtifactEdit`. Align
+types with overlay storage (`DefDraft` vs `Bytes` / `Deleted`) before wire or
+client work builds on the old shape.
+
+**In:** `lpc-node-registry` edit module, apply, diff, tests, change-language docs.
+
+**Out:** Partial text diffs, `lpc-wire`, serde compat shims for flat `{ target, ops }`.
+
+## File structure
+
+```text
+lp-core/lpc-node-registry/src/edit/
+├── mod.rs # exports SlotEdit, AssetEdit, ArtifactEdit, …
+├── slot_edit.rs # NEW — structured slot mutations
+├── asset_edit.rs # NEW — path-level file body ops
+├── artifact_edit.rs # REWRITE — tagged Slot | Asset
+├── edit_batch.rs # unchanged shape: Vec
+├── edit_target.rs
+├── edit_error.rs # drop UnsupportedOp for cross-kind misuse (keep for unknown variants if needed)
+├── apply.rs # match ArtifactEdit::Asset only
+├── slot_overlay.rs
+├── def_draft.rs
+└── edit_op.rs # DELETE
+
+lp-core/lpc-node-registry/src/
+├── registry/
+│ ├── slot_apply.rs # SlotEdit only; remove asset reject arm
+│ └── node_def_registry.rs # match ArtifactEdit::{Slot, Asset}
+└── diff/
+ ├── def_diff.rs # -> Vec
+ └── project_diff.rs # -> ArtifactEdit::Slot | Asset
+```
+
+## Types
+
+### `SlotEdit`
+
+Slot-tree mutations within a `.toml` artifact (unchanged semantics from current
+slot half of `EditOp`):
+
+```rust
+#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SlotEdit {
+ UseEnumVariant { path: SlotPath, variant: String },
+ AssignValue { path: SlotPath, value: LpValue },
+ MapInsert { path: SlotPath, key: String, value: LpValue },
+ MapRemove { path: SlotPath, key: String },
+ UseOption { path: SlotPath, present: bool },
+}
+```
+
+Each variant gets `op_name()` for logging/errors (same strings as today).
+
+### `AssetEdit`
+
+Path-level committed overlay state (whole artifact):
+
+```rust
+#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AssetEdit {
+ Delete,
+ ReplaceBody(String), // was SetBytes
+}
+```
+
+### `ArtifactEdit`
+
+```rust
+#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum ArtifactEdit {
+ Slot { target: EditTarget, ops: Vec },
+ Asset { target: EditTarget, ops: Vec },
+}
+```
+
+Optional convenience constructors (same file or `impl ArtifactEdit`):
+
+```rust
+impl ArtifactEdit {
+ pub fn slot(target: EditTarget, ops: Vec) -> Self { … }
+ pub fn asset(target: EditTarget, ops: Vec) -> Self { … }
+}
+```
+
+### Removed
+
+- `EditOp` — delete; no type alias (ambiguous union).
+- Deprecated `ArtifactOp = EditOp` — remove or repoint doc to `SlotEdit`/`AssetEdit`.
+
+## Apply flow
+
+```text
+EditBatch.edits[]
+ │
+ ▼
+apply_artifact_edit(edit)
+ │
+ ├─ ArtifactEdit::Asset { target, ops }
+ │ resolve target → path
+ │ for op in ops: apply_asset_op(overlay, path, op)
+ │ Delete → overlay.apply_delete
+ │ ReplaceBody → overlay.apply_bytes
+ │
+ └─ ArtifactEdit::Slot { target, ops }
+ resolve target → path
+ ensure .toml (existing ensure_toml_path)
+ fork DefDraft → apply each SlotEdit → write DefDraft back
+```
+
+No runtime `UnsupportedOp` for “slot op on asset path” — wrong `kind` is a
+client/authoring mistake caught at construction or (for `.glsl` + `Slot`) at
+existing `ensure_toml_path`.
+
+## Diff flow
+
+| Path kind | Output |
+|-----------|--------|
+| `.toml` content changed | `ArtifactEdit::Slot { ops: diff_node_defs(...) }` |
+| non-`.toml` added/changed | `ArtifactEdit::Asset { ops: [ReplaceBody(text)] }` |
+| any path removed | `ArtifactEdit::Asset { ops: [Delete] }` |
+
+`diff_node_defs` return type: `Vec`.
+
+## Serde / wire
+
+Breaking change from flat struct:
+
+```json
+// before
+{ "target": { "path": "/a.glsl" }, "ops": [{ "set_bytes": "…" }] }
+
+// after
+{ "kind": "asset", "target": { "path": "/a.glsl" }, "ops": [{ "replace_body": "…" }] }
+```
+
+Acceptable: no production wire consumers yet.
+
+## Documentation updates
+
+- [`change-language.md`](../change-language.md) — two op tables, tagged `ArtifactEdit`, examples
+- [`vocabulary.md`](../m8-edit-session-sync/vocabulary.md) — add M10 row: `EditOp` → `SlotEdit` + `AssetEdit`
+- [`overview.md`](../overview.md) — summary diagram snippet
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo check -p lpc-node-registry --no-default-features # embedded / no diff
+cargo test -p lpc-node-registry --features diff
+```
+
+Host CI gate when committing: `just check` + `just test`.
+
+## Phase map
+
+| Phase | Title | Depends |
+|-------|-------|---------|
+| 01 | Edit types + serde | — |
+| 02 | Apply pipeline | 01 |
+| 03 | Diff + project_diff | 01 |
+| 04 | Integration tests + docs | 02, 03 |
+| 05 | Cleanup + validation | 04 |
+
+Phases 02 and 03 can run in parallel after 01 (disjoint files).
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-notes.md
new file mode 100644
index 000000000..dd02d2f85
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/00-notes.md
@@ -0,0 +1,114 @@
+# M10 Notes — SlotEdit / AssetEdit Split
+
+## Scope
+
+Split the monolithic `EditOp` enum into **`SlotEdit`** and **`AssetEdit`**, and make
+`ArtifactEdit` a **tagged union** so each artifact block is either slot-structured
+or opaque-file, never both.
+
+**In:** `lpc-node-registry` edit types, apply/diff, in-crate tests, `change-language.md`,
+`vocabulary.md`.
+
+**Out:** `lpc-wire` / client protocol, partial text diff ops, engine cutover.
+
+## Current state
+
+Layer-1 edit vocabulary lives in `lpc-node-registry/src/edit/`:
+
+| File | Role |
+|------|------|
+| `edit_op.rs` | Single `EditOp` mixing slot + asset variants |
+| `artifact_edit.rs` | `{ target, ops: Vec }` |
+| `apply.rs` | Handles `Delete` / `SetBytes`; rejects slot ops |
+| `registry/slot_apply.rs` | Handles slot ops; rejects `Delete` / `SetBytes` |
+| `registry/node_def_registry.rs` | Dispatches per-op in `apply_artifact_edit` |
+
+Overlay storage already mirrors the split:
+
+```text
+SlotOverlayEntry = Deleted | Bytes | DefDraft
+```
+
+`def_diff` emits only slot ops. `project_diff` emits slot ops for `.toml` and
+`Delete` / `SetBytes` for other paths — but types allow invalid mixes (e.g. serde
+roundtrip test combines `SetBytes` + `UseEnumVariant` on one block).
+
+Recent naming (M9 follow-up, uncommitted or landing soon):
+
+- `UseEnumVariant`, `AssignValue`, `UseOption` (slot)
+- `Delete`, `SetBytes` (asset)
+
+## User intent
+
+- Split **before** building more on the edit model.
+- Names **`SlotEdit`** and **`AssetEdit`**.
+- Asset side should evolve independently (future partial text diffs); not implemented now.
+- Prefer language without “set” on slot ops (already done).
+
+## Open questions
+
+### Q1 — Serde wire shape for `ArtifactEdit`
+
+**Context:** Today `ArtifactEdit` is a struct `{ target, ops }`. Mixed op lists
+deserialize even when invalid at apply time.
+
+**Suggested answer:** Externally tagged union:
+
+```rust
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum ArtifactEdit {
+ Slot { target: EditTarget, ops: Vec },
+ Asset { target: EditTarget, ops: Vec },
+}
+```
+
+Wire example:
+
+```json
+{ "kind": "slot", "target": { "path": "/shader.toml" }, "ops": [ … ] }
+{ "kind": "asset", "target": { "path": "/shader.glsl" }, "ops": [ … ] }
+```
+
+**Status:** confirmed.
+
+### Q2 — Rename `SetBytes` → `ReplaceBody` on asset side?
+
+**Context:** User suggested `ReplaceBody` when discussing the split; clearer for
+whole-file replacement and distinct from future patch ops.
+
+**Suggested answer:** Yes, rename in this milestone (`AssetEdit::ReplaceBody`).
+
+**Status:** confirmed.
+
+### Q3 — Backward-compat for old `EditOp` / flat `ArtifactEdit` serde?
+
+**Context:** M8 vocabulary renamed types; wire not shipped to production clients.
+Harness tests are the only consumers.
+
+**Suggested answer:** Break wire shape cleanly. Remove `EditOp`. Keep deprecated
+`ArtifactOp` alias only if we add a shim — prefer **no shim**; update
+`#[deprecated] pub type ArtifactOp` doc to point at `SlotEdit | AssetEdit`.
+
+**Status:** confirmed — no serde shim.
+
+### Q4 — `AssetEdit` op list: `Vec` vs single op?
+
+**Context:** Today asset blocks usually have one op (`Delete` or `ReplaceBody`).
+Slot blocks often have many.
+
+**Suggested answer:** `Vec` for both variants — consistent, and leaves
+room for ordered multi-step asset edits without another shape change.
+
+**Status:** confirmed.
+
+## Dependencies
+
+- M9 slot op naming should land first (or in same branch) so phase 01 starts from
+ `UseEnumVariant` / `AssignValue` / `UseOption`, not legacy `VariantSet` / `SetSlot`.
+
+## Risks
+
+- Mechanical churn across ~10 test files and diff helpers — low logic risk.
+- `UnsupportedOp` paths should disappear; replace with compile-time enforcement.
+- `apply_slot_op_on_non_toml_path_errors` remains valid via `ArtifactEdit::Slot` on
+ a `.glsl` path.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/01-edit-types-serde.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/01-edit-types-serde.md
new file mode 100644
index 000000000..029564eaa
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/01-edit-types-serde.md
@@ -0,0 +1,98 @@
+# Phase 01 — Edit Types + Serde
+
+**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** M9 slot op names landed
+
+## Scope of phase
+
+Introduce `SlotEdit` and `AssetEdit`; rewrite `ArtifactEdit` as tagged union;
+remove `EditOp`.
+
+**In scope:**
+
+- Create `slot_edit.rs`, `asset_edit.rs`
+- Rewrite `artifact_edit.rs`
+- Delete `edit_op.rs`
+- Update `edit/mod.rs` exports and `lib.rs`
+- Serde roundtrip tests (slot block, asset block, batch with both)
+- Remove deprecated `ArtifactOp = EditOp` alias (or leave deprecated stub with note — prefer remove)
+
+**Out of scope:** apply, diff, integration tests beyond unit serde.
+
+## Code organization reminders
+
+- One concept per file (`slot_edit.rs`, `asset_edit.rs`).
+- `op_name()` on each op enum.
+- `#[cfg(test)] mod tests` at bottom of `artifact_edit.rs` or `mod.rs`.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Do not touch `apply.rs`, `slot_apply.rs`, `def_diff.rs` yet — crate will not compile until phase 02/03; that's OK for this phase if executed alone, but prefer doing 01→02→03 in sequence on one branch.
+- Fix warnings.
+
+## Implementation details
+
+### `slot_edit.rs`
+
+Move slot variants from current `edit_op.rs`:
+
+- `UseEnumVariant`, `AssignValue`, `MapInsert`, `MapRemove`, `UseOption`
+- `impl SlotEdit { pub fn op_name(&self) -> &'static str }`
+
+### `asset_edit.rs`
+
+```rust
+pub enum AssetEdit {
+ Delete,
+ ReplaceBody(String),
+}
+```
+
+- `impl AssetEdit { pub fn op_name(&self) -> &'static str }` → `"delete"`, `"replace_body"`
+
+### `artifact_edit.rs`
+
+```rust
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum ArtifactEdit {
+ Slot { target: EditTarget, ops: Vec },
+ Asset { target: EditTarget, ops: Vec },
+}
+```
+
+Add helpers:
+
+```rust
+impl ArtifactEdit {
+ pub fn target(&self) -> &EditTarget { … }
+ pub fn slot(target: EditTarget, ops: Vec) -> Self { … }
+ pub fn asset(target: EditTarget, ops: Vec) -> Self { … }
+}
+```
+
+### Exports (`edit/mod.rs`, `lib.rs`)
+
+```rust
+pub use asset_edit::AssetEdit;
+pub use slot_edit::SlotEdit;
+// remove EditOp export
+```
+
+### Tests
+
+Replace invalid mixed-op roundtrip in `edit/mod.rs`:
+
+- Test A: `ArtifactEdit::asset(Path("/shader.glsl"), [ReplaceBody(...)])`
+- Test B: `ArtifactEdit::slot(Path("/shader.toml"), [UseEnumVariant(...)])`
+- Test C: `EditBatch` with one slot + one asset edit
+
+Assert JSON contains `"kind":"slot"` / `"kind":"asset"`.
+
+## Validate
+
+```bash
+# After 02+03 land, full crate passes. For 01-only:
+cargo test -p lpc-node-registry edit::tests
+```
+
+If crate fails to compile due to downstream references, note in handoff — expected until phase 02.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/02-apply-pipeline.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/02-apply-pipeline.md
new file mode 100644
index 000000000..2fe0a5c3d
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/02-apply-pipeline.md
@@ -0,0 +1,107 @@
+# Phase 02 — Apply Pipeline
+
+**Dispatch:** sub-agent: yes | parallel: 03 | **Depends on:** 01
+
+## Scope of phase
+
+Route apply through `ArtifactEdit::{Slot, Asset}`; eliminate cross-kind
+`UnsupportedOp` guards.
+
+**In scope:**
+
+- `edit/apply.rs` — apply `ArtifactEdit::Asset` (and batch loop)
+- `registry/slot_apply.rs` — `SlotEdit` only
+- `registry/node_def_registry.rs` — `apply_artifact_edit` / `apply_edit_batch`
+- `edit/edit_error.rs` — remove `UnsupportedOp` if unused; keep if still needed elsewhere
+
+**Out of scope:** diff, integration test updates (phase 04), docs.
+
+## Code organization reminders
+
+- `apply_asset_op(overlay, path, &AssetEdit)` in `apply.rs` (private).
+- Slot apply stays in `slot_apply.rs`.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Do not weaken `ensure_toml_path` — slot edits on `.glsl` still error.
+- Do not suppress warnings.
+
+## Implementation details
+
+### `apply.rs`
+
+Replace `apply_op(..., &EditOp)` with:
+
+```rust
+pub fn apply_artifact_edit(...) {
+ let path = resolve_path(...)?;
+ match edit {
+ ArtifactEdit::Asset { ops, .. } => {
+ for op in ops { apply_asset_op(slot_overlay, path.clone(), op)?; }
+ }
+ ArtifactEdit::Slot { .. } => {
+ return Err(EditError::UnsupportedOp { … }); // OR delegate to registry only
+ }
+ }
+}
+```
+
+**Decision:** `edit/apply.rs` is the low-level overlay API used without registry
+context. Options:
+
+1. **Preferred:** `apply_artifact_edit` in `apply.rs` handles **Asset only**;
+ `NodeDefRegistry::apply_artifact_edit` matches full `ArtifactEdit` and calls
+ slot path for `Slot`.
+2. Registry method remains the public entry for both kinds.
+
+Update `apply_edit_batch` to match.
+
+`apply_asset_op`:
+
+```rust
+match op {
+ AssetEdit::Delete => slot_overlay.apply_delete(path),
+ AssetEdit::ReplaceBody(text) => slot_overlay.apply_bytes(path, text.into_bytes()),
+}
+```
+
+### `slot_apply.rs`
+
+- Change signatures: `&EditOp` → `&SlotEdit`, `&[EditOp]` → `&[SlotEdit]`
+- Remove arm: `EditOp::Delete | EditOp::SetBytes(_) => Err(UnsupportedOp)`
+- `apply_op_to_def` matches only `SlotEdit` variants
+
+### `node_def_registry.rs`
+
+```rust
+pub fn apply_artifact_edit(&mut self, change: &ArtifactEdit, ...) {
+ let path = self.resolve_edit_target(...)?;
+ match change {
+ ArtifactEdit::Asset { ops, .. } => {
+ for op in ops {
+ apply_asset_op(&mut self.slot_overlay, path.clone(), op)?;
+ }
+ }
+ ArtifactEdit::Slot { ops, .. } => {
+ for op in ops {
+ self.apply_slot_op(path.clone(), op, fs, ctx, frame)?;
+ }
+ }
+ }
+}
+```
+
+Remove per-op `match` on `EditOp`.
+
+### `SyncOp::Apply(ArtifactEdit)`
+
+No signature change — compiles once call sites updated in phase 04.
+
+## Validate
+
+```bash
+cargo check -p lpc-node-registry
+```
+
+Full tests may fail until phase 04 updates call sites.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/03-diff-project-diff.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/03-diff-project-diff.md
new file mode 100644
index 000000000..7affa0d78
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/03-diff-project-diff.md
@@ -0,0 +1,68 @@
+# Phase 03 — Diff + project_diff
+
+**Dispatch:** sub-agent: yes | parallel: 02 | **Depends on:** 01
+
+## Scope of phase
+
+Update diff helpers to emit `SlotEdit` and tagged `ArtifactEdit`.
+
+**In scope:**
+
+- `diff/def_diff.rs` — return `Vec`; update `push_*` helpers
+- `diff/project_diff.rs` — wrap results in `ArtifactEdit::Slot` / `::Asset`
+- `registry/slot_apply.rs` — `apply_ops_to_node_def` takes `&[SlotEdit]` (if not done in 02)
+
+**Out of scope:** integration tests (phase 04), docs.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Preserve diff verify loop (apply ops to clone, compare TOML).
+- Empty slot diff → omit `ArtifactEdit` entry (unchanged behavior).
+
+## Implementation details
+
+### `def_diff.rs`
+
+```rust
+pub fn diff_node_defs(...) -> Result, DiffError>
+```
+
+- All `EditOp::…` → `SlotEdit::…`
+- `ops: &mut Vec` in internal helpers
+
+### `project_diff.rs`
+
+```rust
+(Some(_), None) => changes.push(ArtifactEdit::asset(
+ EditTarget::Path(...),
+ vec![AssetEdit::Delete],
+)),
+
+// .toml
+changes.push(ArtifactEdit::slot(
+ EditTarget::Path(...),
+ ops,
+));
+
+// non-.toml
+changes.push(ArtifactEdit::asset(
+ EditTarget::Path(...),
+ vec![AssetEdit::ReplaceBody(text)],
+));
+```
+
+Remove `EditOp` import.
+
+### `def_diff` unit test
+
+`diff_shader_from_default` — update assertions if op type names changed only.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry diff::
+cargo test -p lpc-node-registry --test project_diff
+```
+
+Integration tests outside `diff` module may fail until phase 04.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/04-tests-and-docs.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/04-tests-and-docs.md
new file mode 100644
index 000000000..2af297ef0
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/04-tests-and-docs.md
@@ -0,0 +1,94 @@
+# Phase 04 — Integration Tests + Docs
+
+**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 02, 03
+
+## Scope of phase
+
+Update all harness/integration tests and canonical change-language docs.
+
+**In scope:**
+
+- Tests under `lpc-node-registry/tests/`:
+ - `asset_overlay.rs`
+ - `commit_promotion.rs`
+ - `effective_projection.rs`
+ - `overlay_lifecycle.rs`
+ - `pending_sync.rs`
+ - `slot_overlay.rs`
+- `change-language.md`
+- `m8-edit-session-sync/vocabulary.md` (M10 table)
+- `overview.md` change-language summary (optional one-liner)
+
+**Out of scope:** archived plan docs, M9 phase files (historical).
+
+## Sub-agent reminders
+
+- Do not commit.
+- Use `ArtifactEdit::slot(...)` / `::asset(...)` helpers for readability.
+- Fix test names/comments referencing `SetBytes` → `ReplaceBody` where user-facing.
+
+## Implementation details
+
+### Test migration pattern
+
+```rust
+// before
+ArtifactEdit {
+ target: EditTarget::Path(...),
+ ops: vec![EditOp::SetBytes("…".into())],
+}
+
+// after
+ArtifactEdit::asset(
+ EditTarget::Path(...),
+ vec![AssetEdit::ReplaceBody("…".into())],
+)
+```
+
+```rust
+// before
+ArtifactEdit { target, ops: vec![EditOp::AssignValue { … }] }
+
+// after
+ArtifactEdit::slot(target, vec![SlotEdit::AssignValue { … }])
+```
+
+```rust
+// Delete
+ArtifactEdit::asset(target, vec![AssetEdit::Delete])
+```
+
+### Imports
+
+Replace `EditOp` with `SlotEdit`, `AssetEdit` in test use lines.
+
+### `change-language.md`
+
+Rewrite ops section:
+
+```rust
+enum ArtifactEdit {
+ Slot { target: EditTarget, ops: Vec },
+ Asset { target: EditTarget, ops: Vec },
+}
+```
+
+Two op tables (slot vs asset). Update all examples (including creatability + add-shader example).
+
+### `vocabulary.md`
+
+Add section:
+
+| Old | New |
+|-----|-----|
+| `EditOp` | removed — use `SlotEdit` or `AssetEdit` |
+| flat `ArtifactEdit { ops }` | tagged `ArtifactEdit::Slot` / `::Asset` |
+| `SetBytes` | `AssetEdit::ReplaceBody` |
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+```
+
+All tests must pass.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/05-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/05-cleanup-validation.md
new file mode 100644
index 000000000..38a0565b4
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/05-cleanup-validation.md
@@ -0,0 +1,60 @@
+# Phase 05 — Cleanup + Validation
+
+**Dispatch:** main | parallel: - | **Depends on:** 04
+
+## Scope of phase
+
+Final pass: grep for stale symbols, fmt, clippy, summary.
+
+**In scope:**
+
+- `cargo +nightly fmt`
+- Grep cleanup:
+ - no `EditOp` in `lp-core/lpc-node-registry/`
+ - no `SetBytes` in edit vocabulary (except comments/history if any)
+ - no flat `ArtifactEdit { target, ops` struct literals
+- `UnsupportedOp` — remove from `EditError` if dead
+- Write `m10-slot-asset-edit-split/summary.md`
+- Update `docs/roadmaps/.../changeset-change-management/summary.md` — M10 entry
+
+**Out of scope:** wire crate, engine, partial diff implementation.
+
+## Cleanup checklist
+
+- [ ] `EditOp` deleted; not exported from `lib.rs`
+- [ ] `ArtifactOp` deprecated alias removed or documents split
+- [ ] Serde tests cover slot + asset kinds
+- [ ] `apply_slot_op_on_non_toml_path_errors` still passes
+- [ ] `project_diff` equivalence tests green
+- [ ] Warnings fixed
+
+## Validate
+
+```bash
+rustup update nightly
+just check
+cargo test -p lpc-node-registry
+```
+
+Optional (if touching nothing outside registry):
+
+```bash
+cargo test -p lpc-engine --lib
+```
+
+## Commit message (when user asks)
+
+```
+refactor(lpc-node-registry): split EditOp into SlotEdit and AssetEdit
+
+- Tagged ArtifactEdit::{Slot, Asset} replaces flat ops list
+- Rename SetBytes to AssetEdit::ReplaceBody
+- Align apply/diff with overlay DefDraft vs Bytes split
+```
+
+## summary.md template
+
+- Status: complete
+- Delivered: typed split, apply/diff migration, docs
+- Breaking: serde wire shape for `ArtifactEdit`; `EditOp` removed
+- Validation commands
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/future.md
new file mode 100644
index 000000000..5da372718
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m10-slot-asset-edit-split/future.md
@@ -0,0 +1,18 @@
+# M10 Future — Asset edit extensions
+
+## Partial text diffs
+
+- **Idea:** Add patch-style variants to `AssetEdit` (range replace, splice, unified diff hunks).
+- **Why not now:** Overlay and apply already work with whole-body `ReplaceBody`; slot path covers structured TOML edits.
+- **Useful context:** `AssetEdit` enum in `asset_edit.rs`; `SlotOverlayEntry::Bytes` may need merge semantics instead of last-write-wins for incremental patches.
+
+## Binary assets
+
+- **Idea:** `ReplaceBody(Vec)` or separate `ReplaceBytes` when non-UTF-8 assets matter.
+- **Why not now:** Current model uses `String` for text assets; UTF-8 assumption matches GLSL/SVG/TOML escape hatch.
+- **Useful context:** `apply_bytes` in `slot_overlay.rs`.
+
+## TOML import escape hatch typing
+
+- **Idea:** Explicit `ArtifactEdit::Asset` on `.toml` paths for bulk import vs `Slot` for normal authoring — already enforced by kind tag; could add apply-time warning in debug builds.
+- **Why not now:** Convention + type split is sufficient for v1.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection.md
new file mode 100644
index 000000000..5fc6c4386
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection.md
@@ -0,0 +1,66 @@
+# Milestone 2: Effective Projection
+
+## Title And Goal
+
+Wire **effective reads** through overlay ∪ base. All public registry/view reads
+return **effective** state — never a committed-only shortcut. Prove the read
+contract parent **M6** engine cutover will rely on.
+
+## Parallel Build
+
+**`lpc-node-registry` only.** **`lpc-engine` unchanged.**
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/`
+
+## Scope
+
+In scope:
+
+- `read_effective_bytes(path)` — overlay before `ArtifactStore` / fs
+- `NodeDefView::get` — effective `NodeDefState` (committed entry ∪ overlay draft)
+- Internal artifact queries on registry route through overlay
+- Eager re-parse of touched `.toml` def artifacts (same shape as parent M4
+ `sync`, touched paths only)
+- Harness:
+ - apply overlay change → view ≠ committed `entries`
+ - discard → view == committed `entries` again
+
+Out of scope:
+
+- Full file/slot op apply (**M3**, **M4**)
+- Commit (**M5**)
+- Typed `*DefView` against effective `SlotShapeLookup` — optional; materialized
+ effective `NodeDef` OK for M2
+- Provenance / mutated badges — client or parent **M10** probes
+
+## Key Decisions
+
+- **`entries` = committed cache** — updated only on commit / `sync_fs`.
+- **Public reads = effective only** — callers need not know pending vs committed.
+- **No provenance on read path** — values only; memory constraint on ESP32.
+
+## User Stories / Gate
+
+| ID | Story | Covered |
+|----|-------|---------|
+| D1 | Apply → effective view ≠ committed base | **Extends M1** |
+
+## Deliverables
+
+- Effective read path on `NodeDefRegistry`
+- `NodeDefView` updated from passthrough stub
+- Projection harness tests
+
+## Dependencies
+
+- M1 change language + overlay lifecycle
+
+## Execution Strategy
+
+Full plan. Projection is the engine-integration contract.
+
+Suggested chat opener:
+
+> M2 plan: effective NodeDefView + artifact reads through overlay. Full plan then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-design.md
new file mode 100644
index 000000000..a9a3bbd9e
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-design.md
@@ -0,0 +1,100 @@
+# M2 Design — Effective Projection
+
+## Scope
+
+Effective artifact byte reads and effective def projection via `NodeDefView`.
+**`lpc-engine` untouched.**
+
+Depends on M1 (`ChangeOverlay`, apply/discard).
+
+## File structure
+
+```
+lp-core/lpc-node-registry/src/
+├── registry/
+│ ├── effective_read.rs # read_effective_bytes, parse_effective_state
+│ └── node_def_registry.rs # delegate / thin wrappers
+├── view/
+│ └── node_def_view.rs # effective get(state)
+└── tests/
+ └── effective_projection.rs # D1 view vs committed
+```
+
+## Architecture
+
+```text
+read_effective_bytes(path, fs)
+ │
+ ├─ overlay.contains(path)?
+ │ ├─ Deleted → None (parse → error state)
+ │ └─ Bytes → return bytes
+ │
+ └─ else artifact_path_to_id → store.read_bytes
+
+parse_effective_state(artifact_id, fs, ctx)
+ └─ read_effective_bytes(artifact_root_path) → NodeDef::read_toml
+
+NodeDefView::get(id, fs, ctx) -> Option
+ └─ committed entry metadata + effective state (owned clone)
+```
+
+### API
+
+```rust
+impl NodeDefRegistry {
+ /// Bytes for `path` from overlay if present, else committed store/fs.
+ pub fn read_effective_bytes(
+ &mut self,
+ path: &LpPath,
+ fs: &dyn LpFs,
+ ) -> Result>, RegistryError>;
+
+ pub fn view(&self) -> NodeDefView<'_>;
+}
+
+impl NodeDefView<'_> {
+ /// Effective def entry (overlay ∪ base). Always owned.
+ pub fn get(
+ &self,
+ id: &NodeDefId,
+ fs: &dyn LpFs,
+ ctx: &ParseCtx<'_>,
+ ) -> Option;
+
+ pub fn state(
+ &self,
+ id: &NodeDefId,
+ fs: &dyn LpFs,
+ ctx: &ParseCtx<'_>,
+ ) -> Option;
+}
+```
+
+**Unchanged:** `NodeDefRegistry::get` returns committed `entries` (internal/sync
+cache). Callers wanting effective state use `view().get(...)`.
+
+### M2 overlay → def semantics
+
+Only whole-file overlay bytes (M1 `SetBytes` / `Delete`). Replacing
+`/clock.toml` bytes replaces the entire parsed tree for all `DefSource` rows on
+that artifact until discard.
+
+Slot-level overlay draft (partial TOML merge) is **M4**.
+
+## Tests
+
+| Test | Asserts |
+|------|---------|
+| `effective_view_differs_after_toml_setbytes` | apply SetBytes on `/clock.toml`; view rate=2, committed rate=1 |
+| `effective_view_matches_committed_without_overlay` | load + view.get == committed |
+| `discard_restores_effective_view_to_committed` | after discard, view matches committed |
+| `effective_deleted_overlay_yields_parse_error` | Delete on loaded `.toml`; view error, committed loaded |
+
+Use `fixtures::load_clock()` + `ParseCtx`.
+
+## Non-goals
+
+- `materialize_source` overlay (M3)
+- Slot op overlay merge (M4)
+- Commit / SyncResult (M5)
+- Effective cache across frames
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-notes.md
new file mode 100644
index 000000000..7af8bbe36
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/00-notes.md
@@ -0,0 +1,81 @@
+# M2 Notes — Effective Projection
+
+## Scope
+
+Wire **effective reads** (overlay ∪ committed base) through `NodeDefRegistry` and
+`NodeDefView`. Prove D1 extension: apply changes what callers **see**, not what
+`entries` stores.
+
+**Out of scope:** slot-draft overlay (M4), commit (M5), materialize overlay
+(M3), `*DefView` typed accessors, provenance.
+
+## Current codebase (post-M1)
+
+```
+lp-core/lpc-node-registry/src/
+├── change/overlay.rs # OverlayEntry::{Deleted, Bytes}
+├── registry/node_def_registry.rs
+│ ├── store.read_bytes # committed artifact bytes only
+│ ├── read_artifact_state # parse via store (no overlay)
+│ └── apply_change / discard_overlay
+└── view/node_def_view.rs # passthrough registry.get (committed)
+```
+
+M1 tests prove overlay + committed `entries` diverge on apply, but **no read path
+uses overlay yet**.
+
+## Resolved design (2026-05-21)
+
+- **`entries` / `registry.get`** — committed cache only (unchanged on apply).
+- **`NodeDefView`** — **effective only**; requires `fs` + `ParseCtx` to parse
+ overlay bytes when present.
+- **M2 overlay semantics** — whole-file `SetBytes` / `Delete` only (same as M1
+ apply). Slot-draft merge deferred to M4.
+- **Parse strategy** — on-read: if overlay touches artifact path, parse overlay
+ bytes → `NodeDefState`; else clone committed entry state. No persistent
+ effective cache in M2 (discard/apply is cheap enough for harness).
+- **`read_effective_bytes(path)`** — single choke point: overlay → store/fs.
+
+## Open questions
+
+### Q1: NodeDefView::get signature
+
+- **Context:** Effective parse needs `fs` + `ParseCtx`. Current `get(&NodeDefId)
+ -> Option<&NodeDefEntry>` borrows committed entries.
+- **Suggested answer:** Change to owned effective entry:
+ `get(id, fs, ctx) -> Option`. Crate has no external callers yet;
+ engine cutover is M6.
+
+### Q2: Deleted overlay path
+
+- **Context:** `OverlayEntry::Deleted` on a loaded artifact.
+- **Suggested answer:** Effective parse returns `NodeDefState::ParseError` with
+ read failure shape (same as missing file from store). Harness asserts view
+ shows error/parse failure vs committed loaded state.
+
+### Q3: Path-only overlay (implicit create)
+
+- **Context:** M1 allows overlay on paths not in `artifact_path_to_id`.
+- **Suggested answer:** `NodeDefView` only resolves existing `NodeDefId`s.
+ Implicit-create paths are visible via `overlay_bytes` until commit registers
+ them. No phantom defs in view until M5 commit + register.
+
+### Q4: materialize_source in M2?
+
+- **Context:** Asset reads should eventually use overlay (M3).
+- **Suggested answer:** **Defer to M3.** M2 only routes def parse +
+ `read_effective_bytes`; materialize keeps store path until M3.
+
+## User stories
+
+| ID | Story | How |
+|----|-------|-----|
+| D1 ext | Apply → **view** ≠ committed `entries` | SetBytes on `/clock.toml`; view new rate, `registry.get` old |
+
+## Validation baseline
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test overlay_lifecycle
+cargo test -p lpc-node-registry --test fs_change_semantics
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/01-effective-bytes-read.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/01-effective-bytes-read.md
new file mode 100644
index 000000000..1dd5d483c
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/01-effective-bytes-read.md
@@ -0,0 +1,45 @@
+# Phase 01 — Effective Bytes Read
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Implement `NodeDefRegistry::read_effective_bytes` — overlay before store.
+
+**In scope:**
+
+- New `registry/effective_read.rs` with byte resolution logic
+- `read_effective_bytes(&mut self, path: &LpPath, fs) -> Result>, RegistryError>`
+- Handle overlay `Bytes`, `Deleted` (return `Ok(None)`)
+- Fall through to `artifact_path_to_id` + `store.read_bytes` when no overlay
+- Path not in store and not in overlay → `Ok(None)`
+- Unit tests on registry or `effective_read` module
+
+**Out of scope:** NodeDefView, TOML parse, materialize.
+
+## Implementation details
+
+**Overlay precedence:**
+
+1. If `overlay.contains_path(path)`:
+ - `Deleted` → `Ok(None)`
+ - `Bytes(b)` → `Ok(Some(b))`
+2. Else if `artifact_path_to_id` has path → `store.read_bytes`
+3. Else → `Ok(None)`
+
+**Requires `&mut self`** because `store.read_bytes` mutates read state (matches
+existing `read_artifact_state` pattern).
+
+**Tests:**
+
+- overlay SetBytes wins over store for loaded shader.glsl
+- no overlay delegates to store
+- overlay Delete returns None
+- implicit-create overlay path returns bytes without store entry
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry effective
+cargo check -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/02-effective-def-parse.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/02-effective-def-parse.md
new file mode 100644
index 000000000..7f1fb743f
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/02-effective-def-parse.md
@@ -0,0 +1,37 @@
+# Phase 02 — Effective Def Parse
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Parse effective TOML for a committed artifact and expose via registry helper.
+
+**In scope:**
+
+- `parse_effective_state(artifact_id, fs, ctx) -> Result`
+ in `effective_read.rs`
+- Uses `artifact_root_path` + `read_effective_bytes`
+- UTF-8 + `NodeDef::read_toml` — same as `read_artifact_state` but effective bytes
+- Refactor `read_artifact_state` to call `parse_effective_state` **or** keep
+ committed path separate (committed = store only; effective = overlay path).
+ **Do not** change committed `sync`/`load_root` parse to use overlay.
+
+**Out of scope:** NodeDefView API change (phase 03).
+
+## Key rule
+
+| Path | Bytes source |
+|------|----------------|
+| `sync` / `load_root` / `entries` | store only (committed) |
+| effective parse / view | overlay ∪ store |
+
+## Tests
+
+- Unit: apply SetBytes on `/clock.toml`; `parse_effective_state` shows new field
+ values; direct `read_artifact_state` (if still exposed internally) unchanged
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/03-node-def-view-projection.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/03-node-def-view-projection.md
new file mode 100644
index 000000000..af145a2ba
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/03-node-def-view-projection.md
@@ -0,0 +1,39 @@
+# Phase 03 — NodeDefView Projection
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Wire `NodeDefView` to effective parse; add integration tests.
+
+**In scope:**
+
+- `NodeDefRegistry::view(&self) -> NodeDefView`
+- Update `NodeDefView::get(id, fs, ctx) -> Option` — owned effective
+- Update `NodeDefView::state(id, fs, ctx) -> Option`
+- Clone committed entry shell (`id`, `source`, `last_seen_revision`); replace
+ `state` with effective parse at entry's artifact root when overlay active on
+ that artifact path; else clone committed state
+- `tests/effective_projection.rs` — D1 extension tests from design
+- Update `view/mod.rs` docs (remove M5 stub comment)
+
+**Out of scope:** materialize (M3), changing `registry.get` semantics.
+
+## Test scenarios
+
+1. **toml setbytes** — apply new clock.toml body; view shows new rate; `registry.get` old
+2. **no overlay** — view matches committed
+3. **discard** — view matches committed after discard
+4. **delete overlay** — view parse error on that def; committed still loaded
+
+## Sub-agent reminders
+
+- Do not commit.
+- `registry.get` stays committed-only.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry --test effective_projection
+cargo test -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/04-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/04-cleanup-validation.md
new file mode 100644
index 000000000..589241c78
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m2-effective-projection/04-cleanup-validation.md
@@ -0,0 +1,33 @@
+# Phase 04 — Cleanup + Validation
+
+**Dispatch:** sub-agent: main | parallel: -
+
+## Scope of phase
+
+Docs, fmt, clippy, milestone summary.
+
+**In scope:**
+
+- `summary.md` in this plan folder
+- Module docs on `effective_read`, `NodeDefView`
+- `cargo +nightly fmt`
+- Full crate validation
+
+**Out of scope:** M3 materialize overlay.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test fs_change_semantics
+cargo test -p lpc-node-registry --test overlay_lifecycle
+cargo test -p lpc-node-registry --test effective_projection
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+```
+
+## Milestone sign-off
+
+- [ ] `read_effective_bytes` overlay precedence
+- [ ] `NodeDefView::get` effective only (requires fs + ctx)
+- [ ] Committed `entries` unchanged on apply (regression)
+- [ ] D1 view-vs-committed test green
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay.md b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay.md
new file mode 100644
index 000000000..e175f3c0d
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay.md
@@ -0,0 +1,65 @@
+# Milestone 3: File Ops + Asset Reads
+
+## Title And Goal
+
+Implement file-level **`ArtifactOp`** (`SetBytes`, `Delete`) on overlay and wire
+**effective asset byte reads** (including `materialize_source` from overlay).
+Prove **C4*** stories.
+
+## Parallel Build
+
+**`lpc-node-registry` only.** **`lpc-engine` unchanged.**
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/`
+
+## Scope
+
+In scope:
+
+- Apply `SetBytes` / `Delete` on path-keyed overlay (implicit create on `Path`)
+- `read_effective_bytes` for asset paths (`.glsl`, `.svg`, …)
+- `materialize_source` reads overlay when present (before store/fs)
+- Harness spot tests:
+ - **C4c** — replace `.glsl` via overlay; def slot unchanged; source revision
+ bumps after commit
+ - **C4a/b/d** — add asset, delete asset, replace without touching def TOML
+
+Out of scope:
+
+- Slot op apply on `.toml` (**M4**)
+- Binary assets ([`future.md`](future.md))
+- Commit promotion (**M5**) — may test bytes in overlay only until M5
+
+## Key Decisions
+
+- **Assets use file ops** — not slot ops; same `ArtifactChange` grouping as TOML.
+- **Whole-file text only** in v1 — no byte-range patches.
+- **User term "asset"** = non-node file; store identity remains **artifact**.
+
+## User Stories / Gate
+
+| ID | Story | Covered |
+|----|-------|---------|
+| C4c | Replace GLSL; def unchanged; source revision after commit | **Yes** |
+| C4a/b/d | Add / delete / replace asset files | Spot tests |
+
+## Deliverables
+
+- File op apply on overlay
+- Asset effective read + materialize integration
+- `tests/changeset/` asset scenarios
+
+## Dependencies
+
+- M1 overlay lifecycle
+- M2 effective projection
+
+## Execution Strategy
+
+Full plan.
+
+Suggested chat opener:
+
+> M3 plan: SetBytes/Delete + overlay asset reads for materialize. Full plan then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/summary.md
new file mode 100644
index 000000000..7c83664e1
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m3-asset-overlay/summary.md
@@ -0,0 +1,43 @@
+# M3 Summary — File Ops + Asset Reads
+
+## Status
+
+Implemented (`81ff051b`) on branch `codex/incremental-artifact-reload`.
+
+Plan folder backfilled post-hoc; execution skipped numbered phase files.
+
+## Delivered
+
+- `SetBytes` / `Delete` apply on overlay (via `change/apply.rs` + registry routing)
+- `read_effective_bytes` — overlay before committed store/fs
+- `materialize_source` — checks `ChangeOverlay` before store read
+- `NodeDefRegistry::materialize_source` wrapper
+- `source_bridge` passes overlay into materialize path
+
+## Tests
+
+`lp-core/lpc-node-registry/tests/asset_overlay.rs` — C4a–d:
+
+- C4a — add asset via overlay implicit create
+- C4b — replace asset bytes
+- C4c — replace GLSL; def TOML unchanged in committed cache
+- C4d — delete asset via overlay
+
+Also covered indirectly: `overlay_lifecycle.rs` (SetBytes/Delete lifecycle).
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry --test asset_overlay
+cargo test -p lpc-node-registry --test overlay_lifecycle
+cargo test -p lpc-node-registry
+```
+
+## Deferred to M5+
+
+- Source revision bump after commit (C4c post-commit expectation)
+- Commit promotion of overlay assets to store/fs
+
+## Next
+
+M4 slot ops + TOML serialize; M5 commit promotion.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches.md b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches.md
new file mode 100644
index 000000000..94860caba
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches.md
@@ -0,0 +1,68 @@
+# Milestone 4: Slot Ops + TOML Serialize
+
+## Title And Goal
+
+Implement slot-level **`ArtifactOp`** (`SetSlot`, `MapInsert`, `MapRemove`,
+`OptionSet`, …) on overlay TOML artifacts and **serialize overlay slot trees to
+TOML bytes** (commit path prep — promotion itself is **M5**). Prove **C1*** and
+**C2*** stories.
+
+## Parallel Build
+
+**`lpc-node-registry` only.** **`lpc-engine` unchanged.**
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/`
+
+## Scope
+
+In scope:
+
+- Apply slot ops at `SlotPath` within target artifact ([`change-language.md`](change-language.md))
+- Overlay holds slot draft per `.toml` path (lazy fork from committed parse OK)
+- **Serialize** effective slot tree → TOML text (slot codec); used by M5 commit
+- Harness:
+ - **C1a–f** — kind + defaults + slot patches; wiring via slot ops on parent
+ - **C2a–c** — inline child edits at nested paths (`entries[n].node`, …)
+ - Inline child edit marks child changed in `NodeDefUpdates` shape (post-commit
+ expectation documented for M5)
+
+Out of scope:
+
+- **C3** inline ↔ standalone refactor ([`future.md`](future.md))
+- Commit flush to store (**M5**)
+- `CreateDef` op — kind + defaults via slot ops only
+
+## Key Decisions
+
+- **Node defs are slots** — all TOML authoring via slot ops at `root()` or inline
+ paths.
+- **Wiring = slot ops** — e.g. `SetSlot` on `nodes[shader].def` path locator.
+- **Normal TOML not via `SetBytes`** — serialize from slot tree; `SetBytes` is
+ import escape hatch only.
+
+## User Stories / Gate
+
+| ID | Story | Covered |
+|----|-------|---------|
+| C1a–f | Standalone def slot + file ops | **Yes** (minimum c/d for gate via diff) |
+| C2a–c | Inline nested slot ops | **b/c in M4** |
+
+## Deliverables
+
+- Slot op apply on overlay drafts
+- TOML serialize helper for overlay → bytes
+- `tests/changeset/` slot scenarios (C1/C2)
+
+## Dependencies
+
+- M1–M3
+
+## Execution Strategy
+
+Full plan. Largest milestone — slot mut access on overlay copies.
+
+Suggested chat opener:
+
+> M4 plan: slot ops on overlay + TOML serialize path. Full plan then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/summary.md
new file mode 100644
index 000000000..2deaec835
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m4-node-slot-patches/summary.md
@@ -0,0 +1,52 @@
+# M4 Summary — Slot Ops + TOML Serialize
+
+## Status
+
+Implemented (`9234bb94`) on branch `codex/incremental-artifact-reload`.
+
+Plan folder backfilled post-hoc; execution skipped numbered phase files.
+
+## Delivered
+
+### lpc-model
+
+- `slot_mutation.rs` — `set_slot_value`, map/option remove helpers
+- `slot_mut_access.rs` — `MapSlotMutAccess::remove_entry`, `SlotOptionMutAccess::clear_presence`
+
+### lpc-node-registry
+
+- `OverlayEntry::SlotDraft` + `change/slot_draft.rs`
+- `registry/slot_apply.rs` — apply slot ops, fork draft, `serialize_slot_draft`
+- `apply_change` / `apply_changeset` take `(fs, ctx, frame)`; route file vs slot ops
+- `effective_read.rs` — slot draft projection + inline child `def_state_at_source`
+- Inline child path routing — `entries[n].node.def.*` mutates invocation body
+
+## Tests
+
+`lp-core/lpc-node-registry/tests/slot_overlay.rs`:
+
+- C1 — SetSlot patches clock rate in view; committed unchanged
+- C1 — slot draft serializes to TOML (round-trip via `NodeDef::read_toml`)
+- C2 — playlist parent slot patch; committed children unchanged
+- C2 — inline child slot patch visible in view; committed child unchanged
+
+Updated for new API: `overlay_lifecycle.rs`, `effective_projection.rs`, `asset_overlay.rs`.
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry --test slot_overlay
+cargo test -p lpc-node-registry
+cargo test -p lpc-model slot_mutation
+```
+
+## Known limits (documented for M5/M6)
+
+- New `.toml` paths fork from `NodeDef::default()` (Project) until kind SetSlot
+- Custom slot paths (general `NodeInvocation` descent) use inline-specific router
+- `MapInsert` / `MapRemove` / `OptionSet` implemented but not integration-tested
+- Post-commit `NodeDefUpdates` for inline children — M5
+
+## Next
+
+M5 commit promotion — flush overlay to fs/store, re-derive entries, `SyncResult`.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion.md
new file mode 100644
index 000000000..8a85da992
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion.md
@@ -0,0 +1,66 @@
+# Milestone 5: Commit Promotion
+
+## Title And Goal
+
+Implement **commit**: promote overlay → `ArtifactStore` (+ optional fs write) →
+re-derive `entries` → **`SyncResult`** (same shape as parent M4 fs sync) → clear
+overlay. Prove **D2** and **D5**.
+
+## Parallel Build
+
+**`lpc-node-registry` only.** **`lpc-engine` unchanged.**
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/`
+
+## Scope
+
+In scope:
+
+- `NodeDefRegistry::commit` (or equivalent facade)
+- Flush overlay paths: asset bytes + serialized TOML from M4 slot trees
+- Re-derive affected defs; populate `SyncResult` / `NodeDefUpdates`
+- `RegistryChange::ChangeSet` or dedicated commit entry point (plan decides)
+- All-or-nothing commit; failure leaves base untouched
+- **D5** harness — uncommitted overlay wins on effective read; after commit, fs
+ `sync` on same path follows committed rules; overlay cleared
+
+Out of scope:
+
+- Engine cutover (parent **M6**)
+- Project diff (**M6**)
+- Wire `slot_mutation` alignment (parent **M6**)
+
+## Key Decisions
+
+- **Commit reuses parent M4 re-derive path** where possible.
+- **`discard`** = overlay clear only; **`commit`** = only path that mutates
+ committed `entries` from client edits.
+- **Failed commit** — base unchanged; overlay may retain pending state (plan
+ specifies).
+
+## User Stories / Gate
+
+| ID | Story | Milestone |
+|----|-------|-----------|
+| D2 | Commit → base updated; overlay clear | **M5** |
+| D5 | Overlay vs fs-change precedence | **M5** |
+
+## Deliverables
+
+- Commit API on registry
+- D2 + D5 harness tests
+- `commit-contract.md` design note in plan folder
+
+## Dependencies
+
+- M1–M4 (meaningful commit requires file + slot ops + serialize)
+
+## Execution Strategy
+
+Full plan. Commit touches registry ownership after op semantics land.
+
+Suggested chat opener:
+
+> M5 plan: commit promotion to base + SyncResult + D5 precedence. Full plan then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-design.md
new file mode 100644
index 000000000..6f9864a0b
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-design.md
@@ -0,0 +1,147 @@
+# M5 Design — Commit Promotion
+
+## Scope
+
+Promote `ChangeOverlay` to committed state: fs write → store revision bump →
+re-derive `entries` → `SyncResult` → clear overlay. Prove **D2**, **D5**, and
+C2 post-commit `NodeDefUpdates` shape.
+
+**`lpc-engine` untouched.**
+
+Depends on M1–M4 (overlay, effective reads, file ops, slot ops + serialize).
+
+## File structure
+
+```
+lp-core/lpc-node-registry/src/
+├── change/
+│ ├── commit_error.rs # NEW — CommitError
+│ └── overlay.rs # add path iteration helper if needed
+├── registry/
+│ ├── commit.rs # NEW — flush + commit impl (#[path] from node_def_registry)
+│ ├── node_def_registry.rs # pub fn commit(...)
+│ ├── slot_apply.rs # reuse serialize_slot_draft
+│ └── sync_result.rs # unchanged shape
+└── tests/
+ └── commit_promotion.rs # NEW — D2, D5, C2 post-commit
+```
+
+```
+docs/roadmaps/.../m5-commit-promotion/
+├── commit-contract.md # behavioral contract (this milestone)
+├── 00-notes.md
+├── 00-design.md
+└── 01–06 phase files
+```
+
+## Architecture
+
+```text
+Client NodeDefRegistry
+ │ │
+ ├─ apply_changeset ───────────────► ChangeOverlay (pending)
+ ├─ view().get() ──────────────────► effective_read (overlay ∪ base)
+ │
+ └─ commit(fs, frame, ctx) ────────► commit.rs
+ │
+ ┌────────────────────┴────────────────────┐
+ │ 1. Early exit if overlay empty │
+ │ 2. Resolve each path → bytes/action │
+ │ SlotDraft → serialize_slot_draft │
+ │ Bytes → raw │
+ │ Deleted → fs delete │
+ │ 3. Write LpFs (create/modify/delete) │
+ │ 4. acquire_file_artifact (new paths) │
+ │ 5. store.apply_fs_changes (bump rev) │
+ │ 6. snapshot_def_states (before) │
+ │ 7. sync_def_artifact (each .toml) │
+ │ 8. sync_source_path (asset deps) │
+ │ 9. reconcile_artifact_refs │
+ │10. build_change_details → SyncResult │
+ │11. overlay.clear() │
+ └─────────────────────────────────────────┘
+ │
+ get() / entries ◄──────────┘ committed cache
+```
+
+### API
+
+```rust
+impl NodeDefRegistry {
+ /// Promote all pending overlay entries to committed store + entries.
+ /// Returns factual SyncResult. Clears overlay on success.
+ pub fn commit(
+ &mut self,
+ fs: &mut dyn LpFs,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+ ) -> Result;
+}
+```
+
+**Unchanged:** `sync` / `sync_fs` remain fs-reload only. `discard_overlay` unchanged.
+
+### Overlay → bytes resolution
+
+| `OverlayEntry` | Fs action | Store |
+|----------------|-----------|-------|
+| `Bytes(v)` | write `v` | acquire/bump via fs change |
+| `SlotDraft(d)` | write `serialize_slot_draft(&d.def, ctx)?` | same |
+| `Deleted` | delete file | `ChangeType::Delete` bump |
+
+### Re-derive strategy
+
+Reuse existing helpers from `node_def_registry.rs`:
+
+- `sync_def_artifact` for each touched `.toml` artifact id
+- `sync_source_path` for touched asset paths referenced by loaded defs
+- `build_change_details` + `snapshot_def_states` for `SyncResult`
+
+Classify touched paths:
+
+- `.toml` → def artifact sync set
+- other → source path sync set (if path appears in `source_path_index` or affects materialized deps after def sync)
+
+For overlay paths not yet in `artifact_path_to_id`: `acquire_file_artifact` after fs write.
+
+### Failure semantics
+
+- Validate serialize + fs writes in an order that allows rollback where practical.
+- If re-derive fails after fs write: document behavior in `commit-contract.md`;
+ prefer **fail before mutating `entries`** when validation catches errors early.
+- On `CommitError`: **`entries` unchanged**, **overlay retained**.
+- Empty overlay: return `Ok(SyncResult::default())`.
+
+### D5 precedence
+
+While overlay active on path `P`:
+
+- `read_effective_bytes`, `view().get`, `materialize_source` → overlay (already M2–M4)
+- `sync_fs` on `P` → bumps store revision but **does not** replace overlay reads
+- After successful `commit` → overlay cleared; `sync_fs` on `P` follows fs/store rules
+
+## Tests
+
+| Test | Story |
+|------|-------|
+| `d2_commit_updates_committed_and_clears_overlay` | SetSlot on clock; commit; `get()` matches view; overlay empty |
+| `d2_commit_slot_draft_serializes_to_fs` | After commit, fs file contains serialized TOML |
+| `d5_overlay_wins_over_fs_until_commit` | overlay + fs diverge; view=overlay; sync_fs doesn't clobber view |
+| `d5_post_commit_fs_sync_applies` | after commit, fs change updates committed |
+| `c2_inline_child_in_sync_result_after_commit` | playlist inline patch; child in `def_updates.changed`, not root |
+
+Fixtures: `load_clock`, `load_playlist_with_inline_child`, `load_shader_project`.
+
+## Non-goals
+
+- `RegistryChange::ChangeSet` batch type
+- Compose-from-blank / `load_root` without pre-existing project (M6)
+- Engine cutover
+- Writing committed bytes without fs (embedded store-only path)
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test commit_promotion
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-notes.md
new file mode 100644
index 000000000..e63dc8d05
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/00-notes.md
@@ -0,0 +1,105 @@
+# M5 Notes — Commit Promotion
+
+## Scope
+
+Implement **commit**: promote overlay → durable bytes (fs write in harness) →
+`ArtifactStore` revision bump → re-derive `entries` → **`SyncResult`** → clear
+overlay. Prove **D2** and **D5**.
+
+**Out of scope:** engine cutover (parent M6), diff/equivalence (M6 here),
+`RegistryChange::ChangeSet` wire integration (optional; plan decides), compose-from-blank
+(A1 — M6 gate).
+
+## Plan directory gap (M3, M4)
+
+M1 and M2 have full plan folders (`m1-change-language-overlay/`,
+`m2-effective-projection/`) with `00-notes.md`, `00-design.md`, and numbered
+phases. **M3 and M4 were implemented without plan directories** — only milestone
+overview stubs exist:
+
+- `m3-asset-overlay.md` (suggested `m3-asset-overlay/` never created)
+- `m4-node-slot-patches.md` (suggested `m4-node-slot-patches/` never created)
+
+Implementation landed via direct execution (commits `81ff051b` M3, `9234bb94` M4).
+Tests live under `lp-core/lpc-node-registry/tests/` (`asset_overlay.rs`,
+`slot_overlay.rs`, etc.) rather than a unified `tests/changeset/` tree mentioned
+in overview docs.
+
+**Optional follow-up (not M5):** backfill lightweight `summary.md` in
+`m3-asset-overlay/` and `m4-node-slot-patches/` documenting what shipped and
+where tests live. Not blocking M5.
+
+## Current codebase (post-M4)
+
+```
+lp-core/lpc-node-registry/src/
+├── change/
+│ ├── overlay.rs # Bytes | SlotDraft | Deleted
+│ ├── apply.rs # SetBytes/Delete only (slot ops via registry)
+│ └── slot_draft.rs
+├── registry/
+│ ├── node_def_registry.rs # apply_change/changeset, discard; NO commit
+│ ├── effective_read.rs # overlay ∪ store reads for preview
+│ ├── slot_apply.rs # slot op apply + serialize_slot_draft
+│ ├── sync_result.rs # SyncResult shape (reuse on commit)
+│ └── registry_change.rs # Fs only
+└── artifact/artifact_store.rs # freshness cache; read_bytes from fs; no write API
+
+Tests (changeset-related):
+├── overlay_lifecycle.rs # D1, D3, implicit create
+├── effective_projection.rs # view vs committed
+├── asset_overlay.rs # C4*
+├── slot_overlay.rs # C1, C2
+└── fs_change_semantics.rs # S1–S6 (fs sync path — commit should mirror)
+```
+
+### Apply vs commit today
+
+| Step | Status |
+|------|--------|
+| `apply_change` / `apply_changeset` → overlay | Done |
+| `view().get()` effective preview | Done |
+| `discard_overlay()` | Done |
+| `commit()` → base + SyncResult | **Missing** |
+| `read_artifact_state` uses overlay | **No** — reads store/fs only |
+| New overlay paths in store | **No** — implicit create is overlay-only until commit |
+
+Commit must bridge: overlay bytes → fs (harness) → store revision →
+`sync_def_artifact` / `derive_inventory` (existing fs-sync path).
+
+## Resolved decisions (from roadmap)
+
+- Commit reuses parent M4 re-derive path (`sync_def_artifact`, `derive_inventory`).
+- `discard` = overlay clear only; **commit** = only path that mutates committed
+ `entries` from client edits.
+- All-or-nothing commit; failure leaves base untouched (overlay may retain pending).
+- D5: uncommitted overlay wins on effective read; fs bump marks stale but does
+ not clobber overlay until commit/discard; on commit, client ChangeSet wins.
+
+## Resolved questions (2026-05-21)
+
+| # | Decision |
+|---|----------|
+| Q1 | Dedicated `NodeDefRegistry::commit(...) -> Result`; do not overload `sync()` in M5 |
+| Q2 | Commit writes overlay content to `LpFs`, then bumps store via existing fs-change pattern |
+| Q3 | Flush all overlay paths → fs → acquire/bump store → re-derive → clear overlay on success |
+| Q4 | M5 tests assume `load_root` already ran; compose-from-blank deferred to M6 |
+| Q5 | Failed commit: base unchanged, overlay retained |
+| Q6 | Commit uses overlay entry variant as-is; no merge between `SlotDraft` and `Bytes` |
+| Q7 | D5 harness: overlay wins over fs until commit; post-commit fs sync follows committed rules |
+
+## User stories (this milestone)
+
+| ID | Story | How |
+|----|-------|-----|
+| D2 | Commit → base updated; overlay clear | `get()` matches post-commit; `overlay_active()` false |
+| D5 | Overlay vs fs-change precedence | Harness above |
+| C2 post-commit | Inline child in `NodeDefUpdates.changed` | Commit slot patch on playlist; child id in SyncResult |
+
+## Validation baseline
+
+```bash
+cargo test -p lpc-node-registry
+```
+
+Must remain green; add `tests/commit_promotion.rs` or extend `overlay_lifecycle.rs`.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/01-commit-errors-and-flush-helper.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/01-commit-errors-and-flush-helper.md
new file mode 100644
index 000000000..70c8d83a8
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/01-commit-errors-and-flush-helper.md
@@ -0,0 +1,39 @@
+# Phase 01 — Commit Errors + Overlay Flush Helpers
+
+**Dispatch:** sub-agent: main | parallel: -
+
+## Scope of phase
+
+Add `CommitError`, overlay iteration/resolution helpers, and unit-level flush
+logic without wiring public `commit()` yet.
+
+**In scope:**
+
+- `change/commit_error.rs` — `CommitError` variants (empty overlay ok, serialize,
+ fs write, re-derive)
+- Export from `change/mod.rs` and `lib.rs`
+- `registry/commit.rs` (included from `node_def_registry.rs` via `#[path]`) with:
+ - `resolve_overlay_bytes(path, entry, ctx) -> Result>, CommitError>`
+ - `overlay_paths(overlay) -> Vec` iterator
+ - `is_def_artifact_path(path) -> bool` (`.toml` suffix)
+- Tests for serialize resolution (SlotDraft → bytes) in commit module unit tests
+
+**Out of scope:** public `commit()`, integration tests, fs writes.
+
+## Implementation details
+
+- Reuse `serialize_slot_draft` from `slot_apply.rs`.
+- `Deleted` → `Ok(None)` from byte resolver; caller maps to fs delete.
+- `CommitError` should convert from `ChangeError` / `RegistryError` where helpful.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Do not expand scope into engine or lpc-model beyond existing serialize path.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry commit
+cargo check -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/02-commit-impl.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/02-commit-impl.md
new file mode 100644
index 000000000..8b6078e97
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/02-commit-impl.md
@@ -0,0 +1,46 @@
+# Phase 02 — `commit()` Implementation
+
+**Dispatch:** sub-agent: main | parallel: -
+
+## Scope of phase
+
+Implement `NodeDefRegistry::commit` using flush helpers from phase 01.
+
+**In scope:**
+
+- `NodeDefRegistry::commit(&mut self, fs: &mut dyn LpFs, frame, ctx) -> Result`
+- Fs writes: create/modify via `write_file_mut`, delete via fs delete API
+- `acquire_file_artifact` for paths not in `artifact_path_to_id`
+- `store.apply_fs_changes` with `ChangeType::Create` / `Modify` / `Delete`
+- Re-derive: `sync_def_artifact` for touched `.toml` artifacts;
+ `sync_source_path` for affected asset paths
+- `snapshot_def_states` + `build_change_details` → `SyncResult`
+- `overlay.clear()` on success only
+- Empty overlay → `Ok(SyncResult::default())`
+
+**Out of scope:** integration tests (phase 03+), `RegistryChange::ChangeSet`.
+
+## Implementation details
+
+Follow `commit-contract.md` ordering: resolve → fs write → store bump → re-derive.
+
+Key symbols in `node_def_registry.rs`:
+
+- `sync_def_artifact`, `sync_source_path`, `acquire_file_artifact`
+- `classify_changed_path` (reuse or mirror for touched path sets)
+
+Prefer validate-all-serialize before first fs write to reduce partial failure.
+
+## Sub-agent reminders
+
+- Do not commit.
+- `sync_fs` behavior unchanged; overlay reads unchanged (already effective_read).
+
+## Validate
+
+```bash
+cargo check -p lpc-node-registry
+cargo test -p lpc-node-registry
+```
+
+Integration tests may not exist yet; existing tests must stay green.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/03-d2-harness.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/03-d2-harness.md
new file mode 100644
index 000000000..47052c027
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/03-d2-harness.md
@@ -0,0 +1,32 @@
+# Phase 03 — D2 Harness (Commit Updates Base)
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Integration tests proving **D2**: commit updates committed cache and clears overlay.
+
+**In scope:**
+
+- `tests/commit_promotion.rs` (new) or extend `overlay_lifecycle.rs` — prefer dedicated file
+- Tests:
+ 1. **`d2_commit_updates_committed_and_clears_overlay`** — `load_clock`, SetSlot
+ `controls.rate = 2.0`, commit, assert `get()` rate 2.0, `!overlay_active()`
+ 2. **`d2_commit_setbytes_updates_committed`** — SetBytes on `/clock.toml`, commit,
+ committed matches overlay
+ 3. **`d2_commit_writes_slot_draft_to_fs`** — after SetSlot + commit, read fs file,
+ contains `rate = 2` (or equivalent)
+
+**Out of scope:** D5, inline child SyncResult (phases 04–05).
+
+## Sub-agent reminders
+
+- Do not commit.
+- Use `fixtures::load_clock`, existing `apply_change` helpers from sibling tests.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry --test commit_promotion
+cargo test -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/04-d5-harness.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/04-d5-harness.md
new file mode 100644
index 000000000..f69a24676
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/04-d5-harness.md
@@ -0,0 +1,31 @@
+# Phase 04 — D5 Harness (Overlay vs Fs Precedence)
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Integration tests proving **D5** precedence from `commit-contract.md`.
+
+**In scope:**
+
+- Add to `tests/commit_promotion.rs`:
+ 1. **`d5_overlay_wins_over_stale_fs`** — load clock, apply SetSlot (overlay rate
+ 2.0), write fs with rate 9.0 directly, assert `view()` still 2.0, `get()` still 1.0
+ 2. **`d5_sync_fs_does_not_clobber_overlay_view`** — with overlay active, call
+ `sync_fs` on same path; assert view still overlay value
+ 3. **`d5_post_commit_fs_sync_updates_committed`** — commit overlay, then fs write +
+ `sync_fs`; assert committed updates to fs value
+
+**Out of scope:** compose-from-blank, engine.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Document in test names what each step proves (overlay > fs pre-commit).
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry --test commit_promotion
+cargo test -p lpc-node-registry
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/05-c2-post-commit-sync-result.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/05-c2-post-commit-sync-result.md
new file mode 100644
index 000000000..97e631e7e
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/05-c2-post-commit-sync-result.md
@@ -0,0 +1,36 @@
+# Phase 05 — C2 Post-Commit SyncResult
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Verify inline child edits appear in `SyncResult.def_updates` after commit (M4
+deferral documented in `m4-node-slot-patches/summary.md`).
+
+**In scope:**
+
+- Add to `tests/commit_promotion.rs`:
+ - **`c2_inline_child_changed_after_commit`** — `load_playlist_with_inline_child`,
+ apply SetSlot on `entries[2].node.def.render_order = 7`, commit, assert:
+ - child id in `result.def_updates.changed`
+ - root **not** in changed (mirror `fs_change_semantics` S4 pattern)
+ - committed child render_order is 7
+
+Optional:
+
+- Asset commit bumps `source_revisions` (C4c post-commit) if straightforward via
+ existing `sync_source_path`.
+
+**Out of scope:** full C4 matrix, A1 compose.
+
+## Sub-agent reminders
+
+- Do not commit.
+- Reuse `inline_child_id` helper pattern from `slot_overlay.rs` / `fs_change_semantics.rs`.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry --test commit_promotion
+cargo test -p lpc-node-registry --test fs_change_semantics
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/06-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/06-cleanup-validation.md
new file mode 100644
index 000000000..72daa21b8
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/06-cleanup-validation.md
@@ -0,0 +1,39 @@
+# Phase 06 — Cleanup + Validation
+
+**Dispatch:** sub-agent: main | parallel: -
+
+## Scope of phase
+
+Final polish for M5: docs, exports, fmt, full validation, milestone summary.
+
+**In scope:**
+
+- `cargo +nightly fmt` on touched Rust files
+- Update `change/mod.rs` / registry module docs referencing commit
+- `summary.md` in this plan folder (status, deliverables, validation commands)
+- Fix clippy warnings in touched code
+- Confirm no `lpc-engine` changes
+
+**Out of scope:** M6 diff work, M3/M4 backfill beyond existing `summary.md`.
+
+## Milestone sign-off
+
+- [ ] D2 tests green
+- [ ] D5 tests green
+- [ ] C2 post-commit test green
+- [ ] All existing `lpc-node-registry` tests green
+- [ ] `commit-contract.md` matches implementation
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test commit_promotion
+cargo test -p lpc-node-registry --test overlay_lifecycle
+cargo test -p lpc-node-registry --test slot_overlay
+cargo test -p lpc-node-registry --test asset_overlay
+cargo test -p lpc-node-registry --test fs_change_semantics
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+```
+
+Optional before push: `just check`
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/commit-contract.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/commit-contract.md
new file mode 100644
index 000000000..f121b3806
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/commit-contract.md
@@ -0,0 +1,78 @@
+# Commit Contract (v1)
+
+Behavioral contract for `NodeDefRegistry::commit` in M5. Normative for harness
+tests; engine cutover is a separate milestone.
+
+## Preconditions
+
+- Registry may have called `load_root` (M5 tests require this).
+- Overlay may be empty (no-op commit).
+- `fs` must be writable (`LpFs::write_file` / `delete_file`).
+
+## Success path
+
+1. **Resolve** every overlay path to a commit action (bytes write or delete).
+2. **Write fs** for all paths before mutating `entries`.
+3. **Register** new file paths in `ArtifactStore` (`acquire_file_artifact`).
+4. **Bump** store revisions (`apply_fs_changes` or per-path equivalent).
+5. **Re-derive** affected defs via `sync_def_artifact` / `sync_source_path`.
+6. **Return** `SyncResult` with factual `def_updates`, `source_revisions`, `change_details`.
+7. **Clear** overlay.
+
+After success:
+
+- `overlay_active()` is false.
+- `registry.get(id)` reflects committed state for affected defs.
+- `view().get(id)` equals `registry.get(id)` when overlay is empty.
+- Fs files match committed bytes for overlay paths written.
+
+## Failure path
+
+On any error after overlay was non-empty:
+
+- **`entries` / `get()` unchanged** from pre-commit snapshot.
+- **Overlay retained** (same pending edits).
+- Return `Err(CommitError)`.
+
+Fs may be partially updated on failure; M5 harness should use fixtures where
+validation fails before destructive steps, or restore fs in test. Implementation
+should serialize/validate before writing when possible.
+
+## Overlay entry mapping
+
+| Entry | Fs | Notes |
+|-------|-----|-------|
+| `Bytes(b)` | write `b` | assets and TOML escape hatch |
+| `SlotDraft(d)` | write `serialize_slot_draft(&d.def, ctx)?` | normal `.toml` authoring |
+| `Deleted` | delete path | store marks deleted |
+
+No merge between entry kinds; last apply wins in overlay.
+
+## Precedence (D5)
+
+| Operation | Overlay active on path P | After commit |
+|-----------|-------------------------|--------------|
+| `view().get` / effective bytes | overlay wins | committed (= fs) |
+| `registry.get` | committed only (unchanged pre-commit) | committed updated |
+| `sync_fs` on P | bumps store; **does not** clobber overlay reads | normal fs-sync |
+| `commit` | promotes overlay → fs + entries | overlay cleared |
+
+## Scope limits (M5)
+
+- Committing new `.toml` files writes fs + store but **does not** add defs to the
+ graph unless reachable from existing root via `derive_inventory`.
+- Compose-from-blank (A1) is **M6**, not M5.
+- Source revision bumps after asset commit follow existing `sync_source_path` rules.
+
+## API surface
+
+```rust
+pub fn commit(
+ &mut self,
+ fs: &dyn LpFs,
+ frame: Revision,
+ ctx: &ParseCtx<'_>,
+) -> Result;
+```
+
+`sync()` / `sync_fs()` remain filesystem reload only.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/summary.md
new file mode 100644
index 000000000..88c8e7407
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m5-commit-promotion/summary.md
@@ -0,0 +1,60 @@
+# M5 Summary — Commit Promotion
+
+## Status
+
+Implemented on branch `codex/incremental-artifact-reload`.
+
+## Delivered
+
+### lpc-node-registry
+
+- `change/commit_error.rs` — `CommitError` (Fs / Serialize / Registry)
+- `change/overlay.rs` — `iter_entries()` for commit flush
+- `registry/commit.rs` — `commit_overlay`: flush overlay → fs → store bump → re-derive → clear overlay
+- `NodeDefRegistry::commit()` — public entry point returning `SyncResult`
+- `restore_entry_states()` — rollback `entries` on failed commit; overlay retained
+
+### Flow
+
+```
+apply_changeset → ChangeOverlay
+view().get() → effective (overlay ∪ base)
+commit(fs) → write fs → apply_fs_changes → sync_def_artifact/sync_source_path → SyncResult → clear overlay
+sync_fs() → fs-reload only (unchanged)
+```
+
+## Tests
+
+`lp-core/lpc-node-registry/tests/commit_promotion.rs`:
+
+- D2 — commit updates `get()`, clears overlay, fs has serialized TOML
+- D2 — SetBytes commit path
+- D5 — overlay wins over stale fs until commit
+- D5 — `sync_fs` does not clobber overlay view
+- D5 — post-commit `sync_fs` updates committed state
+- C2 — inline child in `SyncResult.def_updates.changed` after commit
+- empty overlay commit is no-op
+
+Unit: `OverlayCommitPlan` slot-draft serialization in `registry/commit.rs`.
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --test commit_promotion
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+```
+
+68 integration tests pass (60 pre-M5 + 8 commit).
+
+## Known limits (M6+)
+
+- Compose-from-blank (A1) not yet proven — requires M6 diff gate
+- New overlay `.toml` paths fork `NodeDef::default()` (Project) until kind SetSlot
+- `MapInsert` / `MapRemove` / `OptionSet` not integration-tested
+- `RegistryChange` still `Fs` only — no `ChangeSet` variant
+- Failed commit may leave fs partially written (documented in `commit-contract.md`)
+
+## Next
+
+M6 — compose project from changes alone (A1 blank→basic proof).
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md
new file mode 100644
index 000000000..2056968c7
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate.md
@@ -0,0 +1,75 @@
+# Milestone 6: Diff + Equivalence Gate
+
+## Title And Goal
+
+Add **`diff(base, target) → ChangeSet`** and prove **apply + commit ≡ target**.
+This milestone **gates parent artifact-routed M6** (engine cutover).
+
+Primary regression harness — replaces hand-curated op lists for compose/morph.
+
+## Parallel Build
+
+**`lpc-node-registry` only.** **`lpc-engine` unchanged** until parent **M6**
+starts after this gate is green.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/`
+
+## Scope
+
+In scope:
+
+- `ProjectSnapshot` — fs walk or in-memory `BTreeMap`
+- `diff(base, target) -> ChangeSet`:
+ - new/removed paths → `ArtifactChange` with file ops
+ - assets → `SetBytes` / `Delete`
+ - `.toml` → slot-tree diff → minimal slot ops (not `SetBytes` for normal path)
+- `assert_equivalent(reg, snapshot)` — path set + asset bytes + parsed def/slot
+ equality (TOML need not be byte-identical)
+- **Gate tests:**
+ - `diff(∅, examples/basic)` → apply on empty registry → commit → ≡ basic (**A1**)
+ - `diff(basic, basic2)` → apply → commit → ≡ basic2 (**B1**)
+- Empty base snapshot = **truly no files** (creatability)
+
+Out of scope:
+
+- Full A2–A4 / B4 N×N matrix ([`notes.md`](notes.md))
+- **C3** inline ↔ standalone ([`future.md`](future.md))
+- Engine runtime assertions (parent **M6**)
+- Post-M6 replay stress harness (parent [`future.md`](../2026-05-21-artifact-routed-file-reload/future.md))
+
+## Key Decisions
+
+- **Diff output = change language** — same `ArtifactChange` vocabulary as UI edits.
+- **Equivalence ≠ byte-identical TOML** — parsed def + slot snapshot equality.
+- **Hand-written A1/B1 op lists** — optional once diff gate is green.
+
+## User Stories / Gate
+
+| ID | Story | Covered |
+|----|-------|---------|
+| A1 | Blank → `basic` | **Via diff(∅, basic)** |
+| B1 | `basic` → `basic2` | **Via diff(basic, basic2)** |
+| D1–D3, D5 | Lifecycle | **M1, M5** (prerequisite) |
+| C1/C4 core | Slot + file ops | **M3–M4** (prerequisite) |
+
+**Sign-off:** this milestone green → unblocks [parent M6](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md).
+
+## Deliverables
+
+- `lpc-node-registry/src/diff/`
+- `tests/changeset/project_diff.rs` (or equivalent)
+- Gate sign-off note in plan `summary.md`
+
+## Dependencies
+
+- M1–M5 complete
+
+## Execution Strategy
+
+Full plan. Diff depends on full apply + commit path.
+
+Suggested chat opener:
+
+> M6 plan: ProjectSnapshot + diff + empty→basic equivalence gate. Full plan then implement. Agree?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md
new file mode 100644
index 000000000..2b30950c4
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m6-diff-equivalence-gate/summary.md
@@ -0,0 +1,47 @@
+# M6 Summary — Diff + Equivalence Gate
+
+## Status
+
+Implemented on branch `codex/incremental-artifact-reload`. **Gate satisfied** —
+parent M6 engine cutover may proceed.
+
+## Delivered
+
+### lpc-node-registry
+
+- `diff` Cargo feature (default on host/CI; omit for embedded: `default-features = false`)
+- `diff/` module — `ProjectSnapshot`, `diff()`, `assert_equivalent()`, `DiffError`
+- `diff/def_diff.rs` — slot-tree diff between parsed `NodeDef`s (kind preflight, map/option/enum/value ops)
+- `diff/project_diff.rs` — path union diff: `.toml` → slot ops, assets → `SetBytes`/`Delete`
+- `registry/slot_apply.rs` — `apply_ops_to_node_def`, project `nodes[*].def` SetSlot routing, record-map MapInsert fix, nested enum SetSlot
+
+### Gate tests
+
+`tests/project_diff.rs`:
+
+- **A1** — `diff(∅, basic)` → apply → commit → equivalent
+- **A1** — load_root roundtrip after blank compose
+- **B1** — `diff(basic, basic2)` → apply → commit → equivalent
+- identical snapshots → empty changeset
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry --test project_diff
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+# Embedded-shaped build (no diff harness):
+cargo check -p lpc-node-registry --no-default-features
+```
+
+## Known limits
+
+- Equivalence uses parsed `NodeDef` equality (not byte-identical TOML)
+- Slot diff verify compares serialized TOML (ignores revision metadata)
+- Project node defs: path-backed invocations only in custom diff helper
+- Inline child defs in project nodes fall back to nested slot diff
+- Generic custom-slot diff incomplete beyond `NodeInvocation`
+
+## Unblocks
+
+Parent artifact-routed **M6 engine cutover** (ChangeSet apply in `lpc-engine`).
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation.md
new file mode 100644
index 000000000..63ae04865
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation.md
@@ -0,0 +1,54 @@
+# Milestone 7: Cleanup + Validation
+
+## Title And Goal
+
+Cross-milestone validation, doc updates, and removal of temporary scaffolding.
+Confirm parent roadmap cross-links and CI gate for this roadmap.
+
+## Parallel Build
+
+Validation only — no new features. Parent **M6** engine cutover is a **separate**
+milestone after M6 diff gate here.
+
+## Suggested Plan Location
+
+`docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/`
+
+## Scope
+
+In scope:
+
+- `cargo test -p lpc-node-registry` — all unit, fs-change, changeset, diff tests green
+- `cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings`
+- Roadmap `summary.md` for this promotion
+- Update parent artifact-routed overview / M6 stub with gate status
+- Remove dead stubs (`change/mod.rs` placeholder comments, view passthrough notes)
+- Public API docs on `ChangeSet`, overlay, commit, `NodeDefView`
+
+Out of scope:
+
+- Parent M6 engine cutover
+- Wire ChangeSet protocol ([`future.md`](future.md))
+- Parent M10 ExplainSlot probes
+
+## Key Decisions
+
+- CI gate matches AGENTS.md `just check` for touched crates.
+
+## Deliverables
+
+- `docs/roadmaps/2026-05-21-changeset-change-management/summary.md`
+- Clean exports in `lpc-node-registry/lib.rs`
+- Validation commands recorded in summary
+
+## Dependencies
+
+- M6 diff + equivalence gate green
+
+## Execution Strategy
+
+Small plan. Checklist-driven verification and doc pass.
+
+Suggested chat opener:
+
+> M7 cleanup: validation sweep + summary + parent cross-link update. Agree?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/summary.md
new file mode 100644
index 000000000..2c81e43d5
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m7-cleanup-validation/summary.md
@@ -0,0 +1,25 @@
+# M7 Summary — Cleanup + Validation
+
+## Status
+
+Complete on branch `codex/incremental-artifact-reload`.
+
+## Delivered
+
+- Removed milestone/process comments from `lpc-node-registry` module and type docs
+- Expanded crate and public API docs (`ChangeSet`, `ChangeOverlay`, `NodeDefView`,
+ `NodeDefRegistry` lifecycle)
+- Integration test module docs describe behavior, not milestone IDs
+- Roadmap [`summary.md`](../summary.md) for the full ChangeSet promotion
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+cargo check -p lpc-node-registry --no-default-features
+```
+
+## Unblocks
+
+Parent artifact-routed **M6 engine cutover** (no remaining ChangeSet roadmap work).
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync.md
new file mode 100644
index 000000000..d6c20b88f
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync.md
@@ -0,0 +1,50 @@
+# Milestone 8: Edit Vocabulary, Session Log, and Unified Sync
+
+## Title And Goal
+
+Rename the edit vocabulary (`Edit*` / `SlotOverlay`), add a **versioned session
+log** for pending client edits, and unify registry ingress through **`sync(&[SyncOp])`**
+returning **`SyncOutcome`**. Align filesystem notifications with **`FsEvent`**.
+
+**Gates parent artifact-routed M6 engine cutover** — engine and server need a
+single sync boundary and client-visible pending state before cutover is meaningful.
+
+## Parallel Build
+
+`lpc-node-registry` + `lpfs` + server/cli callers only. **No `lpc-engine` edits**
+in this milestone.
+
+## Suggested Plan Location
+
+[`m8-edit-session-sync/`](m8-edit-session-sync/)
+
+## Scope
+
+In scope:
+
+- Layer 1 renames: `change/` → `edit/`, `EditOp`, `ArtifactEdit`, `EditBatch`, …
+- Layer 2: `SlotOverlay`, `SessionLog`, `SessionVersion`, `SessionDelta`
+- Layer 3: `SyncOp`, extended `sync()`, `SyncOutcome`
+- Layer 0: `FsChange` → `FsEvent` in `lpfs` and direct callers
+- Harness tests updated; session + sync integration tests
+- Roadmap docs + `decisions.md` vocabulary section
+
+Out of scope:
+
+- `lpc-engine` cutover (parent M6)
+- Full `lpc-wire` protocol messages
+- CRDT / multi-writer merge
+- Effective-state `SyncResult` on apply-only (defer; document in `future.md`)
+
+## Dependencies
+
+- ChangeSet M1–M7 complete (overlay, commit, diff gate)
+
+## Execution Strategy
+
+Phased: vocabulary rename → overlay rename → FsEvent → session log → SyncOp.
+Single commit at plan end unless a rename checkpoint is needed mid-plan.
+
+Suggested chat opener:
+
+> M8 plan: Edit* rename + SessionLog + SyncOp unified sync. Review phases?
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md
new file mode 100644
index 000000000..048a77bc4
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-design.md
@@ -0,0 +1,172 @@
+# M8 Design — Edit Session + Unified Sync
+
+## Scope
+
+Rename edit vocabulary, introduce versioned **SessionLog**, materialize **SlotOverlay**
+from the log, route all registry ingress through **`sync(&[SyncOp])`**, return
+**`SyncOutcome`**, and rename **`FsChange` → `FsEvent`**.
+
+**Out of scope:** `lpc-engine`, wire protocol, CRDT, effective `SyncResult` on
+apply-only.
+
+## File structure
+
+```
+lp-base/lpfs/src/
+├── fs_event.rs # UPDATE: FsEvent, FsEventKind (was FsChange, ChangeType)
+├── lp_fs.rs # UPDATE: get_events_since naming (alias ok)
+└── impls/ # UPDATE: lp_fs_mem, lp_fs_std, lp_fs_view, …
+
+lp-app/lpa-server/src/
+└── server.rs # UPDATE: FsEvent
+
+lp-cli/src/commands/dev/
+├── watcher.rs # UPDATE: FsEvent
+├── sync.rs # UPDATE
+└── fs_loop.rs # UPDATE
+
+lp-core/lpc-node-registry/src/
+├── lib.rs # UPDATE: re-exports
+├── edit/ # RENAME from change/
+│ ├── mod.rs
+│ ├── edit_op.rs # RENAME artifact_op.rs
+│ ├── artifact_edit.rs # RENAME artifact_change.rs
+│ ├── edit_batch.rs # RENAME change_set.rs
+│ ├── edit_target.rs # RENAME artifact_target.rs
+│ ├── edit_error.rs # RENAME change_error.rs
+│ ├── apply.rs # UPDATE: Edit* types
+│ ├── slot_overlay.rs # RENAME overlay.rs (SlotOverlay)
+│ ├── slot_overlay_entry.rs # SPLIT if needed
+│ ├── def_draft.rs # RENAME slot_draft.rs
+│ └── commit_error.rs # keep (or sync_error.rs later)
+├── registry/
+│ ├── sync_op.rs # NEW: SyncOp (was registry_change.rs)
+│ ├── sync_outcome.rs # NEW: SyncOutcome
+│ ├── session/
+│ │ mod.rs
+│ │ session_version.rs # NEW
+│ │ session_event.rs # NEW: Append, Remove, Commit marker, …
+│ │ session_log.rs # NEW: append + since(version)
+│ │ session_delta.rs # NEW
+│ │ session_entry_id.rs # NEW
+│ ├── node_def_registry.rs # UPDATE: sync applies SyncOp batch
+│ ├── commit.rs # UPDATE: invoked from SyncOp::Commit
+│ ├── slot_apply.rs # UPDATE: Edit* types
+│ └── effective_read.rs # UPDATE: SlotOverlay
+├── diff/ # UPDATE: returns EditBatch
+└── tests/ # UPDATE all integration tests
+
+docs/roadmaps/2026-05-21-changeset-change-management/
+├── edit-language.md # RENAME from change-language.md
+├── decisions.md # UPDATE: vocabulary + session decisions
+├── summary.md # UPDATE: M8 gate for parent M6
+└── m8-edit-session-sync/ # this plan
+```
+
+## Architecture
+
+```text
+LAYER 0 — Committed disk notifications
+ FsVersion → get_events_since → FsEvent
+
+LAYER 1 — Edit vocabulary (serde / diff / wire)
+ EditBatch { EditBatchId, edits: Vec }
+
+LAYER 2 — Session + materialized pending
+ SessionLog (append-only, SessionVersion)
+ │ fold
+ ▼
+ SlotOverlay (path → SlotOverlayEntry: Bytes | DefDraft | Deleted)
+
+LAYER 3 — Unified ingress
+ sync(fs, &[SyncOp], frame, ctx) → SyncOutcome
+
+ SyncOp:
+ Fs(FsEvent)
+ Append { base: SessionVersion, batch: EditBatch }
+ Remove { base, entry_ids }
+ Commit { base }
+ Discard { base, scope }
+
+LAYER 4 — Outcomes
+ SyncOutcome {
+ session: SessionDelta, // for clients since last SessionVersion
+ committed: SyncResult, // for engine (fs + commit legs)
+ session_version: SessionVersion,
+ }
+
+READS
+ registry.get() → committed entries
+ NodeDefView.get() → SlotOverlay ∪ committed (effective)
+```
+
+## Main components
+
+### SessionLog
+
+- Monotonic `SessionVersion` (starts 0; increments on each meta-op).
+- Append stores `(SessionEntryId, SessionEvent::Append(EditBatch))`.
+- `session_since(v) -> SessionDelta` for client pull.
+- `Append` / `Remove` / `Discard` require `base == current_version` (optimistic lock).
+- **`Commit`**: run existing commit promotion, **clear log**, bump version (fresh draft).
+
+### SlotOverlay
+
+- Derived from SessionLog (rebuild or incremental — implementation choice in phase 4).
+- Same semantics as today's `ChangeOverlay`; rename only in phase 2 unless log rebuild forces refactor.
+
+### sync()
+
+Process `SyncOp` batch in order:
+
+1. **Fs** — existing `sync` fs path → merge into `SyncResult.committed`
+2. **Append** — validate base, append log, update SlotOverlay
+3. **Remove** — tombstone log entries, rebuild overlay
+4. **Discard** — clear log entries (scoped), rebuild overlay
+5. **Commit** — flush overlay → fs → re-derive → `SyncResult.committed`, clear session
+
+Return combined `SyncOutcome`.
+
+### Thin wrappers (compat)
+
+```rust
+pub fn apply_edit_batch(...) -> Result {
+ sync(fs, &[SyncOp::Append { base: session_version(), batch }], ...)
+}
+
+pub fn commit(...) -> Result {
+ sync(fs, &[SyncOp::Commit { base: session_version() }], ...)
+}
+```
+
+### FsEvent rename
+
+- `FsChange` → `FsEvent`; `ChangeType` → `FsEventKind`.
+- `get_changes_since` may alias to `get_events_since` or rename with deprecated alias.
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpfs
+cargo test -p lpa-server --no-run
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+cargo check -p lpc-node-registry --no-default-features
+just check # before plan commit
+```
+
+## Test scenarios (new / updated)
+
+| Test | Behavior |
+|------|----------|
+| Session append | two Append ops; `session_since` returns both; SlotOverlay reflects last |
+| Stale base | Append with wrong `SessionVersion` → error |
+| Sync batch | `sync([Append, Commit])` → committed SyncResult + empty session |
+| Fs + commit | `sync([Fs(modify glsl), Commit])` in one batch |
+| Diff roundtrip | `diff` → EditBatch → sync Append + Commit → equivalent |
+
+## Non-goals
+
+- Engine interpreting `SyncOutcome` (parent M6)
+- Wire message types in `lpc-wire`
+- Per-`EditOp` log entries (v1 = per `EditBatch` append)
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-notes.md
new file mode 100644
index 000000000..c237827d1
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/00-notes.md
@@ -0,0 +1,91 @@
+# M8 Plan Notes — Edit Session + Unified Sync
+
+## Scope of work
+
+Replace the overloaded **Change\*** vocabulary with four clear layers, and
+implement the **session log** that was deferred from M5:
+
+1. **Edit vocabulary** — wire/serde authoring types (`EditOp`, `ArtifactEdit`, `EditBatch`)
+2. **Session + SlotOverlay** — versioned pending log; materialized overlay derived from log
+3. **Sync ingress** — `sync(&[SyncOp])` replaces split `apply_changeset` / `commit` / fs-only `RegistryChange`
+4. **Sync outcomes** — `SyncOutcome { session, committed, session_version }`
+5. **FsEvent** — rename `FsChange` → `FsEvent` in `lpfs` and callers
+
+This milestone completes the registry contract the engine and server need before
+parent **M6 engine cutover**.
+
+## Current state
+
+### Implemented (M1–M7)
+
+- `lpc-node-registry/src/change/` — `ArtifactOp`, `ArtifactChange`, `ChangeSet`, `ChangeOverlay`
+- `NodeDefRegistry::apply_changeset` → overlay; `commit` → fs + `SyncResult`; `sync` → `RegistryChange::Fs` only
+- No pending version cursor; no `since(version)` for client edits
+- `lpfs::FsChange` + `FsVersion` + `get_changes_since` used by `lpa-server`, `lp-cli`, registry tests
+- `diff` feature generates `ChangeSet` (host harness)
+
+### Design gap (from M4/M5)
+
+M4 design intended `RegistryChange` to grow ChangeSet variants on **`sync`**. M5
+implemented dedicated `apply_changeset` / `commit` instead (harness shortcut).
+Engine policy (`engine-policy-v1.md`) assumes `sync` → `SyncResult` for all
+change sources.
+
+### Rename blast radius (approximate)
+
+| Symbol | Files touching (repo-wide grep) |
+|--------|----------------------------------|
+| ChangeSet / ArtifactChange / ChangeOverlay | ~40+ under `lpc-node-registry`, docs |
+| FsChange | `lpfs`, `lpa-server`, `lp-cli`, `fw-esp32`, registry tests |
+
+## Agreed vocabulary (from design discussion)
+
+| Layer | Old | New |
+|-------|-----|-----|
+| 0 | `FsChange` | `FsEvent` |
+| 0 | `ChangeType` | `FsEventKind` (optional) |
+| 1 | `ArtifactOp` | `EditOp` |
+| 1 | `ArtifactChange` | `ArtifactEdit` |
+| 1 | `ChangeSet` | `EditBatch` |
+| 1 | `ChangeSetId` | `EditBatchId` |
+| 1 | `ArtifactTarget` | `EditTarget` |
+| 1 | `ChangeError` | `EditError` |
+| 1 | `change/` module | `edit/` |
+| 2 | `ChangeOverlay` | `SlotOverlay` |
+| 2 | `OverlayEntry` | `SlotOverlayEntry` |
+| 2 | `SlotDraft` | `DefDraft` |
+| 2 | (new) | `SessionVersion`, `SessionEvent`, `SessionLog`, `SessionDelta` |
+| 3 | `RegistryChange` | `SyncOp` |
+| 4 | (new) | `SyncOutcome` |
+| 4 | `SyncResult` | keep (committed effects) |
+
+## Open questions
+
+| # | Question | Context | Suggested answer |
+|---|----------|---------|------------------|
+| Q1 | Gate parent M6 on M8 (not just M6 diff)? | Parent cutover needs SyncOp contract | **Yes** — update parent M6 deps |
+| Q2 | `SlotDraft` → `DefDraft`? | Pairs with SlotOverlay | **Yes** |
+| Q3 | `FsEvent` rename in same milestone? | Touches lpfs, server, cli | **Yes**, dedicated phase after registry renames |
+| Q4 | Temporary type aliases (`type ChangeSet = EditBatch`)? | Eases doc/test migration | **Yes**, one release; grep CI for deprecated uses |
+| Q5 | Session log granularity | Append whole `EditBatch` vs per `ArtifactEdit` | **Append EditBatch** for v1; log entry gets stable `SessionEntryId` |
+| Q6 | After `SyncOp::Commit`, reset session log? | Client `since(version)` semantics | **Clear log, bump SessionVersion** (fresh draft baseline) |
+| Q7 | Keep `apply_edit_batch` / `commit` as wrappers? | Ergonomics for tests | **Yes**, thin delegates to `sync([...])` |
+| Q8 | `SyncOp::Append` requires matching `SessionVersion`? | Optimistic concurrency | **Yes** — return `EditError::StaleSession { expected, actual }` |
+| Q9 | Apply-only returns committed `SyncResult`? | Live preview before commit | **No in M8** — `SyncOutcome.committed` empty on Append-only; engine uses `NodeDefView` for preview until parent M6 policy |
+| Q10 | Rename roadmap title "ChangeSet" → "Edit session"? | Docs only | **Defer** — update `change-language.md` → `edit-language.md`, keep roadmap folder name |
+
+## Notes
+
+- User prefers **Edit\*** over Author\* for layer 1.
+- **SlotOverlay** holds Bytes + DefDraft + Deleted (assets included); name accepted with that caveat.
+- M7 doc pass (uncommitted) cleaned milestone comments; M8 will update vocabulary in docs.
+- Single commit at plan end per `/plan` process unless rename checkpoint needed.
+
+## Answers
+
+*(Fill as user confirms Q1–Q10.)*
+
+### Nomenclature phase (started)
+
+- **Done:** Layer 1 + Layer 2 renames in `lpc-node-registry` (see [`vocabulary.md`](vocabulary.md)).
+- **Pending:** Session log, `SyncOp`, `FsEvent`.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/future.md
new file mode 100644
index 000000000..54b75a2ed
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/future.md
@@ -0,0 +1,25 @@
+# M8 Future Work
+
+## Effective SyncResult on apply-only
+
+- **Idea:** `SyncOp::Append` also diffs effective parse state and returns partial `SyncResult` for live engine preview before commit.
+- **Why not now:** Engine cutover can use `NodeDefView` for preview; doubles diff logic.
+- **Useful context:** `engine-policy-v1.md`; parent M6 policy decision.
+
+## Per-ArtifactEdit log entries
+
+- **Idea:** Finer session log than whole `EditBatch` append — enables single-op undo.
+- **Why not now:** v1 batches match client wire shape; coarser log is enough for `since(version)`.
+- **Revisit when:** Undo/redo UI needs op-level granularity.
+
+## CRDT / multi-writer session merge
+
+- **Idea:** Concurrent Append from multiple clients with merge.
+- **Why not now:** v1 single-writer server + optimistic `SessionVersion` base check.
+- **Useful context:** `future.md` on ChangeSet roadmap.
+
+## Session history across commits
+
+- **Idea:** Keep committed session log for audit/undo stack instead of clear-on-commit.
+- **Why not now:** Simpler client semantics — commit resets draft baseline.
+- **Revisit when:** Persistent undo or collaborative editing.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/summary.md
new file mode 100644
index 000000000..9a0abceb7
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/summary.md
@@ -0,0 +1,33 @@
+# M8 Summary — Unified Sync + Pending Edit Map
+
+**Status:** complete
+
+## Delivered
+
+- **Pending map** — `SlotOverlay` holds current uncommitted edits keyed by path (no history)
+- **Unified sync** — `SyncOp` batch: `Fs`, `Apply`, `Remove`, `ClearPending`, `Commit`
+- **CRUD** — `apply_artifact_edit`, `remove_pending_edit`, `discard_slot_overlay`, `apply_edit_batch`
+- **Outcomes** — `SyncOutcome { committed, pending_changed }`
+- **FsEvent rename** — `FsEvent` / `FsEventKind` in `lpfs` (deprecated aliases kept)
+- **Legacy** — `RegistryChange` = `SyncOp` (deprecated)
+
+## Explicitly not on device
+
+- Session log, version cursor, incremental pull, undo history — client-side if ever needed
+
+## Tests
+
+- `tests/pending_sync.rs` — apply, remove, apply+commit, fs+commit batch
+- Existing overlay/commit/fs tests unchanged in behavior
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+cargo check -p lpc-node-registry --no-default-features
+```
+
+## Gate
+
+Parent **M6 engine cutover** may wire `sync(&[SyncOp])` when ready.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/vocabulary.md b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/vocabulary.md
new file mode 100644
index 000000000..feb2586f6
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m8-edit-session-sync/vocabulary.md
@@ -0,0 +1,57 @@
+# Edit Session Vocabulary
+
+Canonical names for M8 and beyond. Layer 0 (`FsEvent`) and layers 3–4 (`SyncOp`,
+`SyncOutcome`) land in later M8 phases.
+
+## Layer 1 — Edit vocabulary (`lpc-node-registry/src/edit/`)
+
+| Old | New |
+|-----|-----|
+| `change/` module | `edit/` |
+| `ArtifactOp` | `EditOp` |
+| `ArtifactChange` | `ArtifactEdit` |
+| `ChangeSet` | `EditBatch` |
+| `ChangeSetId` | `EditBatchId` |
+| `ArtifactTarget` | `EditTarget` |
+| `ChangeError` | `EditError` |
+
+`EditBatch` field: `edits: Vec` (serde alias `changes` for wire compat).
+
+## M10 — Slot / asset split
+
+| Old | New |
+|-----|-----|
+| `EditOp` | removed — use `SlotEdit` or `AssetEdit` |
+| flat `ArtifactEdit { target, ops }` | tagged `ArtifactEdit::Slot` / `::Asset` |
+| `SetBytes` | `AssetEdit::ReplaceBody` |
+| `Delete` (in mixed enum) | `AssetEdit::Delete` |
+
+## Layer 2 — Slot overlay (registry pending state)
+
+| Old | New |
+|-----|-----|
+| `ChangeOverlay` | `SlotOverlay` |
+| `OverlayEntry` | `SlotOverlayEntry` |
+| `SlotDraft` | `DefDraft` |
+| `NodeDefRegistry.overlay` | `NodeDefRegistry.slot_overlay` |
+| `apply_changeset` | `apply_edit_batch` |
+| `apply_change` | `apply_artifact_edit` |
+| `discard_overlay` | `discard_slot_overlay` |
+| `overlay_active` | `slot_overlay_active` |
+| `overlay_contains_path` | `slot_overlay_contains_path` |
+| `overlay_bytes` | `slot_overlay_bytes` |
+
+## Legacy aliases
+
+Deprecated type aliases live in `edit/mod.rs` and `lib.rs` (`change` module re-export).
+
+## Layer 3–4 — Sync ingress
+
+| Symbol | Role |
+|--------|------|
+| `SyncOp` | `Fs`, `Apply`, `Remove`, `ClearPending`, `Commit` |
+| `SyncOutcome` | `{ committed, pending_changed }` |
+| `SlotOverlay` | Current pending map (path → draft/bytes/deleted) |
+| `FsEvent` | Committed filesystem notification |
+
+History/undo lives on the **client**, not in the registry.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops.md
new file mode 100644
index 000000000..c3f67d28e
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops.md
@@ -0,0 +1,43 @@
+# M9 — NodeInvocation Ref|Def + Generic Slot Edit Ops
+
+Collapse `NodeInvocation` to a slotted **`Ref | Def`** enum, add **`VariantSet`**
+edit op, and remove registry shortcuts that paper over the old custom codec.
+
+**Prerequisite:** M8 unified sync (done).
+
+**Plan:** [`m9-invocation-ref-def-generic-slot-ops/`](m9-invocation-ref-def-generic-slot-ops/)
+
+## Phases
+
+| # | Title | Focus |
+|---|--------|--------|
+| 01 | Invocation Ref\|Def model | `lpc-model` enum + TOML codec |
+| 02 | Model tests + remove NodeDefRef | Tests, exports, callers in `lpc-model` |
+| 03 | Engine + registry consumers | `def_walker`, `def_shell`, `effective_read`, registration |
+| 04 | VariantSet + thin slot_apply | `lpc-node-registry` edit apply |
+| 05 | Generic diff + test TOML | `def_diff`, harness/fixtures, integration tests |
+| 06 | Cross-crate validation | `lpc-model`, `lpc-engine`, `lpc-node-registry`, `fw-tests` |
+| 07 | Examples + docs | `examples/`, `change-language.md`, roadmap summaries |
+| 08 | Cleanup + CI gate | `just check`, plan `summary.md` |
+
+Rust and in-repo tests through phase 06; examples and docs in phase 07.
+
+## TOML wire (breaking, no dual-read)
+
+```toml
+[nodes.shader]
+ref = "./shader.toml"
+
+[nodes.clock.def]
+kind = "Clock"
+```
+
+Playlist:
+
+```toml
+[entries.2.node]
+ref = "./active.toml"
+
+[entries.2.node.def]
+kind = "Shader"
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-design.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-design.md
new file mode 100644
index 000000000..0c3efaeed
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-design.md
@@ -0,0 +1,123 @@
+# M9 Design — Invocation Ref|Def + Generic Slot Edit Ops
+
+## Scope
+
+Make child-node references honest slot data and make the edit language fully generic.
+
+**In:** `lpc-model` invocation reshape, `VariantSet`, registry apply/diff cleanup,
+in-repo tests/fixtures, `examples/`, `change-language.md`.
+
+**Out:** `lpc-wire`, `lpa-server` client protocol, parent engine M6 cutover.
+
+## TOML wire
+
+```toml
+# Project — external def
+[nodes.shader]
+ref = "./shader.toml"
+
+# Project — inline def
+[nodes.clock.def]
+kind = "Clock"
+
+# Playlist — external
+[entries.2.node]
+ref = "./active.toml"
+
+# Playlist — inline
+[entries.2.node.def]
+kind = "Shader"
+source = { path = "active.glsl" }
+```
+
+Reject: `def = { path = ... }`, `artifact = ...`.
+
+## Rust model
+
+```rust
+pub enum NodeInvocation {
+ Ref(ArtifactSpecifier),
+ Def(NodeDef),
+}
+```
+
+- **`Slotted` enum** — generic `set_slot_variant_default` / `set_slot_value` apply
+- **Remove:** `NodeDefRef`, `def_slot`, `NODE_INVOCATION_CODEC_ID` whole-record custom path
+- **Helpers:** `ref_specifier()`, `inline_def()` → match on `Ref` / `Def`
+
+## Edit ops (layer 1)
+
+```rust
+VariantSet { path: SlotPath, variant: String } // enum switch incl. root kind, Ref/Def
+SetSlot { path: SlotPath, value: LpValue } // value leaves only
+// MapInsert, MapRemove, OptionSet unchanged
+```
+
+Apply = thin dispatch to `lpc-model` slot mutation only.
+
+## Slot path examples
+
+| Intent | Path / ops |
+|--------|------------|
+| Root kind | `VariantSet(root(), "Clock")` |
+| Wire child to file | `VariantSet(nodes.shader, "Ref")` + `SetSlot(nodes.shader.ref, "./x.toml")` |
+| Inline child kind | `VariantSet(nodes.clock, "Def")` + `VariantSet(nodes.clock.def, "Clock")` |
+| Inline field | `SetSlot(nodes.clock.def.Clock.controls.rate, 2.0)` |
+| Playlist inline patch | `SetSlot(entries[2].node.def.Shader...., ...)` |
+
+Exact paths follow slotted enum field names (`ref`, `def`, variant names).
+
+## File structure
+
+```
+lp-core/lpc-model/src/
+├── node/
+│ ├── node_invocation.rs # REWRITE: Ref|Def slotted enum + TOML
+│ └── mod.rs # drop NodeDefRef export
+├── nodes/project/project_def.rs # tests: ref= wire
+├── nodes/playlist/playlist_entry.rs
+├── slot_codec/custom_slot_codec.rs # remove invocation custom branch (if unused)
+└── lib.rs
+
+lp-core/lpc-node-registry/src/
+├── edit/edit_op.rs # + VariantSet
+├── registry/slot_apply.rs # thin apply; delete shortcuts
+├── registry/def_shell.rs # match Ref|Def
+├── registry/def_walker.rs # match Ref|Def
+├── registry/effective_read.rs # match Ref|Def
+├── registry/node_def_registry.rs # register_invocations match
+├── diff/def_diff.rs # VariantSet; drop CustomDef
+└── tests/ + harness/fixtures.rs # new TOML
+
+lp-core/lpc-engine/src/
+├── engine/project_loader.rs # if NodeDefRef references remain
+└── ... # grep NodeDefRef / def_locator / inline_def
+
+examples/**/project.toml, playlist.toml, … # phase 07
+
+docs/roadmaps/.../change-language.md # phase 07
+```
+
+## Architecture
+
+```text
+NodeDef (enum slot) — artifact root
+ └─ Project.nodes: Map
+ └─ NodeInvocation (enum slot)
+ ├─ Ref → locator string leaf
+ └─ Def → NodeDef enum (inline body)
+
+EditBatch → apply → set_slot_variant_default | set_slot_value
+ (no registry domain knowledge)
+```
+
+## Validation (full gate)
+
+```bash
+cargo test -p lpc-model
+cargo test -p lpc-node-registry
+cargo test -p lpc-engine
+cargo test -p fw-tests --test scene_render_emu --no-run
+cargo clippy -p lpc-model -p lpc-node-registry -p lpc-engine --all-targets --no-deps -- -D warnings
+just check # phase 08
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-notes.md
new file mode 100644
index 000000000..fbd858396
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/00-notes.md
@@ -0,0 +1,36 @@
+# M9 Plan Notes — Invocation Ref|Def + Generic Slot Edit Ops
+
+## Scope
+
+1. **`NodeInvocation`** becomes `Ref(ArtifactSpecifier) | Def(NodeDef)` slotted enum
+2. **TOML** — `ref = "..."` vs `[....def] kind = ...` (breaking; no dual-read)
+3. **Delete** `NodeDefRef`, `def_slot`, whole-invocation custom codec hack
+4. **`VariantSet`** edit op; **`SetSlot`** = value leaves only
+5. **Remove** registry shortcuts: `project_node_def_mutation`, `inline_body_mutation`,
+ `apply_node_invocation_def`, diff `CustomDef` / `invocation_def_value`
+6. **Examples + docs** after Rust/tests green
+
+**Out of scope:** wire protocol, client undo/history, engine cutover to `SyncOp`.
+
+## Current state
+
+- `NodeInvocation { def: NodeDefRef, def_slot: ArtifactPathSlot }` — duplicate path state
+- Slot shape lies: `def` field typed as `ArtifactPathSlot` even for inline defs
+- `slot_apply.rs` string heuristics + invocation-specific routers
+- `def_diff.rs` emits `SetSlot + String` for enum/kind; `CustomDef` for wiring
+- Tests/examples use `def = { path = "..." }` and `[node.def]` inline form
+
+## Agreed decisions
+
+| # | Decision |
+|---|----------|
+| D1 | No backwards compat — update tests and examples |
+| D2 | Variants **`Ref`** and **`Def`** (not Path/Inline/Artifact) |
+| D3 | TOML: top-level `ref` key; inline body under `def` subtable |
+| D4 | Edit op **`VariantSet { path, variant }`** (not SetKind) |
+| D5 | **`SetSlot`** = scalar/string leaf values only |
+| D6 | Phases 01–06 Rust/tests; phase 07 examples/docs |
+
+## Open questions
+
+*(None blocking — user confirmed naming and no compat.)*
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/01-invocation-ref-def-model.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/01-invocation-ref-def-model.md
new file mode 100644
index 000000000..ec75fb63d
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/01-invocation-ref-def-model.md
@@ -0,0 +1,93 @@
+# Phase 01 — NodeInvocation Ref|Def Model
+
+**Dispatch:** sub-agent: yes | parallel: -
+
+## Scope of phase
+
+Rewrite `NodeInvocation` as a **`Slotted` enum** with `Ref` and `Def` variants and
+new TOML read/write.
+
+**In scope:**
+
+- `lpc-model/src/node/node_invocation.rs` — enum, helpers, TOML codec
+- Remove `NodeDefRef` type from this file
+- Remove `def_slot: ArtifactPathSlot` and record/custom shape hack
+- `node/mod.rs` — stop exporting `NodeDefRef`
+- `lib.rs` — drop `NodeDefRef` re-export
+
+**Out of scope:** registry, engine, examples, `VariantSet`, diff.
+
+## TOML contract
+
+```toml
+ref = "./shader.toml" # Ref variant at current table
+
+[....def] # Def variant
+kind = "Clock"
+```
+
+Read: if table has `ref` key → `Ref`; if has `def` subtable or inline def fields → `Def`.
+Error if both.
+
+## Implementation details
+
+**Enum shape (target):**
+
+```rust
+#[derive(Clone, Debug, PartialEq, Slotted)]
+pub enum NodeInvocation {
+ Ref(ArtifactSpecifier), // or Ref { locator } if slotted derive needs named fields
+ Def(NodeDef),
+}
+```
+
+Use `#[slotted(...)]` / derive patterns consistent with `NodeDef` enum in
+`nodes/node_def.rs`. Variant wire names: **`Ref`**, **`Def`**.
+
+**TOML mapping:**
+
+| Variant | Authored form |
+|---------|----------------|
+| `Ref` | `ref = ""` (string at invocation table) |
+| `Def` | `[parent.def]` table with `kind = ...` |
+
+Implement via slotted enum codec if possible; otherwise minimal custom
+`FieldSlot` read/write on the enum only (not whole fake record).
+
+**Helpers (replace old API):**
+
+```rust
+impl NodeInvocation {
+ pub fn path(locator: ArtifactSpecifier) -> Self;
+ pub fn inline(def: NodeDef) -> Self;
+ pub fn ref_specifier(&self) -> Option<&ArtifactSpecifier>;
+ pub fn inline_def(&self) -> Option<&NodeDef>;
+}
+```
+
+Keep `path()` / `inline()` constructors for call-site ergonomics.
+
+**Delete from `custom_slot_codec.rs`:** `NODE_INVOCATION_CODEC_ID` branches if enum
+no longer uses custom codec.
+
+**Default:** `Ref(ArtifactSpecifier::path(""))` or `Def(NodeDef::default())` — match
+prior default behavior.
+
+## Tests (this phase)
+
+Add/update in `node_invocation.rs` `#[cfg(test)]`:
+
+- `ref = "./texture.toml"` loads `Ref`
+- `[def] kind = "Clock"` loads `Def`
+- reject `ref` + `[def]` together
+- reject legacy `def = { path = ... }`
+- round-trip write/read for both variants
+
+## Validate
+
+```bash
+cargo test -p lpc-model node_invocation
+cargo check -p lpc-model
+```
+
+Expect compile failures in other crates until phase 03 — OK.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/02-model-tests-remove-nodedefref.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/02-model-tests-remove-nodedefref.md
new file mode 100644
index 000000000..e3e82a7ef
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/02-model-tests-remove-nodedefref.md
@@ -0,0 +1,49 @@
+# Phase 02 — Model Tests + NodeDefRef Removal
+
+**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 01
+
+## Scope of phase
+
+Fix all **`lpc-model`** callers and tests for `Ref | Def` API. Remove remaining
+`NodeDefRef` references inside `lpc-model`.
+
+**In scope:**
+
+- `nodes/project/project_def.rs` tests — `ref =` wire
+- `nodes/playlist/playlist_entry.rs` tests — `node.ref` / `[node.def]`
+- `nodes/node_def.rs` if it references `NodeDefRef`
+- Grep `lpc-model` for `NodeDefRef`, `.def`, `def_locator`, `inline_def` on old struct
+
+**Out of scope:** `lpc-node-registry`, `lpc-engine`, examples.
+
+## TOML test migrations
+
+| Old | New |
+|-----|-----|
+| `def = { path = "./x.toml" }` | `ref = "./x.toml"` |
+| `def = { kind = "Clock" }` | `[nodes.clock.def]` or nested def table |
+| `[entries.2.node.def] kind = ...` | unchanged path for inline |
+
+## Implementation details
+
+Update match sites:
+
+```rust
+// before
+match &invocation.def { NodeDefRef::Path(l) => ..., NodeDefRef::Inline(d) => ... }
+// after
+match &invocation { NodeInvocation::Ref(l) => ..., NodeInvocation::Def(d) => ... }
+```
+
+Update `def_shell.rs` in model if any — likely none in lpc-model.
+
+Ensure `collect_invocations` consumers in model tests use new helpers.
+
+## Validate
+
+```bash
+cargo test -p lpc-model
+cargo clippy -p lpc-model --all-targets --no-deps -- -D warnings
+```
+
+Other workspace crates may still fail — OK until phase 03.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/03-engine-registry-consumers.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/03-engine-registry-consumers.md
new file mode 100644
index 000000000..8f1803a3f
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/03-engine-registry-consumers.md
@@ -0,0 +1,57 @@
+# Phase 03 — Engine + Registry Consumers
+
+**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 02
+
+## Scope of phase
+
+Migrate **`lpc-node-registry`** and **`lpc-engine`** off `NodeDefRef` / old
+invocation struct field access. Update TOML in unit tests embedded in these crates.
+
+**In scope:**
+
+- `lpc-node-registry/src/registry/def_walker.rs`
+- `lpc-node-registry/src/registry/def_shell.rs`
+- `lpc-node-registry/src/registry/effective_read.rs`
+- `lpc-node-registry/src/registry/node_def_registry.rs` (`register_invocations`, etc.)
+- `lpc-engine/src/engine/project_loader.rs`
+- Grep both crates: `NodeDefRef`, `NodeInvocation::path`, `.def`, `def_locator`, `inline_def`
+
+**Out of scope:** `VariantSet`, `slot_apply` shortcut removal, examples/, diff generic ops.
+
+## Implementation details
+
+**Registration / walking:**
+
+```rust
+match &invocation {
+ NodeInvocation::Ref(locator) => { resolve path, acquire artifact ... }
+ NodeInvocation::Def(body) => { register inline at DefSource ... }
+}
+```
+
+**def_shell:** kind stubs for inline children — match `NodeInvocation::Def`.
+
+**effective_read:** inline `def_state_at_source` — paths may shift from
+`entries[n].node.def.*` to `entries[n].node.def.*` (still valid if Def variant
+uses `def` sub-record) — verify against new slot paths.
+
+**Inline slot paths:** After slotted enum lands, confirm generic path for inline
+edits is e.g. `entries[2].node.def.Clock.controls.rate` (not custom router).
+
+Update test TOML strings in:
+
+- `def_walker.rs` tests
+- `def_shell.rs` tests
+- `fs_change_semantics.rs`, `node_def_registry.rs` tests (project/playlist snippets)
+- `lpc-engine` tests referencing `NodeDefRef`
+
+## Validate
+
+```bash
+cargo test -p lpc-model
+cargo test -p lpc-node-registry
+cargo test -p lpc-engine
+cargo check -p fw-esp32 --target riscv32imac-unknown-none-elf --features esp32c6,server
+```
+
+Registry apply/diff may still use old SetSlot heuristics — OK until phases 04–05.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/04-variant-set-thin-slot-apply.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/04-variant-set-thin-slot-apply.md
new file mode 100644
index 000000000..d553be0a5
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/04-variant-set-thin-slot-apply.md
@@ -0,0 +1,67 @@
+# Phase 04 — VariantSet + Thin slot_apply
+
+**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 03
+
+## Scope of phase
+
+Add **`VariantSet`** to edit vocabulary and reduce `slot_apply.rs` to generic slot
+mutation only.
+
+**In scope:**
+
+- `edit/edit_op.rs` — add `VariantSet { path, variant }`
+- `registry/slot_apply.rs` — dispatch table; delete shortcuts
+- `edit/mod.rs` serde roundtrip test
+
+**Delete from `slot_apply.rs`:**
+
+- `apply_set_slot_on_def` string→variant heuristic
+- `project_node_def_mutation`
+- `apply_node_invocation_def`
+- `inline_body_mutation` / `matching_inline_inner_path` / `invocation_at_mut` (if generic paths work)
+
+**Keep:**
+
+- `apply_op_to_def` match arms for Map/Option
+- `mutate_def` wrapper
+- `ensure_toml_path`, `parse_def_bytes`, `serialize_slot_draft`
+
+## EditOp
+
+```rust
+VariantSet { path: SlotPath, variant: String },
+SetSlot { path: SlotPath, value: LpValue }, // doc: value leaves only
+```
+
+`op_name()` → `"variant_set"`.
+
+## Apply
+
+```rust
+EditOp::VariantSet { path, variant } => mutate_def(def, |root| {
+ set_slot_variant_default(root, ctx.shapes, path, frame, variant)
+}),
+EditOp::SetSlot { path, value } => mutate_def(def, |root| {
+ set_slot_value(root, ctx.shapes, path, frame, value.clone())
+}),
+```
+
+If generic mutation fails on paths that worked via shortcuts, **fix slot shapes in
+phase 01/03** — do not re-add registry routers.
+
+## Tests
+
+Update hand-written ops in tests still using `SetSlot + String` for kind/wiring:
+
+- `slot_overlay.rs`, `commit_promotion.rs`, `pending_sync.rs`, etc.
+
+Use `VariantSet` for kind changes; `VariantSet(…, "Ref")` + `SetSlot(…ref, …)` for wiring.
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry --test slot_overlay --test overlay_lifecycle
+cargo test -p lpc-node-registry
+```
+
+Diff may still emit old ops — phase 05.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/05-generic-diff-test-toml.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/05-generic-diff-test-toml.md
new file mode 100644
index 000000000..88cdd8272
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/05-generic-diff-test-toml.md
@@ -0,0 +1,53 @@
+# Phase 05 — Generic Diff + Test TOML
+
+**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 04
+
+## Scope of phase
+
+Make **`def_diff.rs`** emit generic ops and migrate **all `lpc-node-registry` test
+/fixture TOML** to `ref`/`def` wire.
+
+**In scope:**
+
+- `diff/def_diff.rs` — remove `CustomDef`, `invocation_def_value`, kind-at-root special case
+- Enum branch → `push_variant_set` not `push_set_slot + String`
+- `tests/*.rs`, `harness/fixtures.rs`, `harness/snapshot*.rs` if any
+- `tests/project_diff.rs` snapshot TOML in memory
+
+**Out of scope:** `examples/` tree (phase 07).
+
+## Diff changes
+
+| Before | After |
+|--------|-------|
+| `SlotKind::Enum` → `SetSlot(String)` | `VariantSet { path, variant }` |
+| Root kind change special case | `VariantSet(root(), variant)` |
+| `SlotKind::CustomDef` + path string | `VariantSet(..., "Ref")` + `SetSlot(...ref, path)` or recurse into Def |
+
+Add `push_variant_set` mirroring `push_set_slot` (apply-then-push for diff simulation).
+
+Remove `classify_slot` → `CustomDef` arm if `NodeInvocation` is normal enum in tree.
+
+## Fixture TOML grep
+
+Replace across `lpc-node-registry`:
+
+```
+def = { path = → ref =
+node = { def = { path = → ref = (flatten)
+```
+
+Playlist inline `[entries.N.node.def]` stays for Def variant body.
+
+## Tests
+
+- `project_diff` equivalence still passes
+- Add/adjust test: diff project wiring change emits `VariantSet` + `SetSlot`, not CustomDef
+- `def_walker` / `def_shell` tests green with new wire
+
+## Validate
+
+```bash
+cargo test -p lpc-node-registry
+cargo test -p lpc-node-registry --features diff
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/06-cross-crate-validation.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/06-cross-crate-validation.md
new file mode 100644
index 000000000..0ce493cbf
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/06-cross-crate-validation.md
@@ -0,0 +1,41 @@
+# Phase 06 — Cross-Crate Validation
+
+**Dispatch:** sub-agent: supervised | parallel: - | **Depends on:** 05
+
+## Scope of phase
+
+Fix remaining workspace references and run broader test gate **before** touching
+`examples/`.
+
+**In scope:**
+
+- Grep repo for `NodeDefRef`, `def = { path`, `NodeInvocation { def`
+- Fix: `lpc-wire/tests`, `lpa-server/template.rs`, any stray engine nodes
+- `fw-tests` / `scene_render_emu` compile if example projects referenced — may still
+ fail until phase 07; note which failures are example-TOML-only
+
+**Out of scope:** `examples/` (phase 07).
+
+## Grep commands
+
+```bash
+rg 'NodeDefRef|def = \{ path' lp-core lp-app lp-cli lp-fw
+rg 'inline_def|def_locator' lp-core
+```
+
+## Expected fixes
+
+- `lpc-wire` source slot sync test paths if node path strings changed
+- Engine runtime tests constructing `NodeInvocation::path(...)` — should still work
+
+## Validate
+
+```bash
+cargo test -p lpc-model
+cargo test -p lpc-node-registry
+cargo test -p lpc-engine
+cargo test -p fw-tests --test scene_render_emu 2>&1 | tail -30 # may fail on examples
+cargo clippy -p lpc-model -p lpc-node-registry -p lpc-engine --all-targets --no-deps -- -D warnings
+```
+
+Document any remaining failures blocked on phase 07 in phase PR notes.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/07-examples-docs.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/07-examples-docs.md
new file mode 100644
index 000000000..d7b8fb925
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/07-examples-docs.md
@@ -0,0 +1,78 @@
+# Phase 07 — Examples + Docs
+
+**Dispatch:** sub-agent: yes | parallel: - | **Depends on:** 06
+
+## Scope of phase
+
+Update **example projects** and **documentation** to `ref`/`def` TOML wire and
+`VariantSet` edit vocabulary.
+
+**In scope:**
+
+- `examples/**/project.toml`, `playlist.toml`, and nested defs with `def = { path`
+- `docs/roadmaps/.../change-language.md` — `VariantSet`, Ref|Def TOML, remove SetSlot overload doc
+- `docs/design/source-artifacts.md` if invocation syntax documented
+- Roadmap `decisions.md` / M9 summary stub
+- Re-run `fw-tests` that load examples
+
+**Out of scope:** `docs/plans-old/`, archived roadmaps (optional one-line note only).
+
+## TOML migration pattern
+
+```toml
+# before
+[nodes.shader]
+def = { path = "./shader.toml" }
+
+# after
+[nodes.shader]
+ref = "./shader.toml"
+```
+
+Project inline one-liner:
+
+```toml
+# before
+[nodes.clock]
+def = { kind = "Clock" }
+
+# after
+[nodes.clock.def]
+kind = "Clock"
+```
+
+Playlist:
+
+```toml
+# before
+node = { def = { path = "./active.toml" } }
+
+# after
+node = { ref = "./active.toml" }
+# or
+[entries.2.node]
+ref = "./active.toml"
+```
+
+## Example inventory (~15 project.toml + playlist.toml under `examples/`)
+
+Use `rg 'def = \{ path' examples/` for full list.
+
+## Docs
+
+Update `change-language.md` slot op table:
+
+| Op | Use |
+|----|-----|
+| `VariantSet { path, variant }` | Enum variant (node kind, Ref/Def, nested enums) |
+| `SetSlot { path, value }` | Value leaves only |
+
+Remove "SetSlot includes kind, path locators, wiring".
+
+## Validate
+
+```bash
+cargo test -p fw-tests --test scene_render_emu
+cargo test -p fw-tests --test profile_alloc_emu
+rg 'def = \{ path' examples/ # expect zero
+```
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/08-cleanup-ci-gate.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/08-cleanup-ci-gate.md
new file mode 100644
index 000000000..c9f8eb8d5
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/08-cleanup-ci-gate.md
@@ -0,0 +1,46 @@
+# Phase 08 — Cleanup + CI Gate
+
+**Dispatch:** main | parallel: - | **Depends on:** 07
+
+## Scope of phase
+
+Final pass: formatting, clippy, full CI gate, plan `summary.md`.
+
+**In scope:**
+
+- `cargo +nightly fmt`
+- `just check`
+- `just test` or minimum `cargo test -p lpc-node-registry` + `fw-tests` emu tests
+- Write `m9-invocation-ref-def-generic-slot-ops/summary.md`
+- Update `docs/roadmaps/.../changeset-change-management/summary.md` — M9 entry
+
+**Out of scope:** new features, engine M6 cutover.
+
+## Cleanup checklist
+
+- [ ] No `NodeDefRef` in `lp-core/` (except deprecated alias if explicitly kept — prefer none)
+- [ ] No `def_slot` field
+- [ ] No `project_node_def_mutation` / `CustomDef` in registry
+- [ ] No `def = { path` in `examples/` or active tests
+- [ ] `VariantSet` in `edit_op.rs` + serde test
+- [ ] Warnings fixed
+
+## Validate
+
+```bash
+rustup update nightly
+just check
+just test
+cargo test -p fw-tests --test scene_render_emu --test profile_alloc_emu
+```
+
+Commit (when user asks) with message along:
+
+`feat(lpc-model): NodeInvocation Ref|Def enum + VariantSet edit ops`
+
+## summary.md template
+
+- Status: complete
+- Delivered: enum model, TOML wire, VariantSet, generic apply/diff
+- Breaking: `def = { path }` → `ref =`
+- Validation commands
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/future.md b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/future.md
new file mode 100644
index 000000000..819098a0a
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/m9-invocation-ref-def-generic-slot-ops/future.md
@@ -0,0 +1,11 @@
+## Wire protocol VariantSet
+
+- **Idea:** Expose `VariantSet` on client wire alongside `SetSlot`.
+- **Why not now:** Registry/model only; `lpc-wire` unchanged in M9.
+- **Useful context:** Same serde shape as `EditOp` in `edit_op.rs`.
+
+## Slotted derive for NodeInvocation enum
+
+- **Idea:** Full `#[derive(Slotted)]` on `Ref|Def` if manual codec proves brittle.
+- **Why not now:** Phase 01 tries slotted enum first; revisit only if TOML edge cases block.
+- **Useful context:** Compare `NodeDef` enum in `nodes/node_def.rs`.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/notes.md b/docs/roadmaps/2026-05-21-changeset-change-management/notes.md
new file mode 100644
index 000000000..6516b9c85
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/notes.md
@@ -0,0 +1,125 @@
+# ChangeSet Change Management — Notes
+
+## Scope
+
+Prove client-driven edits in `lpc-node-registry` harness: ChangeSet apply →
+effective view → commit/discard → `SyncResult`. No `lpc-engine` changes until
+parent artifact-routed reload **M6**.
+
+## Prerequisites (parent roadmap)
+
+Complete before starting M1 here:
+
+| Parent | Deliverable |
+|--------|-------------|
+| M1 | `ArtifactStore` — freshness-only |
+| M2 | `NodeDefRegistry`, `NodeDefId`, `DefSource` |
+| M3 | `SourceFileSlot`, `SourceFileRef`, materialize |
+| M4 | `registry.sync` → `SyncResult`, fs-change scenarios S1–S6 |
+
+See [`dependencies.md`](dependencies.md).
+
+## Current Codebase State
+
+```
+lp-core/lpc-node-registry/src/
+├── artifact/ # M1 — done
+├── registry/ # M2 + M4 — done; RegistryChange::Fs only
+├── source/ # M3 — done
+├── change/mod.rs # stub — implement in ChangeSet M1
+└── view/node_def_view.rs # base passthrough; effective overlay in M2
+```
+
+M4 left **`RegistryChange::Fs`** only; commit from this roadmap extends the
+registry sync path. See [`change-language.md`](change-language.md).
+
+## Change language (summary)
+
+See [`change-language.md`](change-language.md) for the full spec.
+
+- `ChangeSet` → `Vec` (grouped by artifact)
+- `ArtifactTarget`: `Id` | `Path` — **path implies implicit create**
+- `ArtifactOp`: file (`Delete`, `SetBytes`) + slot (`SetSlot`, map ops, …)
+- Node defs authored only via slot ops; no `CreateDef`
+
+## Global Invariants (all stories)
+
+- **No panic / no corrupt base** until commit.
+- **Intermediate uselessness OK** — mid-morph views may have parse errors,
+ dangling refs, missing bindings.
+- **Commit contract** — all-or-nothing promotion or explicit error.
+- **Discard** — reads restore to base exactly.
+
+## User Story Matrix
+
+Reference projects: `examples/basic`, `basic2`, `events`, `button-playlist`,
+`fyeah-sign`, `fluid`, …
+
+### A — Compose from blank
+
+| ID | Story | M6 gate? |
+|----|-------|----------|
+| A1 | Blank → `basic` | **Yes** (via diff) |
+| A2 | Blank → `events` | Later |
+| A3 | Blank → `button-playlist` | Later |
+| A4 | Blank → `fyeah-sign` | Later |
+
+### B — Morph between examples
+
+| ID | Story | M6 gate? |
+|----|-------|----------|
+| B1 | `basic` → `basic2` | **Yes** (via diff) |
+| B2 | `basic` → `button` | Spot-check |
+| B3 | `events` → `basic` | Spot-check |
+| B4 | Cross-family morph | Aspirational |
+
+### C — Atomic author operations
+
+| ID | Area | M6 gate? |
+|----|------|----------|
+| C1a–f | Slot + file ops on artifacts | c/d minimum (via diff) |
+| C2a–c | Inline via slot ops at nested paths | b/c in M4 |
+| C3a–c | Inline ↔ standalone refactor | **Defer** ([`future.md`](future.md)) |
+| C4a–d | Source ↔ asset file | c minimum (`SetBytes`); a/b/d in M3 |
+
+### D — Lifecycle
+
+| ID | Story | Milestone |
+|----|-------|-----------|
+| D1 | Apply → effective view ≠ committed base | M1–M2 |
+| D2 | Commit → base updated | M5 |
+| D3 | Discard → base unchanged | M1 |
+| D4 | Multi-ChangeSet replay | [`future.md`](future.md) |
+| D5 | ChangeSet + fs-change precedence | M5 |
+
+## Resolved design (2026-05-21)
+
+Change language locked — see [`change-language.md`](change-language.md) and
+[`decisions.md`](decisions.md).
+
+## Open Questions
+
+### C3 inline ↔ standalone timing
+
+- **Context:** Extract/inline playlist entries is complex and not required for
+ A1/B1.
+- **Suggested answer:** Defer C3 to post diff-gate or parent M8; document in
+ `future.md`.
+
+### Fs vs overlay precedence (D5)
+
+- **Context:** Parent M4 `sync` applies fs changes to internal store.
+- **Suggested answer (v1):** Uncommitted ChangeSet wins for reads on overlaid
+ paths; fs bump marks stale but does not clobber overlay until commit/discard;
+ on commit, client ChangeSet wins over stale fs read.
+
+### Asserting "matches example"
+
+- **Suggested answer:** Parsed def equality + slot snapshots + asset bytes after
+ final commit; shared blank-project fixture for A* stories.
+
+## Process Notes
+
+- Promoted from parent M5 (2026-05-21).
+- Change language v1 locked — [`change-language.md`](change-language.md).
+- Parent M6 gated on M6 diff + equivalence gate here.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/overview.md b/docs/roadmaps/2026-05-21-changeset-change-management/overview.md
new file mode 100644
index 000000000..7a4f5503b
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/overview.md
@@ -0,0 +1,122 @@
+# ChangeSet Change Management
+
+## Motivation
+
+Client edits today mutate node defs in place (`slot_mutation`) with no commit
+model, no overlay, and no shared vocabulary with filesystem reload. That blocks
+incremental hot reload (artifact-routed file reload) from covering the full edit
+loop the UI needs.
+
+This roadmap proves **client-driven change management** in `lpc-node-registry`:
+ordered, id'd **ChangeSets** that express authorable edits in memory until
+**commit**. All future client edits should flow through this model (overlay →
+view → optional commit).
+
+ChangeSets also become the **universal edit vocabulary**: the same ordered op
+stream powers the UI, **project diff**, and incremental stress replay on
+host, RV32 emulator, and device.
+
+## Relationship to Artifact-Routed File Reload
+
+This roadmap was **promoted from M5** of
+[`2026-05-21-artifact-routed-file-reload`](../2026-05-21-artifact-routed-file-reload/overview.md)
+for process clarity. It is a **prerequisite** for that roadmap's **M6 engine
+cutover** — not a fork.
+
+```text
+Artifact-routed reload (parent) ChangeSet (this roadmap)
+───────────────────────────────── ─────────────────────────
+M1 ArtifactStore ──┐
+M2 NodeDefRegistry ──┼── prerequisites (complete)
+M3 SourceFileSlot ──┤
+M4 fs-change sync → SyncResult ──┘
+ │
+M6 engine cutover ◄── gated on ──────┘ M1–M6 here green
+M7–M10 server / graph / probes / cleanup
+```
+
+**Parallel build rule unchanged:** this roadmap does **not** modify
+`lpc-engine` until parent M6.
+
+## Architecture
+
+```text
+NodeDefRegistry (owns committed + pending)
+ store: ArtifactStore — committed bytes + freshness
+ overlay: ChangeOverlay — pending artifact mutations (path-keyed)
+ entries, indexes — committed parse cache; re-derived on commit/sync
+
+ Internal reads: overlay ∪ store → effective artifact bytes / slot trees
+ Public reads (NodeDefView): effective only — always base ∪ overlay
+ entries + SyncResult: committed truth after commit/sync
+
+ChangeSet (wire / UI / diff)
+ ChangeSet { id, changes: Vec }
+ apply → overlay; discard → clear overlay; commit → flush → SyncResult
+
+Engine (parent M6 — minimal change)
+ consumed slot: bindings → effective registry def read → value
+ provenance: not on tick path; parent M10 ExplainSlot probe when client asks
+```
+
+## Change language
+
+Full spec: [`change-language.md`](change-language.md).
+
+Summary:
+
+```text
+ChangeSet → Vec // grouped by artifact
+
+ArtifactChange {
+ target: Id(ArtifactId) | Path(LpPathBuf), // Path → implicit create if missing
+ ops: Vec,
+}
+
+ArtifactOp:
+ file: Delete | SetBytes // assets; TOML import escape hatch only
+ slot: SetSlot | MapInsert | … // node defs are slots; wiring included
+```
+
+**Asset** (user term) = non-node file (GLSL, SVG). **Artifact** = store path
+identity (any file, including `.toml`).
+
+## Alternatives Considered
+
+- **Defer ChangeSet until after engine cutover** — rejected.
+- **Flat op stream with per-op artifact ref** — rejected; group by artifact.
+- **Explicit Create / New artifact target** — rejected; implicit create on `Path`.
+- **CreateDef / pre-populated def blobs** — rejected; slot ops + defaults.
+- **Ops as slot-system types** — rejected; serde change vocabulary in `change/`.
+- **CRDT / concurrent merge in v1** — deferred to `future.md`.
+
+## Risks
+
+- Slot op apply touches `lpc-model` mut paths — phased in M4.
+- C3 inline ↔ standalone refactor — defer past diff gate.
+- ESP32 heap — overlays must not retain duplicate file bytes long-term.
+- Diff engine complexity — M6 gate; hand-written stories not required once diff green.
+
+## Scope Estimate
+
+Seven milestones. Parent **M6** starts only when **M6 (diff + equivalence
+gate)** here is green.
+
+## Milestones
+
+| # | Milestone | Gate |
+|---|-----------|------|
+| M1 | [Change language + overlay](m1-change-language-overlay.md) | Types, `ChangeOverlay` in registry, apply/discard, D1/D3 |
+| M2 | [Effective projection](m2-effective-projection.md) | `NodeDefView` effective reads; overlay ∪ base |
+| M3 | [File ops + asset reads](m3-asset-overlay.md) | `SetBytes`/`Delete`, materialize from overlay; C4* |
+| M4 | [Slot ops + serialize](m4-node-slot-patches.md) | Slot ops, TOML serialize path; C1*, C2* |
+| M5 | [Commit promotion](m5-commit-promotion.md) | Commit → base + `SyncResult`; D2, D5 |
+| M6 | [Diff + equivalence gate](m6-diff-equivalence-gate.md) | `diff(∅, basic)`, `diff(basic, basic2)`; **parent M6 gate** |
+| M7 | [Cleanup + validation](m7-cleanup-validation.md) | CI, docs, parent cross-links |
+
+## User Story Index
+
+Full story matrix: [`notes.md`](notes.md).
+
+**M6 gate (minimum):** D1–D3, D5, C1 slot ops + C4c, **A1** via diff, **B1** via
+diff. Hand-written story tests optional once diff covers compose/morph.
diff --git a/docs/roadmaps/2026-05-21-changeset-change-management/summary.md b/docs/roadmaps/2026-05-21-changeset-change-management/summary.md
new file mode 100644
index 000000000..2af57a60b
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-changeset-change-management/summary.md
@@ -0,0 +1,63 @@
+# ChangeSet Change Management — Summary
+
+## Status
+
+Complete on branch `codex/incremental-artifact-reload` (M1–M6 implemented; M7 doc
+and validation pass).
+
+## Delivered
+
+`lpc-node-registry` now supports client-driven edits as ordered
+[`ChangeSet`](../../lp-core/lpc-node-registry/src/change/change_set.rs) batches:
+
+```text
+apply_changeset → ChangeOverlay
+NodeDefView.get() → effective (overlay ∪ committed)
+commit(fs) → write fs → re-derive entries → SyncResult → clear overlay
+discard_overlay → drop pending edits
+```
+
+| Milestone | Summary |
+|-----------|---------|
+| M1 | Change language types, `ChangeOverlay`, apply/discard |
+| M2 | Effective projection via `NodeDefView` |
+| M3 | Asset `SetBytes`/`Delete`, materialize from overlay |
+| M4 | Slot ops, TOML serialize for slot drafts |
+| M5 | Commit promotion to filesystem + `SyncResult` |
+| M6 | `diff(base, target)` + equivalence gate (`diff` feature) |
+
+Per-milestone notes: `m1-change-language-overlay/summary.md` through
+`m6-diff-equivalence-gate/summary.md`.
+
+## Public API
+
+- **Read:** `NodeDefView`, `ParseCtx`, committed `NodeDefRegistry::get`
+- **Client edit:** `ChangeSet`, `NodeDefRegistry::apply_changeset`,
+ `discard_overlay`, `commit`
+- **Filesystem reload:** `sync` / `sync_fs` (unchanged from artifact-routed M4)
+- **Harness (`diff` feature):** `ProjectSnapshot`, `diff`, `assert_equivalent`
+
+Embedded consumers should depend with `default-features = false` to omit the diff
+harness.
+
+## Validation
+
+```bash
+cargo test -p lpc-node-registry
+cargo clippy -p lpc-node-registry --all-targets --no-deps -- -D warnings
+cargo check -p lpc-node-registry --no-default-features
+```
+
+73 integration + unit tests at last count.
+
+## Unblocks
+
+Parent [artifact-routed M6 engine cutover](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md)
+— ChangeSet M6 diff + equivalence gate is green.
+
+## Out of scope (see `future.md`)
+
+- Wire ChangeSet protocol to clients
+- `RegistryChange::ChangeSet` variant (fs notifications only today)
+- Concurrent merge / CRDT
+- Parent M7–M10 server, graph, provenance
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover.md b/docs/roadmaps/2026-05-21-engine-registry-cutover.md
new file mode 100644
index 000000000..4c58f58e7
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover.md
@@ -0,0 +1,30 @@
+# Engine–Registry Cutover
+
+**Promoted from** [artifact-routed M6](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md).
+
+- [Overview](overview.md)
+- [Notes](notes.md)
+- [Decisions](decisions.md)
+- [Future](future.md)
+
+## Status
+
+**M1 next** — API hardening and readiness (no cutover code until shape is signed off).
+
+## Prerequisites
+
+- [Artifact-routed M1–M4](../2026-05-21-artifact-routed-file-reload/overview.md)
+- [ChangeSet M1–M10](../2026-05-21-changeset-change-management/overview.md)
+
+## Milestones
+
+| # | Doc |
+|---|-----|
+| M1 | [**API hardening + readiness**](m1-api-hardening.md) |
+| M2 | [Wire edit messages](m2-wire-edit-messages.md) |
+| M3 | [Server registry + apply](m3-server-registry-apply.md) |
+| M4 | [Engine loader cutover](m4-engine-loader-cutover.md) |
+| M5 | [SyncResult engine policy](m5-sync-result-engine-policy.md) |
+| M6 | [Graph reconciliation](m6-graph-reconciliation.md) |
+| M7 | [Server fs wire-up](m7-server-fs-wireup.md) |
+| M8 | [Cleanup + legacy mutation removal](m8-cleanup-validation.md) |
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/decisions.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/decisions.md
new file mode 100644
index 000000000..0c0906dd2
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/decisions.md
@@ -0,0 +1,78 @@
+#### Promoted from artifact-routed M6
+
+- **Decision:** Engine switchover + prerequisites live in
+ `docs/roadmaps/2026-05-21-engine-registry-cutover/`.
+- **Why:** Large enough for its own roadmap.
+- **Revisit when:** Unlikely.
+
+#### M1 is API hardening, not a blind type move
+
+- **Decision:** M1 resolves shape, UI parity, mutation inventory, and M3/M4
+ sequencing **before** committing to implementation.
+- **Why:** Avoid wire/registry churn; user-requested gate.
+- **Revisit when:** M1 exit criteria met.
+
+#### Edit vocabulary in lpc-model
+
+- **Decision:** Shared serde edit types live in **`lpc-model::edit`**.
+- **Why:** Wire + registry need one vocabulary; wire cannot depend on registry.
+- **Status:** Implemented with canonical overlay vocabulary: `ProjectOverlay`,
+ `ArtifactOverlay`, `SlotOverlay`, `SlotEdit`, `SlotEditOp`,
+ `ArtifactBodyEdit`, `OverlayMutation`, mutation results, commit summaries,
+ and portable definition locations.
+
+#### Edit types are still not SlotData
+
+- **Decision:** `lpc-model::edit` is dedicated — not slot shapes / codec.
+- **Why:** Preserves ChangeSet intent.
+- **Supersedes:** ChangeSet “ops in registry only” for **location**; not for semantics.
+
+#### Registry keeps runtime logic
+
+- **Decision:** Overlay, apply, commit, `NodeDefRegistry`, `SyncResult`,
+ `NodeDefView` stay in **`lpc-node-registry`**.
+
+#### Wire addressing
+
+- **Decision:** Authored edits use artifact path + `SlotPath`; read/UI metadata
+ will bridge runtime nodes to those edit addresses during cutover.
+- **Rejected for now:** Keeping `node..def` as the edit wire root.
+- **Revisit when:** M1 UI parity doc — may require read metadata, not second root.
+
+#### Artifact body edit naming
+
+- **Decision:** New shared/wire-facing byte-level operations use
+ `ArtifactBodyEdit`, not `AssetEdit`.
+- **Why:** The operation can replace or delete any artifact body, including
+ `.toml` definitions. "Asset" remains useful for non-def referenced files such
+ as GLSL/SVG bodies.
+- **Status:** Implemented. Registry-local `AssetEdit` compatibility state was
+ removed; registry stores `ProjectOverlay` from `lpc-model`.
+
+#### Overlay mutations, not registry SyncOp on wire
+
+- **Decision:** Client-authored overlay changes use ordered
+ `OverlayMutationBatch`; registry `SyncOp::Fs` stays server-local.
+- **Why:** `SyncOp` mixes client edit intent with filesystem watcher events.
+ Exposing it would leak registry mechanics into the wire contract.
+- **Status:** Implemented for the POC with `WireOverlayRead*`,
+ `WireOverlayMutation*`, and `WireOverlayCommit*` wrappers.
+
+#### Legacy mutation cleanup
+
+- **Decision:** Inventory in M1; **delete** `WireSlotMutation*` path, engine
+ `slot_mutation`, client pending queue in **M8** (after cutover works on new path).
+- **Why:** No dual mutation APIs in production long-term.
+
+#### M3 / M4 sequencing
+
+- **Decision:** **Open** — options documented in M1; not preset to server-first.
+- **Why:** User preference to think through; cutover not feared.
+
+#### Session log deferred
+
+- **Decision:** No server session log v1; path-keyed overlay (ChangeSet M8).
+
+#### M10 provenance out of scope
+
+- **Decision:** ExplainSlot probes stay artifact-routed M10.
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/future.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/future.md
new file mode 100644
index 000000000..b0d6f144e
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/future.md
@@ -0,0 +1,23 @@
+## Slot provenance / ExplainSlot
+
+- **Idea:** Post-cutover client probe for effective slot resolution (artifact-routed M10).
+- **Why not now:** Diagnostic feature; cutover does not require it.
+- **Useful context:** `lpc-wire` probe types; engine effective read path after M5.
+
+## ChangeSet replay stress harness
+
+- **Idea:** Replay `EditBatch` streams through full engine on host/emu/device.
+- **Why not now:** Needs M4–M5 engine on registry path.
+- **Useful context:** ChangeSet `diff` + `assert_equivalent`.
+
+## Server-side session log
+
+- **Idea:** Versioned append log for multi-tab / undo.
+- **Why not now:** ChangeSet M8 explicitly dropped session log.
+- **Revisit when:** Client needs pull-based pending sync.
+
+## CRDT / multi-writer edits
+
+- **Idea:** Concurrent edit merge on shared projects.
+- **Why not now:** Single-writer server v1.
+- **Useful context:** ChangeSet roadmap `future.md`.
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m0.1-pre-m1-stabilization/00-notes.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m0.1-pre-m1-stabilization/00-notes.md
new file mode 100644
index 000000000..cef4274c2
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m0.1-pre-m1-stabilization/00-notes.md
@@ -0,0 +1,155 @@
+# M0.1 — Pre-M1 stabilization
+
+Small refactors and design notes before M1 API hardening sign-off.
+
+### Store-owned artifact catalog (M0.1b)
+
+`ArtifactStore` now owns durable path ↔ [`ArtifactId`] registration:
+
+- `register_file` / `unregister` — catalog lifetime (not refcount cache pins)
+- `id_for_path` / `path_for_id` — lookups
+- Registry duplicate maps removed; `reconcile_artifacts` unregisters ids not
+ referenced by defs or source deps
+
+Ids remain stable for the same path until explicit unregister.
+
+### `ArtifactStore` keys use `ArtifactId`
+
+`by_handle: BTreeMap` and `location_to_handle: BTreeMap<_, u32>` were
+wrong — maps now use `ArtifactId` directly:
+
+- `by_id: BTreeMap`
+- `location_to_id: BTreeMap`
+
+Allocation counter stays `next_id: u32`; `alloc_id()` returns `ArtifactId`.
+
+### Terminology: id, not handle
+
+- `ArtifactId::handle()` → `ArtifactId::raw()` (inner u32 for logging/errors)
+- `ArtifactError::UnknownHandle` → `UnknownArtifact { id: ArtifactId }`
+- `ArtifactError::InvalidRelease { handle }` → `{ id: ArtifactId }`
+- `EditError::UnknownArtifact { artifact_id: u32 }` → `{ id: ArtifactId }`
+- `ArtifactId` serde is `#[serde(transparent)]` (wire: plain number)
+
+### Serde: PascalCase `kind` tags
+
+For internally tagged enums where `kind` names a **type**, variant names stay
+PascalCase on the wire:
+
+```json
+{ "kind": "Slot", "target": { ... }, "ops": [ ... ] }
+{ "kind": "Asset", "target": { ... }, "ops": [ ... ] }
+```
+
+No `rename_all = "snake_case"` on the enum when `tag = "kind"`. Field names and
+nested op variant names may still use snake_case where they are not type tags.
+
+---
+
+## Open for M1: edit batch shape
+
+### Problem: `EditTarget` mixes wire and storage concerns
+
+Today each `ArtifactEdit` carries `target: EditTarget`:
+
+```rust
+EditTarget::Id(ArtifactId) // registry-internal
+EditTarget::Path(LpPathBuf) // authoring / implicit overlay create
+```
+
+That is convenient for a single apply path, but **stored pending edits on the
+server should always be keyed by `ArtifactId`**. Path is a client authoring
+concern: resolve once at apply ingress, then never persist path in the batch.
+
+**Proposal:**
+
+| Layer | Target form |
+|-------|-------------|
+| Wire / client apply | Path only (M1 default A3) |
+| Registry overlay + pending batch | `ArtifactId` only |
+| `EditTarget` | Keep for wire ingress helper, or split into `WireArtifactTarget` vs drop from stored types |
+
+Apply flow: `Path` → register/acquire artifact if needed → merge into pending
+map by id.
+
+### Problem: `EditBatch` is `Vec`
+
+A vec allows multiple blocks for the same artifact (duplicate targets, ambiguous
+merge order). That does not match mental model: **one pending body per artifact
+per batch**.
+
+**Proposal — stored form:**
+
+```rust
+pub struct EditBatch {
+ pub id: EditBatchId,
+ pub artifacts: BTreeMap,
+}
+
+pub enum ArtifactBodyEdit {
+ Slot(Vec),
+ Asset(Vec),
+}
+```
+
+Properties:
+
+- At most one entry per `ArtifactId` (map invariant)
+- Slot vs asset is explicit on the value — no repeated `target` per block
+- Ops within one artifact still ordered (`Vec` / `Vec`)
+
+**Wire form** (optional separate type or same with path key before resolution):
+
+```rust
+// ingress only — converted before persistence
+pub struct WireEditBatch {
+ pub id: EditBatchId,
+ pub edits: Vec,
+}
+
+pub enum WireArtifactEdit {
+ Slot { path: LpPathBuf, ops: Vec },
+ Asset { path: LpPathBuf, ops: Vec },
+}
+```
+
+Server: resolve each path → id, merge ops into `BTreeMap`, reject duplicate
+path/id in one batch.
+
+### Overlay alignment
+
+`SlotOverlay` is still path-keyed today. For server storage consistency,
+consider:
+
+- Overlay keyed by `ArtifactId` with path lookup via `artifact_path_to_id`, or
+- Keep path keys in overlay but ensure batch/pending metadata is id-keyed
+
+Decision deferred to M1 — note dependency on implicit-create semantics (new
+path before first commit).
+
+### Migration from current `ArtifactEdit`
+
+Current shape can be mechanically translated:
+
+```text
+Vec → fold into BTreeMap
+ Slot { target, ops } → resolve(target) → ArtifactBodyEdit::Slot(ops)
+ Asset { target, ops } → resolve(target) → ArtifactBodyEdit::Asset(ops)
+```
+
+Reject batch if two blocks resolve to the same id with conflicting kinds
+(Slot vs Asset on same file is an error).
+
+---
+
+## M1 questions to add
+
+| # | Question |
+|---|----------|
+| E1 | Stored `EditBatch`: `BTreeMap` vs vec + validation? |
+| E2 | Separate wire ingress type vs single type with optional path/id? |
+| E3 | Overlay key: path vs `ArtifactId`? |
+| E4 | Duplicate artifact in one batch: error vs last-wins merge? |
+
+Suggested defaults: **map + error on duplicate**, **wire path-only ingress**,
+**overlay id-keyed when server owns registry** (path ok for harness-only until M3).
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md
new file mode 100644
index 000000000..853669769
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening.md
@@ -0,0 +1,138 @@
+# Milestone 1: API Hardening + Cutover Readiness
+
+## Title and goal
+
+**Do not move code yet.** Decide and document the committed API shape for edit
+vocabulary, wire messages, client parity, and cutover sequencing — then implement
+only what M1 needs to prove the shape (small spikes optional).
+
+M1 is the gate: we should be confident in the types and boundaries before M2+
+implementation churn.
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/`
+
+Deliverables live in that plan directory:
+
+- `00-notes.md` — question log + answers
+- `00-design.md` — frozen API (after review)
+- `ui-parity.md` — debug UI / client capability matrix
+- `mutation-inventory.md` — legacy mutation stack to delete post-cutover
+- `m3-m4-sequencing.md` — server-first vs combined cutover options
+
+## Scope
+
+**In:**
+
+- All open design questions (below) resolved or explicitly deferred with owner
+- UI parity analysis vs current `WireSlotMutation` + debug UI
+- Legacy mutation inventory + cleanup checklist (execution in M8; list in M1)
+- Target module layout (`lpc-model::edit`, `lpc-wire`, registry boundaries)
+- Optional: type move spike **only if** design is signed off mid-M1
+
+**Out:**
+
+- Full wire implementation (M2)
+- Server or engine cutover (M3+)
+- Graph reconciliation
+
+## Question catalog (resolve in M1)
+
+### A. Model vs registry split
+
+| # | Question | Context | Suggested default |
+|---|----------|---------|-------------------|
+| A1 | Which types live in `lpc-model::edit`? | Portable edit vocabulary must be shared by wire and registry | `ProjectOverlay`, `ArtifactOverlay`, `SlotOverlay`, `SlotEdit`, `SlotEditOp`, `ArtifactBodyEdit`, `OverlayMutation`, mutation ids/results, portable definition locations |
+| A2 | Does `SyncOp` live in model? | `SyncOp` mixes client edits with server-local fs events | **No** — client wire uses overlay mutations; registry `SyncOp::Fs` remains local |
+| A3 | `EditTarget::Id(ArtifactId)` on wire? | Id is registry-internal | **Path only** on wire; registry resolves id locally if kept in model |
+| A4 | `EditError` in model vs wire-only rejections? | Two layers today | Model: apply errors; wire: request rejections + mapping table |
+| A5 | `schemars` on model edit types? | Wire has schema-gen | Mirror `lpc-wire` pattern via model feature flag |
+| A6 | `EditBatchId` semantics | Unused in sync today | Define: client correlation id vs server idempotency (or neither v1) |
+
+### B. Wire envelope
+
+| # | Question | Context | Suggested default |
+|---|----------|---------|-------------------|
+| B1 | Piggyback on `ProjectReadRequest` vs new message? | Mutations already piggybacked | TBD — document tradeoffs in M1 |
+| B2 | Wire op set = full `SyncOp` or subset? | Fs events are server-local | Client: read overlay, mutate overlay, commit overlay; no Fs on wire |
+| B3 | Response carries `SyncOutcome`? | Today only mutation accept/reject | Extend `ProjectReadResponse` with pending + commit summary |
+| B4 | Optimistic concurrency model? | Slot mutation uses shape/data `Revision` CAS | Overlay model: pending until commit; define conflict rules for concurrent Apply |
+
+### C. Addressing
+
+| # | Question | Context | Suggested default |
+|---|----------|---------|-------------------|
+| C1 | Artifact path vs `node..def`? | Two vocabularies today | **Path + SlotPath** for edits; see UI parity |
+| C2 | How does UI resolve node → artifact path? | Debug UI uses string roots | Project read metadata: `NodeId` → `(artifact_path, path_prefix)` |
+| C3 | Inline playlist children | Edits use paths on parent `.toml` | Document prefix rules; no separate wire root |
+
+### D. M3 / M4 sequencing (open — user TBD)
+
+| # | Question | Context | Options |
+|---|----------|---------|---------|
+| D1 | Server registry without engine update? | M3 applies/commits to fs; engine stale until M4 | **A:** M3 server-only staging · **B:** merge M3+M4 · **C:** M4 first on host harness |
+| D2 | When does UI see effective edits? | Overlay ≠ engine tree until cutover | Document UX per option; may accept lag if cutover is fast |
+| D3 | Single project open on server? | Registry per project | Confirm lifecycle: load root, unload on close |
+
+### E. Commit / pending UX
+
+| # | Question | Context | Suggested default |
+|---|----------|---------|-------------------|
+| E1 | Explicit Commit on wire? | Registry commit is exposed through `WireOverlayCommit*` | **Yes** — client drives commit; server does not auto-commit edits |
+| E2 | Discard / ClearPending exposure? | Registry supports both | Wire both for editor reset |
+| E3 | Read effective vs committed in project read? | `NodeDefView` vs `get()` | Define query flag or always effective for editor |
+
+## UI parity (document in `ui-parity.md`)
+
+Current production edit path (debug UI):
+
+| Capability | Today (`WireSlotMutation`) | Edit language equivalent | M1 note |
+|------------|---------------------------|--------------------------|---------|
+| Value leaf edit | `SetValue` on `node..def` + `SlotPath` | `AssignValue` on artifact path + path | Needs C2 mapping |
+| Enum / kind change | Not on wire | `EnsurePresent` on variant path | POC |
+| Map insert/remove | Not on wire | `EnsurePresent` / `Remove` | POC |
+| Option some/none | Not on wire | `EnsurePresent` / `Remove` | POC |
+| Artifact body replace | Not on wire | `ArtifactBodyEdit::ReplaceBody` | POC |
+| Artifact delete | Not on wire | `ArtifactBodyEdit::Delete` | POC |
+| Pending indicator | `SlotMirrorView.pending` + mutation id | Client overlay mirror TBD | Redesign in M2/M3 |
+| Conflict handling | shape/data revision CAS | TBD (B4) | M1 must decide |
+| Commit | Immediate apply to engine memory | `WireOverlayCommitRequest` | **Behavior change** — UI must add commit |
+| Error display | `WireSlotMutationRejection` | `EditError` / wire rejection | Map in M1 |
+
+**M1 deliverable:** explicit v1 parity target — which rows are cutover blockers vs
+post-cutover enhancements.
+
+## Legacy mutation cleanup inventory (document in `mutation-inventory.md`)
+
+Remove after cutover (execution **M8**; inventory **M1**):
+
+| Area | Symbols / files |
+|------|-----------------|
+| `lpc-wire` | `WireSlotMutationRequest`, `WireSlotMutationOp`, responses, rejections |
+| `lpc-view` | `SlotMirrorView::prepare_set_value`, `PendingSlotMutation`, pending queue |
+| `lpc-engine` | `slot_mutation.rs`, `mutate_project_slots` |
+| `lpa-server` | `apply_project_mutations`, mutation logging |
+| `lp-cli` | `SlotEditIntent`, `prepare_queued_mutations`, mutation status UI |
+| `lpc-shared` | server trait mutation hook |
+
+Also: engine in-memory def mutation path vs overlay+commit.
+
+## M1 exit criteria (gate for M2)
+
+- [ ] `00-design.md` reviewed — frozen type list and crate boundaries
+- [ ] All A–E questions answered or deferred with milestone tag
+- [ ] `ui-parity.md` — v1 blocker list agreed
+- [ ] `mutation-inventory.md` — complete
+- [ ] `m3-m4-sequencing.md` — chosen option (A/B/C)
+- [ ] ChangeSet `change-language.md` + decisions updated to point at model home
+- [ ] Optional: types moved to model if design closed early
+
+## Dependencies
+
+- ChangeSet M10 slot/asset split landed on branch
+
+## Execution strategy
+
+**Full plan** — design review milestone; implementation is mostly docs + small
+spikes. Type move can be last phase of M1 or first task of M2 depending on review.
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/m3-m4-sequencing.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/m3-m4-sequencing.md
new file mode 100644
index 000000000..ca35ac040
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/m3-m4-sequencing.md
@@ -0,0 +1,60 @@
+# M3 / M4 Sequencing Options
+
+**Status:** open — pick in M1 review
+
+## Option A — Server staging first (original proposal)
+
+```text
+M2 wire → M3 server registry (apply/commit to fs, engine unchanged)
+ → M4 engine loader cutover
+ → M5 SyncResult policy
+```
+
+**Pros:** Prove wire + registry on real server without engine risk.
+**Cons:** UI edits commit to disk but running scene stale until M4; awkward demo window.
+
+## Option B — Combined server + loader cutover
+
+```text
+M2 wire → M4+M3 single milestone (registry on server + engine loads from registry)
+ → M5 SyncResult policy
+```
+
+**Pros:** No stale-engine period; matches "not scared of cutover."
+**Cons:** Larger bang; harder to bisect failures.
+
+## Option C — Engine harness first, server second
+
+```text
+M2 wire (types only) → M4 engine cutover on host tests / examples
+ → M3 server wire-up
+ → M5 policy
+```
+
+**Pros:** Validates loader on CI before server lifecycle work.
+**Cons:** Client still can't edit via server until M3.
+
+## Option D — Wire + engine cutover; server follows
+
+```text
+M2 wire → M4 engine (no lpa-server edit path yet)
+ → M3 server
+ → M5
+```
+
+**Pros:** Engine is the hard part; server is thin wiring.
+**Cons:** No E2E client path until M3.
+
+## Questions to answer
+
+1. Is a temporary stale-engine window (A) acceptable at all?
+2. Do we need client E2E before or after engine loader lands?
+3. Can `lpa-server` integration tests use registry without full engine cutover?
+
+## Recommendation for discussion
+
+Lean **B or D** if cutover risk is low: avoid building two production mutation
+paths. Use **A** only if we want a server-only integration test milestone with
+clear pass/fail before touching `ProjectLoader`.
+
+**User note (2026-05-21):** not 100% sure — keep open until M1 review.
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/mutation-inventory.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/mutation-inventory.md
new file mode 100644
index 000000000..f9abffbac
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/mutation-inventory.md
@@ -0,0 +1,63 @@
+# Legacy Mutation Stack — Deletion Inventory
+
+**Status:** draft for M1 review
+**Execution:** M8 (after new edit path works)
+**M1 job:** confirm list complete; no deletion in M1.
+
+## lpc-wire
+
+| File / symbol | Role |
+|---------------|------|
+| `slot/mutation.rs` | `WireSlotMutationRequest`, `WireSlotMutationOp`, responses, rejections |
+| `messages/project_read/project_read_request.rs` | `mutations: Vec` |
+| `messages/project_read/project_read_response.rs` | `mutations: Vec` |
+
+## lpc-view
+
+| File / symbol | Role |
+|---------------|------|
+| `slot/mirror.rs` | `prepare_set_value`, `apply_mutation_response`, pending map |
+| `slot/pending.rs` | `PendingSlotMutation` |
+
+## lpc-engine
+
+| File / symbol | Role |
+|---------------|------|
+| `engine/slot_mutation.rs` | `mutate_project_slots`, in-memory def mutation |
+| `engine/mod.rs` | module export |
+
+## lpa-server
+
+| File / symbol | Role |
+|---------------|------|
+| `project_read_source.rs` | `apply_project_mutations` → engine |
+
+## lpc-shared / server trait
+
+| Symbol | Role |
+|--------|------|
+| `TransportServer` mutation hook | passes mutations to server impl |
+
+## lp-cli (debug UI)
+
+| File / symbol | Role |
+|---------------|------|
+| `debug_ui/ui.rs` | `queued_mutations`, `prepare_queued_mutations` |
+| `debug_ui/slot_edit.rs` | `SlotEditIntent`, mutation status by id |
+
+## Replacement (target)
+
+| Old | New |
+|-----|-----|
+| `WireSlotMutationRequest` | `WireOverlayMutationRequest` / `OverlayMutation` |
+| `prepare_set_value` | build `SlotEdit { path, op: AssignValue }` + mutate overlay |
+| `mutate_project_slots` | `registry.sync` + engine refresh on commit |
+| immediate accept/reject | apply errors + commit outcome |
+
+## Grep commands (M8 verification)
+
+```bash
+rg 'WireSlotMutation|prepare_set_value|mutate_project_slots|slot_mutation' lp-core lp-app lp-cli
+```
+
+Expect zero hits in production paths after M8.
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/ui-parity.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/ui-parity.md
new file mode 100644
index 000000000..5b1f65e43
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m1-api-hardening/ui-parity.md
@@ -0,0 +1,69 @@
+# UI Parity — WireSlotMutation vs Edit Language
+
+**Status:** draft for M1 review
+**Goal:** Define v1 cutover blockers vs post-cutover enhancements.
+
+## Current debug UI flow
+
+```text
+User edits control in lp-cli debug UI
+ → SlotEditIntent { root: "node..def", path, value }
+ → SlotMirrorView.prepare_set_value (client-side validation + revision CAS)
+ → WireSlotMutationRequest piggybacked on ProjectReadRequest
+ → lpa-server → engine.mutate_project_slots (immediate in-memory write)
+ → WireSlotMutationResponse accept/reject
+ → SlotMirrorView.apply_mutation_response
+```
+
+No commit step. No overlay. Effective = committed = engine memory.
+
+## Target flow (after cutover)
+
+```text
+User edits control
+ → Resolve (artifact_path, slot_path) ← NEW: needs read metadata (M1 C2)
+ → Build OverlayMutation::PutSlotEdit { SlotEdit { path, op } }
+ → WireOverlayMutationRequest (pending overlay)
+ → Optional: read effective via project read
+ → User commits → WireOverlayCommitRequest → fs + engine refresh
+```
+
+## Capability matrix
+
+| # | User action | Today | Edit language | v1 blocker? | Notes |
+|---|-------------|-------|---------------|-------------|-------|
+| 1 | Edit scalar on node def (clock rate, shader param) | `SetValue` | `AssignValue` | **Yes** | Core debug UI |
+| 2 | See edit errors inline | `WireSlotMutationRejection` | TBD wire rejection | **Yes** | Map error enums in M1 |
+| 3 | Pending / in-flight indicator | per-slot mutation id | client pending overlay | **Yes** | Redesign mirror model |
+| 4 | Optimistic local preview | pending queue, no local write | overlay mirror or wait for read | **TBD** | M1 B4 |
+| 5 | Change node kind | not in UI | `EnsurePresent` on variant path | POC | Default-based kind switch |
+| 6 | Map / playlist entry edits | not in UI | `EnsurePresent` / `Remove` | POC | SlotPath identity creates map keys |
+| 7 | Option fields | not in UI | `EnsurePresent` / `Remove` | POC | No separate option op |
+| 8 | Edit GLSL source | not via mutation | `ArtifactBodyEdit::ReplaceBody` | POC | Future asset editor |
+| 9 | Commit / discard | N/A (instant) | commit request / clear overlay mutation | **Yes** | UX change — add UI affordance? |
+| 10 | Node tree slot read | `node..def` roots in project read | effective defs from registry | **Yes** | Read path must use NodeDefView |
+
+## Addressing migration
+
+Today `SlotEditKey` = `{ root: "node.3.def", path }`.
+
+Target key = `{ artifact_path: "/clock.toml", path }` or keep logical root if read
+continues to expose `node..def` snapshots but **edits** use paths.
+
+**M1 decision needed:** dual keys during transition vs one-time UI rewrite.
+
+## Read metadata needed (if path-centric edits)
+
+Project read should expose per node (minimum):
+
+- `node_id`
+- `def_artifact_path` (absolute)
+- optional `slot_path_prefix` for inline children (e.g. `entries[2].node.def`)
+
+Without this, UI cannot build overlay mutations from the node panel.
+
+## Suggested v1 parity bar
+
+**Blockers:** rows 1, 2, 3, 9, 10
+**Defer:** rows 5–8
+**Decide in M1:** row 4 (optimistic preview strategy)
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages.md
new file mode 100644
index 000000000..fbf11811e
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages.md
@@ -0,0 +1,24 @@
+# Milestone 2: Wire Edit / Sync Messages
+
+## Title and goal
+
+Implement **`lpc-wire`** messages per **M1 `00-design.md`**.
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m2-wire-edit-messages/`
+
+## Scope
+
+**In:** Types, serde, schema-gen, roundtrip tests, rejection enums, mapping to
+model `SyncOp` / edit types.
+
+**Out:** Server dispatch (M3); engine (M4+).
+
+## Dependencies
+
+- **M1 exit criteria** complete.
+
+## Execution strategy
+
+**Full plan**
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply.md
new file mode 100644
index 000000000..53cd3a2b3
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply.md
@@ -0,0 +1,30 @@
+# Milestone 3: Server Registry + Apply
+
+## Title and goal
+
+**`lpa-server`** owns **`NodeDefRegistry`**; wire edits → `registry.sync()`.
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m3-server-registry-apply/`
+
+## Scope
+
+**In:** Registry lifecycle; wire → sync translation; commit to project fs;
+integration tests.
+
+**Out:** Engine tree updates — **unless M1 chooses combined M3+M4**.
+
+## Key decisions (from M1)
+
+See **`m1-api-hardening/m3-m4-sequencing.md`**. This milestone's scope shifts if
+we merge server + engine cutover.
+
+## Dependencies
+
+- M2 complete
+- M1 sequencing choice recorded
+
+## Execution strategy
+
+**Full plan**
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover.md
new file mode 100644
index 000000000..1ccc2ae79
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover.md
@@ -0,0 +1,31 @@
+# Milestone 4: Engine Loader Cutover
+
+## Title and goal
+
+**Hard cut:** `ProjectLoader` / `Engine` → **`NodeDefRegistry`**; delete old
+artifact payload store.
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m4-engine-loader-cutover/`
+
+## Scope
+
+**In:** `lpc-engine` depends on registry; loader via registry + `NodeDefView`;
+remove `lpc-engine/src/artifact/`.
+
+**Out:** SyncResult incremental policy (M5) unless merged forward.
+
+## Key decisions (from M1)
+
+May run **before, with, or immediately after M3** per M1 sequencing doc.
+
+## Dependencies
+
+- M1 sequencing choice
+- M2 wire types (if client edits during cutover testing)
+- Artifact-routed M4 + ChangeSet M6 diff gate
+
+## Execution strategy
+
+**Full plan**
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy.md
new file mode 100644
index 000000000..9dd4295b8
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy.md
@@ -0,0 +1,30 @@
+# Milestone 5: SyncResult Engine Policy
+
+## Title and goal
+
+Wire **`Engine::handle_fs_changes`** and **commit** to **`registry.sync()`**;
+apply [engine-policy-v1](../2026-05-21-artifact-routed-file-reload/m4-fs-change-semantics-harness/engine-policy-v1.md).
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m5-sync-result-engine-policy/`
+
+## Scope
+
+**In:** `SyncResult` → node add/remove/refresh; shader/fixture **`SourceFileRef`**
+materialize; client commit triggers engine refresh.
+
+**Out:** Server watcher routing (M7); graph-level child list (M6).
+
+## Deliverables
+
+- Fs-change + commit integration tests on engine path.
+- M4 harness scenarios re-run on production stack.
+
+## Dependencies
+
+- M4 complete.
+
+## Execution strategy
+
+**Full plan** — policy table + node kind matrix.
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation.md
new file mode 100644
index 000000000..72f67ea1f
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation.md
@@ -0,0 +1,27 @@
+# Milestone 6: Graph Reconciliation
+
+## Title and goal
+
+When **`project.toml`** / playlist invocation wiring changes, engine mutates the
+node tree (add/remove/repoint) — not only in-def slot patches.
+
+Promoted from [artifact-routed M8](../2026-05-21-artifact-routed-file-reload/m8-project-graph-reconciliation.md).
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m6-graph-reconciliation/`
+
+## Scope
+
+**In:** Detect invocation graph diffs from `SyncResult`; engine child attach/detach;
+tests for top-level and playlist inline children.
+
+**Out:** Minimal diff optimality for every edit shape.
+
+## Dependencies
+
+- M5 complete.
+
+## Execution strategy
+
+**Full plan**
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup.md
new file mode 100644
index 000000000..d275a8fb8
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup.md
@@ -0,0 +1,27 @@
+# Milestone 7: Server Fs Wire-Up
+
+## Title and goal
+
+Route filesystem watcher events to **`Engine::handle_fs_changes`** (registry
+sync) instead of **`Project::reload()`**.
+
+Promoted from [artifact-routed M7](../2026-05-21-artifact-routed-file-reload/m7-server-fs-change-wireup.md).
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m7-server-fs-wireup/`
+
+## Scope
+
+**In:** `LpServer` → incremental sync; E2E single-file reload (TOML, GLSL).
+Explicit full reload retained for user-initiated reset.
+
+**Out:** Graph reconciliation (M6 if not done — ordering flexible).
+
+## Dependencies
+
+- M5 minimum (engine handles fs sync).
+
+## Execution strategy
+
+**Small plan**
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation.md
new file mode 100644
index 000000000..c36db2c0c
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation.md
@@ -0,0 +1,32 @@
+# Milestone 8: Cleanup + Validation
+
+## Title and goal
+
+Remove deprecated paths, **delete legacy mutation stack**, CI gate, summary.
+
+## Suggested plan location
+
+`docs/roadmaps/2026-05-21-engine-registry-cutover/m8-cleanup-validation/`
+
+## Scope
+
+**In:**
+
+- Execute **`mutation-inventory.md`** from M1 (grep-clean):
+ - `lpc-wire` slot mutation types
+ - `lpc-view` `prepare_set_value` / pending queue
+ - `lpc-engine` `slot_mutation.rs`
+ - `lpa-server` mutation dispatch
+ - `lp-cli` debug UI mutation queue (replace with edit sync + commit UX)
+- Old `lpc-engine` artifact module (if any remnants post-M4)
+- `just ci`; fw-esp32 check; roadmap summaries
+
+**Out:** M10 provenance probes.
+
+## Dependencies
+
+- New edit path working end-to-end (M5 minimum)
+
+## Execution strategy
+
+**Full plan** with checklist derived from M1 inventory.
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/notes.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/notes.md
new file mode 100644
index 000000000..8dfc98522
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/notes.md
@@ -0,0 +1,51 @@
+# Engine–Registry Cutover — Notes
+
+## Scope
+
+Promote the old **artifact-routed M6** (engine switchover) into a standalone
+roadmap. **M1 is API hardening** — resolve shape, UI parity, and sequencing before
+implementation churn.
+
+Registry **behavior** stays in `lpc-node-registry`. Shared **vocabulary** moves to
+**`lpc-model::edit`** (pending M1 sign-off). Engine **policy** stays in
+`lpc-engine`.
+
+## Current state
+
+See [overview.md](overview.md). Registry harness green; production stack unchanged.
+
+## User direction (2026-05-21)
+
+- Promote parent M6 into its own roadmap.
+- **M1 = cleanup + hardening** — answer open questions; commit to API shape before
+ moving types / wire / cutover.
+- **M3/M4 split** — not decided; not blocked on fear of cutover.
+- **Mutation cleanup** — inventory in M1; delete legacy path after cutover (M8).
+- **UI parity** — M1 documents what debug UI needs vs edit language.
+
+## Open questions → M1
+
+All question catalogs live in [m1-api-hardening.md](m1-api-hardening.md). Summary:
+
+| Area | Status |
+|------|--------|
+| Model vs registry split | M1 |
+| SyncOp in model? | M1 (lean yes) |
+| Wire envelope | M1 |
+| Path vs node-id addressing | M1 |
+| M3 vs M4 sequencing | M1 — user TBD |
+| UI parity / commit UX | M1 |
+| Legacy mutation inventory | M1 doc → M8 delete |
+
+## Dependencies (entry criteria)
+
+- Artifact-routed M4 green
+- ChangeSet M6 diff gate green
+- ChangeSet M8 sync green
+- ChangeSet M10 slot/asset split on branch
+
+## Risks
+
+- Skipping M1 and moving types too early → wire churn
+- UI commit model vs instant mutation — product decision in M1
+- Graph reconciliation scope (M6)
diff --git a/docs/roadmaps/2026-05-21-engine-registry-cutover/overview.md b/docs/roadmaps/2026-05-21-engine-registry-cutover/overview.md
new file mode 100644
index 000000000..01edde1ed
--- /dev/null
+++ b/docs/roadmaps/2026-05-21-engine-registry-cutover/overview.md
@@ -0,0 +1,65 @@
+# Engine–Registry Cutover
+
+## Motivation
+
+Two parallel stacks exist today:
+
+```text
+PRODUCTION PROVEN (harness only)
+────────── ─────────────────────
+ProjectLoader → Engine tree NodeDefRegistry → SlotOverlay → commit
+WireSlotMutation → Engine memory SyncOp → overlay → NodeDefView
+Project::reload() on fs change registry.sync() → SyncResult
+```
+
+This roadmap **ends the dual stack** after **M1 hardens the API shape**, then wire,
+server, and engine cutover land in order.
+
+Promoted from [artifact-routed M6](../2026-05-21-artifact-routed-file-reload/m6-engine-cutover.md)
+(absorbs old M7/M8; M10 provenance stays separate).
+
+## Relationship to other roadmaps
+
+```text
+Artifact-routed M1–M4 ChangeSet M1–M10
+ │ │
+ └──────────┬───────────────────┘
+ ▼
+ M1 API hardening (this roadmap) ← gate everything else
+ │
+ ├── M2 wire
+ ├── M3 server apply (sequencing TBD in M1)
+ ├── M4–M5 engine cutover + SyncResult policy
+ ├── M6–M7 graph + fs wire-up
+ └── M8 cleanup (incl. legacy mutation deletion)
+```
+
+## Architecture (target — shape frozen in M1)
+
+```text
+lpc-model::edit shared serde vocabulary (TBD details in M1)
+lpc-wire project sync messages (M2)
+lpa-server NodeDefRegistry (M3+)
+lpc-node-registry overlay, commit, NodeDefView
+lpc-engine SyncResult policy (M4+)
+```
+
+## Milestones
+
+| # | Milestone | Gate |
+|---|-----------|------|
+| M0.1 | [**Pre-M1 stabilization**](m0.1-pre-m1-stabilization/00-notes.md) | ArtifactId/store fixes; edit batch design notes |
+| M1 | [**API hardening + readiness**](m1-api-hardening.md) | Design signed off; UI parity + mutation inventory |
+| M2 | [Wire edit/sync messages](m2-wire-edit-messages.md) | M1 exit criteria |
+| M3 | [Server registry + apply](m3-server-registry-apply.md) | M2; sequencing per M1 |
+| M4 | [Engine loader cutover](m4-engine-loader-cutover.md) | M1 sequencing decision |
+| M5 | [SyncResult engine policy](m5-sync-result-engine-policy.md) | M4 |
+| M6 | [Graph reconciliation](m6-graph-reconciliation.md) | M5 |
+| M7 | [Server fs wire-up](m7-server-fs-wireup.md) | M5 |
+| M8 | [Cleanup + validation](m8-cleanup-validation.md) | Delete legacy mutation stack |
+
+**M3/M4 order is intentionally open** until M1 `m3-m4-sequencing.md` closes.
+
+## Entry criteria
+
+See [notes.md](notes.md).
diff --git a/docs/toolchain-notes.md b/docs/toolchain-notes.md
index fda1ec353..7ef5ca393 100644
--- a/docs/toolchain-notes.md
+++ b/docs/toolchain-notes.md
@@ -22,6 +22,56 @@ Three features used by `fw-esp32` and `fw-emu` are unstable:
All three are needed for OOM recovery via stack unwinding on ESP32 and the RISC-V
emulator. See `docs/reports/2026-03-13-esp32-unwinding-implementation.md` for details.
+## Why the nightly is pinned (and why it's coupled to `unwinding`)
+
+The toolchain is pinned to a dated nightly (e.g. `nightly-2026-04-27`), **not** a
+rolling `nightly`. The pin lives in three places that must stay in sync:
+
+- `rust-toolchain.toml` (workspace root) — drives local dev and any in-repo
+ `cargo`/`rustc` call.
+- `lp-fw/fw-esp32/rust-toolchain.toml` — a per-crate pin. Several recipes `cd` into
+ `lp-fw/fw-esp32`, so *this* file wins there; it also carries `rust-src` for
+ `-Zbuild-std`. If it drifts from the root pin, the firmware build resolves a
+ different (possibly unpinned) toolchain — which is exactly how CI broke once: the
+ root was pinned but this file still said `nightly`, so the build-std step ran on a
+ rolling nightly with no `rust-src`.
+- `.github/workflows/pre-merge.yml` — the `dtolnay/rust-toolchain` step (CI checks
+ out into a subdirectory, so the action can't auto-read the toml; the date is
+ passed explicitly).
+
+The reason it's pinned rather than rolling: this is a `-Zbuild-std` project, and the
+[`unwinding`](https://crates.io/crates/unwinding) crate (our `eh_personality`
+provider) is bound to the nightly `core::intrinsics::catch_unwind` ABI. That
+intrinsic changed its return type from an integer to `bool`:
+
+- `unwinding` **0.2.8** expects the integer form (`catch_unwind(...) == 0`).
+- `unwinding` **0.2.9** expects the `bool` form (`if catch_unwind(...) { ... }`).
+
+So the `unwinding` version and the nightly are a matched pair — there is no single
+`unwinding` that builds on both an old and a new nightly. With an unpinned `nightly`,
+CI silently drifts onto a newer toolchain than local dev and the build-std compile
+breaks (`E0308: expected bool, found integer`). Pinning keeps CI reproducible and in
+lockstep with local.
+
+## Bumping the toolchain
+
+Use the helper — it updates both pins, moves `unwinding` only if the new nightly
+requires it, and validates before you commit:
+
+```sh
+just bump-nightly 2026-06-01 # pin to a specific dated nightly
+just bump-nightly # pin to today's nightly (UTC)
+```
+
+It (1) rewrites the pin in every `rust-toolchain.toml` (root + per-crate) and the
+workflow, (2) runs `just check` with the current `unwinding` (this compiles
+`unwinding` under build-std
+via `clippy-rv32`, and also surfaces any new clippy lints from the newer nightly),
+(3) only if that fails, advances `unwinding` to the latest `0.2.x` and re-checks, and
+(4) leaves everything in the working tree for review — it never commits. If the new
+nightly can't be made to build (e.g. `unwinding` needs a new *major*, or a new lint
+fires), it reports what to try and reverts only the speculative `unwinding` bump.
+
## Alternatives considered
Keeping the workspace on stable with per-crate nightly overrides (`lp-fw/fw-esp32/rust-toolchain.toml`,
diff --git a/examples/basic/project.toml b/examples/basic/project.toml
index cc9afe55d..8a96142f7 100644
--- a/examples/basic/project.toml
+++ b/examples/basic/project.toml
@@ -2,13 +2,13 @@ kind = "Project"
name = "basic"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/basic2/project.toml b/examples/basic2/project.toml
index cd1aeef53..a254cc658 100644
--- a/examples/basic2/project.toml
+++ b/examples/basic2/project.toml
@@ -2,16 +2,16 @@ kind = "Project"
name = "basic2"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.texture]
-def = { path = "./texture.toml" }
+ref = "./texture.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/button-playlist/playlist.toml b/examples/button-playlist/playlist.toml
index d1a83a8f1..58d482268 100644
--- a/examples/button-playlist/playlist.toml
+++ b/examples/button-playlist/playlist.toml
@@ -8,13 +8,13 @@ source = "bus#time.seconds"
[entries.1]
name = "idle"
fade_after = 0.12
-node = { def = { path = "./idle.toml" } }
+node = { ref = "./idle.toml" }
[entries.2]
name = "active"
duration = 4.0
fade_after = 0.8
-node = { def = { path = "./active.toml" } }
+node = { ref = "./active.toml" }
[entries.2.bindings.trigger]
source = "bus#trigger"
diff --git a/examples/button-playlist/project.toml b/examples/button-playlist/project.toml
index 6d9313467..4a958cacb 100644
--- a/examples/button-playlist/project.toml
+++ b/examples/button-playlist/project.toml
@@ -2,16 +2,16 @@ kind = "Project"
name = "button-playlist"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.button]
-def = { path = "./button.toml" }
+ref = "./button.toml"
[nodes.playlist]
-def = { path = "./playlist.toml" }
+ref = "./playlist.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/button-sign/playlist.toml b/examples/button-sign/playlist.toml
index d1a83a8f1..58d482268 100644
--- a/examples/button-sign/playlist.toml
+++ b/examples/button-sign/playlist.toml
@@ -8,13 +8,13 @@ source = "bus#time.seconds"
[entries.1]
name = "idle"
fade_after = 0.12
-node = { def = { path = "./idle.toml" } }
+node = { ref = "./idle.toml" }
[entries.2]
name = "active"
duration = 4.0
fade_after = 0.8
-node = { def = { path = "./active.toml" } }
+node = { ref = "./active.toml" }
[entries.2.bindings.trigger]
source = "bus#trigger"
diff --git a/examples/button-sign/project.toml b/examples/button-sign/project.toml
index 237872902..7213a6285 100644
--- a/examples/button-sign/project.toml
+++ b/examples/button-sign/project.toml
@@ -2,19 +2,19 @@ kind = "Project"
name = "button-sign"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.button]
-def = { path = "./button.toml" }
+ref = "./button.toml"
[nodes.radio]
-def = { path = "./radio.toml" }
+ref = "./radio.toml"
[nodes.playlist]
-def = { path = "./playlist.toml" }
+ref = "./playlist.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/button/project.toml b/examples/button/project.toml
index 76f08a8b9..5642ded72 100644
--- a/examples/button/project.toml
+++ b/examples/button/project.toml
@@ -2,13 +2,13 @@ kind = "Project"
name = "button"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.button]
-def = { path = "./button.toml" }
+ref = "./button.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/events/project.toml b/examples/events/project.toml
index 3345f480b..aff26579e 100644
--- a/examples/events/project.toml
+++ b/examples/events/project.toml
@@ -2,19 +2,19 @@ kind = "Project"
name = "events"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.event_a]
-def = { path = "./event_a.toml" }
+ref = "./event_a.toml"
[nodes.event_b]
-def = { path = "./event_b.toml" }
+ref = "./event_b.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/fast/project.toml b/examples/fast/project.toml
index e53c393aa..eba897aa7 100644
--- a/examples/fast/project.toml
+++ b/examples/fast/project.toml
@@ -2,16 +2,16 @@ kind = "Project"
name = "fast"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.texture]
-def = { path = "./texture.toml" }
+ref = "./texture.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/fluid/project.toml b/examples/fluid/project.toml
index 7c5a87a97..908835147 100644
--- a/examples/fluid/project.toml
+++ b/examples/fluid/project.toml
@@ -2,16 +2,16 @@ kind = "Project"
name = "fluid"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.compute]
-def = { path = "./compute.toml" }
+ref = "./compute.toml"
[nodes.fluid]
-def = { path = "./fluid.toml" }
+ref = "./fluid.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/fyeah-button/playlist.toml b/examples/fyeah-button/playlist.toml
index c31a6a23f..13944ff34 100644
--- a/examples/fyeah-button/playlist.toml
+++ b/examples/fyeah-button/playlist.toml
@@ -8,13 +8,13 @@ source = "bus#time.seconds"
[entries.1]
name = "attract"
fade_after = 0.12
-node = { def = { path = "./attract.toml" } }
+node = { ref = "./attract.toml" }
[entries.2]
name = "blast"
duration = 6.0
fade_after = 2.0
-node = { def = { path = "./blast.toml" } }
+node = { ref = "./blast.toml" }
[entries.2.bindings.trigger]
source = "bus#trigger"
diff --git a/examples/fyeah-button/project.toml b/examples/fyeah-button/project.toml
index 0a8672824..cfb51da21 100644
--- a/examples/fyeah-button/project.toml
+++ b/examples/fyeah-button/project.toml
@@ -2,19 +2,19 @@ kind = "Project"
name = "fyeah-button"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.button]
-def = { path = "./button.toml" }
+ref = "./button.toml"
[nodes.radio]
-def = { path = "./radio.toml" }
+ref = "./radio.toml"
[nodes.playlist]
-def = { path = "./playlist.toml" }
+ref = "./playlist.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/fyeah-sign/playlist.toml b/examples/fyeah-sign/playlist.toml
index d053f58e9..63c2e2c10 100644
--- a/examples/fyeah-sign/playlist.toml
+++ b/examples/fyeah-sign/playlist.toml
@@ -8,13 +8,13 @@ source = "bus#time.seconds"
[entries.1]
name = "idle"
fade_after = 0.12
-node = { def = { path = "./idle.toml" } }
+node = { ref = "./idle.toml" }
[entries.2]
name = "blast"
duration = 6.0
fade_after = 2.0
-node = { def = { path = "./blast.toml" } }
+node = { ref = "./blast.toml" }
[entries.2.bindings.trigger]
source = "bus#trigger"
diff --git a/examples/fyeah-sign/project.toml b/examples/fyeah-sign/project.toml
index 2c6694e02..2749647be 100644
--- a/examples/fyeah-sign/project.toml
+++ b/examples/fyeah-sign/project.toml
@@ -2,19 +2,19 @@ kind = "Project"
name = "fyeah-sign"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.button]
-def = { path = "./button.toml" }
+ref = "./button.toml"
[nodes.radio]
-def = { path = "./radio.toml" }
+ref = "./radio.toml"
[nodes.playlist]
-def = { path = "./playlist.toml" }
+ref = "./playlist.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/perf/baseline/project.toml b/examples/perf/baseline/project.toml
index fd7b5678e..240c4859e 100644
--- a/examples/perf/baseline/project.toml
+++ b/examples/perf/baseline/project.toml
@@ -2,16 +2,16 @@ kind = "Project"
name = "perf-baseline"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.texture]
-def = { path = "./texture.toml" }
+ref = "./texture.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/perf/fastmath/project.toml b/examples/perf/fastmath/project.toml
index ec882dc0c..6438f31ec 100644
--- a/examples/perf/fastmath/project.toml
+++ b/examples/perf/fastmath/project.toml
@@ -2,16 +2,16 @@ kind = "Project"
name = "perf-fastmath"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.texture]
-def = { path = "./texture.toml" }
+ref = "./texture.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/examples/rocaille/project.toml b/examples/rocaille/project.toml
index dd00ff15a..d3cc45fd1 100644
--- a/examples/rocaille/project.toml
+++ b/examples/rocaille/project.toml
@@ -2,16 +2,16 @@ kind = "Project"
name = "rocaille"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.texture]
-def = { path = "./texture.toml" }
+ref = "./texture.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
diff --git a/justfile b/justfile
index d1731d571..cf4b59fb4 100644
--- a/justfile
+++ b/justfile
@@ -28,6 +28,10 @@ install-rv32-target:
echo "Target {{ rv32_target }} already installed"; \
fi
+# Pin the nightly toolchain + ABI-coupled `unwinding` in lockstep and validate (date defaults to today UTC; see docs/toolchain-notes.md)
+bump-nightly date="":
+ scripts/bump-nightly.sh {{ date }}
+
# Generate builtin boilerplate code
generate-builtins:
cargo run --bin lps-builtins-gen-app -p lps-builtins-gen-app
diff --git a/lp-app/lpa-client/src/client.rs b/lp-app/lpa-client/src/client.rs
index af76d866b..4ac0bc95e 100644
--- a/lp-app/lpa-client/src/client.rs
+++ b/lp-app/lpa-client/src/client.rs
@@ -5,7 +5,11 @@
use anyhow::{Error, Result};
use lpc_model::{LpPath, LpPathBuf};
use lpc_wire::{
- ProjectReadRequest, ProjectReadResponse, WireProjectHandle as ProjectHandle, WireServerMessage,
+ ProjectReadRequest, ProjectReadResponse, WireOverlayCommitRequest, WireOverlayCommitResponse,
+ WireOverlayMutationRequest, WireOverlayMutationResponse, WireOverlayReadRequest,
+ WireOverlayReadResponse, WireProjectCommand, WireProjectCommandResponse,
+ WireProjectHandle as ProjectHandle, WireProjectInventoryReadRequest,
+ WireProjectInventoryReadResponse, WireServerMessage,
messages::{ClientMessage, ClientRequest},
server::{AvailableProject, FsResponse, LoadedProject, ServerMsgBody},
};
@@ -443,6 +447,98 @@ impl LpClient {
.await
}
+ pub async fn project_command(
+ &self,
+ handle: ProjectHandle,
+ command: WireProjectCommand,
+ ) -> Result {
+ let request = ClientRequest::ProjectCommand { handle, command };
+ let response = self.send_request(request).await?;
+ match response.msg {
+ ServerMsgBody::ProjectCommand { response } => Ok(response),
+ _ => Err(Error::msg(format!(
+ "Unexpected response type for project_command: {:?}",
+ response.msg
+ ))),
+ }
+ }
+
+ pub async fn project_overlay_read(
+ &self,
+ handle: ProjectHandle,
+ ) -> Result {
+ match self
+ .project_command(
+ handle,
+ WireProjectCommand::ReadOverlay {
+ request: WireOverlayReadRequest,
+ },
+ )
+ .await?
+ {
+ WireProjectCommandResponse::ReadOverlay { response } => Ok(response),
+ other => Err(Error::msg(format!(
+ "Unexpected project command response for overlay read: {other:?}"
+ ))),
+ }
+ }
+
+ pub async fn project_overlay_mutate(
+ &self,
+ handle: ProjectHandle,
+ request: WireOverlayMutationRequest,
+ ) -> Result {
+ match self
+ .project_command(handle, WireProjectCommand::MutateOverlay { request })
+ .await?
+ {
+ WireProjectCommandResponse::MutateOverlay { response } => Ok(response),
+ other => Err(Error::msg(format!(
+ "Unexpected project command response for overlay mutate: {other:?}"
+ ))),
+ }
+ }
+
+ pub async fn project_overlay_commit(
+ &self,
+ handle: ProjectHandle,
+ ) -> Result {
+ match self
+ .project_command(
+ handle,
+ WireProjectCommand::CommitOverlay {
+ request: WireOverlayCommitRequest,
+ },
+ )
+ .await?
+ {
+ WireProjectCommandResponse::CommitOverlay { response } => Ok(response),
+ other => Err(Error::msg(format!(
+ "Unexpected project command response for overlay commit: {other:?}"
+ ))),
+ }
+ }
+
+ pub async fn project_inventory_read(
+ &self,
+ handle: ProjectHandle,
+ ) -> Result {
+ match self
+ .project_command(
+ handle,
+ WireProjectCommand::ReadInventory {
+ request: WireProjectInventoryReadRequest,
+ },
+ )
+ .await?
+ {
+ WireProjectCommandResponse::ReadInventory { response } => Ok(response),
+ other => Err(Error::msg(format!(
+ "Unexpected project command response for inventory read: {other:?}"
+ ))),
+ }
+ }
+
/// List available projects on the server filesystem
///
/// # Returns
diff --git a/lp-app/lpa-server/Cargo.toml b/lp-app/lpa-server/Cargo.toml
index 6568a9680..1a389032c 100644
--- a/lp-app/lpa-server/Cargo.toml
+++ b/lp-app/lpa-server/Cargo.toml
@@ -12,6 +12,7 @@ default = ["std"]
panic-recovery = ["lpc-engine/panic-recovery"]
naga = ["lpc-engine/naga"]
std = [
+ "lpc-hardware/std",
"lpc-shared/std",
"lpc-engine/std",
"lpc-wire/std",
@@ -21,7 +22,9 @@ std = [
[dependencies]
lpc-engine = { path = "../../lp-core/lpc-engine", default-features = false }
+lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false }
lpc-model = { path = "../../lp-core/lpc-model", default-features = false }
+lpc-registry = { path = "../../lp-core/lpc-registry", default-features = false }
lpc-wire = { path = "../../lp-core/lpc-wire", default-features = false }
lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false }
lpfs = { path = "../../lp-base/lpfs", default-features = false }
@@ -30,7 +33,9 @@ hashbrown = { workspace = true }
log = { workspace = true, default-features = false }
[dev-dependencies]
+lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false, features = ["std"] }
lpc-engine = { path = "../../lp-core/lpc-engine", default-features = false, features = ["std"] }
+lpc-registry = { path = "../../lp-core/lpc-registry", default-features = false, features = ["std"] }
lpc-view = { path = "../../lp-core/lpc-view", default-features = false, features = ["std"] }
lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false, features = ["std"] }
lpfs = { path = "../../lp-base/lpfs", default-features = false, features = ["std"] }
diff --git a/lp-app/lpa-server/src/handlers.rs b/lp-app/lpa-server/src/handlers.rs
index 03a4d52e2..3c2c6a9ae 100644
--- a/lp-app/lpa-server/src/handlers.rs
+++ b/lp-app/lpa-server/src/handlers.rs
@@ -13,7 +13,8 @@ use lpc_shared::backtrace;
use lpc_shared::output::OutputProvider;
use lpc_shared::time::TimeProvider;
use lpc_wire::{
- WireServerMessage, WireServerMsgBody as ServerMessagePayload,
+ WireProjectCommand, WireProjectCommandResponse, WireServerMessage,
+ WireServerMsgBody as ServerMessagePayload,
messages::ClientMessage,
server::{AvailableProject, FsRequest, FsResponse},
};
@@ -70,6 +71,11 @@ pub fn handle_client_message(
"ProjectRequest must be handled by streaming transport".into(),
));
}
+ lpc_wire::ClientRequest::ProjectCommand { handle, command } => {
+ ServerMessagePayload::ProjectCommand {
+ response: handle_project_command(project_manager, handle, command)?,
+ }
+ }
lpc_wire::ClientRequest::ListAvailableProjects => {
handle_list_available_projects(project_manager, base_fs)?
}
@@ -84,6 +90,39 @@ pub fn handle_client_message(
Ok(WireServerMessage { id, msg: response })
}
+fn handle_project_command(
+ project_manager: &mut ProjectManager,
+ handle: lpc_wire::WireProjectHandle,
+ command: WireProjectCommand,
+) -> Result {
+ let project = project_manager
+ .get_project_mut(handle)
+ .ok_or_else(|| ServerError::ProjectNotFound(format!("handle {}", handle.id())))?;
+
+ match command {
+ WireProjectCommand::ReadOverlay { request: _ } => {
+ Ok(WireProjectCommandResponse::ReadOverlay {
+ response: project.read_overlay(),
+ })
+ }
+ WireProjectCommand::MutateOverlay { request } => {
+ Ok(WireProjectCommandResponse::MutateOverlay {
+ response: project.mutate_overlay(request)?,
+ })
+ }
+ WireProjectCommand::CommitOverlay { request } => {
+ Ok(WireProjectCommandResponse::CommitOverlay {
+ response: project.commit_overlay(request)?,
+ })
+ }
+ WireProjectCommand::ReadInventory { request: _ } => {
+ Ok(WireProjectCommandResponse::ReadInventory {
+ response: project.read_inventory(),
+ })
+ }
+ }
+}
+
/// Handle a filesystem request
fn handle_fs_request(fs: &mut dyn LpFs, request: FsRequest) -> Result {
match request {
diff --git a/lp-app/lpa-server/src/project.rs b/lp-app/lpa-server/src/project.rs
index 11fd234a2..156ffa452 100644
--- a/lp-app/lpa-server/src/project.rs
+++ b/lp-app/lpa-server/src/project.rs
@@ -7,12 +7,18 @@ use crate::server::MemoryStatsFn;
use alloc::{boxed::Box, format, rc::Rc, string::String, sync::Arc};
use core::cell::RefCell;
use lpc_engine::{ButtonService, Engine, EngineServices, LpGraphics, ProjectLoader, RadioService};
-use lpc_model::{LpPath, LpPathBuf, TreePath};
+use lpc_hardware::HwEndpointSpec;
+use lpc_model::{LpPath, LpPathBuf, TreePath, current_revision};
+use lpc_registry::{ParseCtx, ProjectRegistry};
use lpc_shared::backtrace;
-use lpc_shared::hardware::HardwareEndpointSpec;
use lpc_shared::output::{OutputChannelHandle, OutputDriverOptions, OutputFormat, OutputProvider};
use lpc_shared::time::TimeProvider;
-use lpfs::{FsVersion, LpFs};
+use lpc_wire::{
+ ProjectReadRequest, ProjectReadResponse, WireOverlayCommitRequest, WireOverlayCommitResponse,
+ WireOverlayMutationRequest, WireOverlayMutationResponse, WireOverlayReadResponse,
+ WireProjectInventoryReadResponse,
+};
+use lpfs::{FsEvent, FsVersion, LpFs};
/// A project instance wrapping one loaded engine.
pub struct Project {
@@ -22,18 +28,20 @@ pub struct Project {
path: LpPathBuf,
/// Chrooted filesystem for this project.
fs: Rc>,
- /// Shared output provider used when rebuilding engine services.
+ /// Shared output provider used by engine services and manual recovery reloads.
output_provider: Rc>,
- /// Shared time provider used when rebuilding engine services.
+ /// Shared time provider used by engine services and manual recovery reloads.
time_provider: Option>,
- /// Shared button service used when rebuilding engine services.
+ /// Shared button service used by engine services and manual recovery reloads.
button_service: Option>,
- /// Shared radio service used when rebuilding engine services.
+ /// Shared radio service used by engine services and manual recovery reloads.
radio_service: Option>,
/// Optional memory stats callback for project load/reload checkpoints.
memory_stats: Option,
/// Graphics backend used by shader runtime nodes.
graphics: Arc,
+ /// Canonical project registry: artifacts, overlay, effective defs/assets.
+ registry: ProjectRegistry,
/// The loaded project engine.
runtime: Option,
/// Last filesystem version processed by this project
@@ -62,20 +70,21 @@ impl Project {
let root_path = project_root_path(&name)?;
log_memory(memory_stats, "project new after root path");
backtrace::set_oom_context("project new: engine services");
- let mut services = EngineServices::new(root_path);
- services.set_output_provider(Some(Box::new(SharedOutputProvider(
+ let services = build_engine_services(
+ root_path,
output_provider.clone(),
- ))));
- services.set_time_provider(time_provider.clone());
- services.set_button_service(button_service.clone());
- services.set_radio_service(radio_service.clone());
+ time_provider.clone(),
+ button_service.clone(),
+ radio_service.clone(),
+ );
log_memory(memory_stats, "project new after services");
backtrace::set_oom_context("project new: load core project");
- let mut runtime = {
+ let (mut runtime, registry) = {
let fs_ref = fs.borrow();
ProjectLoader::load_from_root(&*fs_ref, services)
.map_err(|e| ServerError::Core(format!("Failed to load core project: {e}")))?
+ .into_parts()
};
log_memory(memory_stats, "project new after core project");
backtrace::set_oom_context("project new: set graphics");
@@ -93,6 +102,7 @@ impl Project {
radio_service,
memory_stats,
graphics,
+ registry,
runtime: Some(runtime),
last_fs_version: loaded_fs_version.next(),
};
@@ -125,7 +135,121 @@ impl Project {
.expect("project runtime is only absent while reloading")
}
- /// Reload the project from the filesystem.
+ pub fn registry(&self) -> &ProjectRegistry {
+ &self.registry
+ }
+
+ pub fn registry_mut(&mut self) -> &mut ProjectRegistry {
+ &mut self.registry
+ }
+
+ pub(crate) fn runtime_read_parts(&mut self) -> (&mut Engine, &ProjectRegistry) {
+ let runtime = self
+ .runtime
+ .as_mut()
+ .expect("project runtime is only absent while reloading");
+ (runtime, &self.registry)
+ }
+
+ pub fn tick(&mut self, delta_ms: u32) -> Result<(), ServerError> {
+ let registry = &self.registry;
+ let runtime = self
+ .runtime
+ .as_mut()
+ .expect("project runtime is only absent while reloading");
+ runtime
+ .tick(registry, delta_ms)
+ .map_err(|e| ServerError::Core(format!("{e}")))
+ }
+
+ pub fn read_project(&mut self, request: ProjectReadRequest) -> ProjectReadResponse {
+ let registry = &self.registry;
+ let runtime = self
+ .runtime
+ .as_mut()
+ .expect("project runtime is only absent while reloading");
+ runtime.read_project(registry, request)
+ }
+
+ pub fn read_overlay(&self) -> WireOverlayReadResponse {
+ WireOverlayReadResponse::new(self.registry.overlay().get().clone())
+ }
+
+ pub fn read_inventory(&self) -> WireProjectInventoryReadResponse {
+ let index = self.engine().project_runtime_index();
+ WireProjectInventoryReadResponse::from_inventory_with_runtime_ids(
+ self.registry.inventory(),
+ |use_location| index.node_id(use_location),
+ )
+ }
+
+ pub fn mutate_overlay(
+ &mut self,
+ request: WireOverlayMutationRequest,
+ ) -> Result {
+ let frame = current_revision();
+ let shapes = self.engine().slot_shapes().clone();
+ let ctx = ParseCtx { shapes: &shapes };
+ let result = {
+ let fs_ref = self.fs.borrow();
+ self.registry
+ .mutate_batch(&*fs_ref, request.batch, frame, &ctx)
+ };
+ {
+ let fs_ref = self.fs.borrow();
+ self.runtime
+ .as_mut()
+ .expect("project runtime is only absent while reloading")
+ .apply_project_changes(&*fs_ref, &mut self.registry, &result.changes)
+ .map_err(|e| ServerError::Core(format!("apply project changes: {e}")))?;
+ }
+ Ok(WireOverlayMutationResponse::new(result.commands))
+ }
+
+ pub fn commit_overlay(
+ &mut self,
+ _request: WireOverlayCommitRequest,
+ ) -> Result {
+ let frame = current_revision();
+ let shapes = self.engine().slot_shapes().clone();
+ let ctx = ParseCtx { shapes: &shapes };
+ let (result, committed_fs_version) = {
+ let fs_ref = self.fs.borrow();
+ let result = self
+ .registry
+ .commit_overlay(&*fs_ref, frame, &ctx)
+ .map_err(|e| ServerError::Core(format!("commit overlay: {e:?}")))?;
+ (result, fs_ref.current_version())
+ };
+ self.last_fs_version = committed_fs_version.next();
+ Ok(WireOverlayCommitResponse::new(result))
+ }
+
+ pub fn refresh_artifacts(&mut self, events: &[FsEvent]) -> Result<(), ServerError> {
+ let frame = current_revision();
+ let shapes = self.engine().slot_shapes().clone();
+ let ctx = ParseCtx { shapes: &shapes };
+ let changes = {
+ let fs_ref = self.fs.borrow();
+ self.registry
+ .refresh_artifacts(&*fs_ref, events, frame, &ctx)
+ };
+ {
+ let fs_ref = self.fs.borrow();
+ self.runtime
+ .as_mut()
+ .expect("project runtime is only absent while reloading")
+ .apply_project_changes(&*fs_ref, &mut self.registry, &changes)
+ .map_err(|e| ServerError::Core(format!("apply project changes: {e}")))?;
+ }
+ Ok(())
+ }
+
+ /// Manually reload the registry and runtime from durable artifacts.
+ ///
+ /// Normal overlay mutation and filesystem refresh paths use incremental
+ /// registry-driven apply. This is a recovery path for callers that want to
+ /// discard live runtime state and rebuild from the committed filesystem.
pub fn reload(&mut self) -> Result<(), ServerError> {
log_memory(self.memory_stats, "project reload start");
backtrace::set_oom_context("project reload: drop old runtime");
@@ -135,24 +259,26 @@ impl Project {
let root_path = project_root_path(&self.name)?;
log_memory(self.memory_stats, "project reload after root path");
backtrace::set_oom_context("project reload: engine services");
- let mut services = EngineServices::new(root_path);
- services.set_output_provider(Some(Box::new(SharedOutputProvider(
+ let services = build_engine_services(
+ root_path,
self.output_provider.clone(),
- ))));
- services.set_time_provider(self.time_provider.clone());
- services.set_button_service(self.button_service.clone());
- services.set_radio_service(self.radio_service.clone());
+ self.time_provider.clone(),
+ self.button_service.clone(),
+ self.radio_service.clone(),
+ );
log_memory(self.memory_stats, "project reload after services");
backtrace::set_oom_context("project reload: load core project");
- let mut runtime = {
+ let (mut runtime, registry) = {
let fs_ref = self.fs.borrow();
ProjectLoader::load_from_root(&*fs_ref, services)
.map_err(|e| ServerError::Core(format!("Failed to reload core project: {e}")))?
+ .into_parts()
};
log_memory(self.memory_stats, "project reload after core project");
backtrace::set_oom_context("project reload: set graphics");
runtime.set_graphics(Some(self.graphics.clone()));
+ self.registry = registry;
self.runtime = Some(runtime);
log_memory(self.memory_stats, "project reload after swap");
backtrace::clear_oom_context();
@@ -182,16 +308,31 @@ fn log_memory(memory_stats: Option, label: &str) {
}
}
+fn build_engine_services(
+ root_path: TreePath,
+ output_provider: Rc>,
+ time_provider: Option>,
+ button_service: Option>,
+ radio_service: Option>,
+) -> EngineServices {
+ let mut services = EngineServices::new(root_path);
+ services.set_output_provider(Some(Box::new(SharedOutputProvider(output_provider))));
+ services.set_time_provider(time_provider);
+ services.set_button_service(button_service);
+ services.set_radio_service(radio_service);
+ services
+}
+
struct SharedOutputProvider(Rc>);
impl OutputProvider for SharedOutputProvider {
fn open(
&self,
- endpoint: &HardwareEndpointSpec,
+ endpoint: &HwEndpointSpec,
byte_count: u32,
format: OutputFormat,
options: Option,
- ) -> Result {
+ ) -> Result {
self.0.borrow().open(endpoint, byte_count, format, options)
}
@@ -199,11 +340,11 @@ impl OutputProvider for SharedOutputProvider {
&self,
handle: OutputChannelHandle,
data: &[u16],
- ) -> Result<(), lpc_shared::error::OutputError> {
+ ) -> Result<(), lpc_hardware::OutputError> {
self.0.borrow().write(handle, data)
}
- fn close(&self, handle: OutputChannelHandle) -> Result<(), lpc_shared::error::OutputError> {
+ fn close(&self, handle: OutputChannelHandle) -> Result<(), lpc_hardware::OutputError> {
self.0.borrow().close(handle)
}
}
diff --git a/lp-app/lpa-server/src/project_read_source.rs b/lp-app/lpa-server/src/project_read_source.rs
index 2cbf233f5..d030d2da3 100644
--- a/lp-app/lpa-server/src/project_read_source.rs
+++ b/lp-app/lpa-server/src/project_read_source.rs
@@ -1,134 +1,54 @@
//! Server-decorated project-read source.
-extern crate alloc;
-
-use alloc::vec::Vec;
-use lpc_engine::Engine;
+use lpc_engine::EngineProjectReadSource;
use lpc_shared::transport::ProjectReadJsonSource;
-use lpc_wire::json::json_write::JsonWrite;
-use lpc_wire::json::json_writer::{JsonWriter, JsonWriterError};
-use lpc_wire::{
- ProjectProbeRequest, ProjectReadQuery, ProjectReadResult, RuntimeReadQuery,
- ServerRuntimeStatus, WireSlotMutationRequest, WireSlotMutationResponse, WireSlotMutationResult,
- write_project_read_result_json,
-};
+use lpc_wire::ServerRuntimeStatus;
+
+use crate::project::Project;
/// Project-read source that adds server-loop status to runtime queries.
pub(crate) struct ServerProjectReadSource<'a> {
- engine: &'a mut Engine,
- server_status: Option,
+ source: EngineProjectReadSource<'a>,
}
impl<'a> ServerProjectReadSource<'a> {
- pub(crate) fn new(engine: &'a mut Engine, server_status: Option) -> Self {
+ pub(crate) fn new(
+ project: &'a mut Project,
+ server_status: Option,
+ ) -> Self {
+ let (engine, registry) = project.runtime_read_parts();
Self {
- engine,
- server_status,
+ source: EngineProjectReadSource::with_server_status(engine, registry, server_status),
}
}
}
impl ProjectReadJsonSource for ServerProjectReadSource<'_> {
fn project_read_revision(&self) -> lpc_model::Revision {
- self.engine.revision()
- }
-
- fn apply_project_mutations(
- &mut self,
- mutations: Vec,
- ) -> Vec {
- log_project_mutations(&mutations);
- let responses = self.engine.mutate_project_slots(mutations);
- log_project_mutation_responses(&responses);
- responses
+ self.source.project_read_revision()
}
fn write_project_read_result_json(
&mut self,
since: Option,
- query: ProjectReadQuery,
+ query: lpc_wire::ProjectReadQuery,
out: W,
- ) -> Result>
+ ) -> Result>
where
- W: JsonWrite,
+ W: lpc_wire::json::json_write::JsonWrite,
{
- match query {
- ProjectReadQuery::Runtime(query) => self.write_project_runtime_result_json(query, out),
- other => ProjectReadJsonSource::write_project_read_result_json(
- self.engine,
- since,
- other,
- out,
- ),
- }
+ self.source
+ .write_project_read_result_json(since, query, out)
}
fn write_project_probe_result_json(
&mut self,
- probe: ProjectProbeRequest,
- out: W,
- ) -> Result>
- where
- W: JsonWrite,
- {
- ProjectReadJsonSource::write_project_probe_result_json(self.engine, probe, out)
- }
-}
-
-fn log_project_mutations(mutations: &[WireSlotMutationRequest]) {
- if mutations.is_empty() {
- return;
- }
- log::info!("received {} project slot mutation(s)", mutations.len());
- for mutation in mutations {
- log::info!(
- "slot mutation id={} root={} path={} op={:?}",
- mutation.id.id(),
- mutation.root,
- mutation.path,
- mutation.op,
- );
- log::info!(
- "slot mutation id={} expected shape_rev={} data_rev={}",
- mutation.id.id(),
- mutation.expected_shape_version.0,
- mutation.expected_data_version.0,
- );
- }
-}
-
-fn log_project_mutation_responses(responses: &[WireSlotMutationResponse]) {
- for response in responses {
- match &response.result {
- WireSlotMutationResult::Accepted => {
- log::info!("slot mutation id={} accepted", response.id.id());
- }
- WireSlotMutationResult::Rejected(rejection) => {
- log::warn!(
- "slot mutation id={} rejected: {:?}",
- response.id.id(),
- rejection,
- );
- }
- }
- }
-}
-
-impl ServerProjectReadSource<'_> {
- fn write_project_runtime_result_json(
- &mut self,
- query: RuntimeReadQuery,
+ probe: lpc_wire::ProjectProbeRequest,
out: W,
- ) -> Result>
+ ) -> Result>
where
- W: JsonWrite,
+ W: lpc_wire::json::json_write::JsonWrite,
{
- let result = ProjectReadResult::Runtime(
- self.engine
- .read_project_runtime(query, self.server_status.clone()),
- );
- let mut writer = JsonWriter::new(out);
- write_project_read_result_json(&mut writer, &result)?;
- Ok(writer.into_inner())
+ self.source.write_project_probe_result_json(probe, out)
}
}
diff --git a/lp-app/lpa-server/src/server.rs b/lp-app/lpa-server/src/server.rs
index b12804cb4..9dead4797 100644
--- a/lp-app/lpa-server/src/server.rs
+++ b/lp-app/lpa-server/src/server.rs
@@ -16,7 +16,7 @@ use lpc_shared::output::OutputProvider;
use lpc_shared::time::TimeProvider;
use lpc_shared::transport::ServerTransport;
use lpc_wire::{ClientRequest, WireMessage, WireServerMessage};
-use lpfs::{FsChange, LpFs};
+use lpfs::{FsEvent, LpFs};
/// Optional callback returning (free_bytes, used_bytes) for memory logging.
/// Platforms without heap stats (e.g. fw-emu) pass `None`.
@@ -160,7 +160,7 @@ impl LpServer {
);
// Collect changes per project
- let mut project_changes_map: HashMap<_, Vec> = HashMap::new();
+ let mut project_changes_map: HashMap<_, Vec> = HashMap::new();
for (handle, project_path) in &project_info {
if let Some(project) = self.project_manager.get_project(*handle) {
@@ -178,14 +178,14 @@ impl LpServer {
// Build project prefix path using join - ensure it ends with /
let project_prefix_buf = LpPathBuf::from("/").join(project_path.as_str()).join("");
let project_prefix = project_prefix_buf.as_str();
- let project_changes: Vec = base_changes
+ let project_changes: Vec = base_changes
.into_iter()
.filter_map(|change| {
// Use LpPath to strip prefix and normalize
if let Some(stripped) = change.path.strip_prefix(project_prefix) {
- Some(FsChange {
+ Some(FsEvent {
path: stripped.to_path_buf(),
- change_type: change.change_type,
+ kind: change.kind,
})
} else {
None
@@ -199,23 +199,20 @@ impl LpServer {
}
}
- // Get current_version AFTER collecting all changes but BEFORE processing
- // This represents the version that will be assigned to the NEXT change
- // So all changes we're about to process have versions < current_version
+ // Capture the next version before refresh applies anything. The events
+ // in this batch are all older than this marker.
let current_version = self.base_fs().current_version();
// Now apply changes to projects (mutable borrows)
- for (handle, _project_changes) in project_changes_map {
+ for (handle, project_changes) in project_changes_map {
if let Some(project) = self.project_manager.get_project_mut(handle) {
- if let Err(_e) = project.reload() {
+ if let Err(_e) = project.refresh_artifacts(&project_changes) {
// Log error but continue with other projects
// Note: In no_std context, errors are silently ignored
// Errors will be visible when clients read project state.
} else {
- // Update last processed version to current_version.next() (one more than the next version)
- // This ensures that get_changes_since(current_version.next()) will return nothing next time
- // because get_changes_since uses >=, and current_version.next() is beyond all changes we processed
- // All changes we processed have versions < current_version, so >= current_version.next() returns nothing
+ // Advance past the batch marker so the same events are not
+ // returned again by get_changes_since, which is inclusive.
project.update_fs_version(current_version.next());
}
}
@@ -235,7 +232,7 @@ impl LpServer {
);
// Ignore errors and continue with other projects
// Errors will be visible when clients sync or query project state
- match project.engine_mut().tick(delta_ms) {
+ match project.tick(delta_ms) {
Ok(()) => {
log::trace!("LpServer::tick: Project {} tick succeeded", project.name());
}
@@ -309,10 +306,8 @@ impl LpServer {
response_count += 1;
continue;
};
- let mut source = ServerProjectReadSource::new(
- project.engine_mut(),
- Some(server_status),
- );
+ let mut source =
+ ServerProjectReadSource::new(project, Some(server_status));
transport
.send_project_read(msg_id, handle, &mut source, request)
.await
diff --git a/lp-app/lpa-server/src/template.rs b/lp-app/lpa-server/src/template.rs
index dcc53cdef..4ea636d6b 100644
--- a/lp-app/lpa-server/src/template.rs
+++ b/lp-app/lpa-server/src/template.rs
@@ -16,19 +16,19 @@ use lpfs::LpFs;
const PROJECT_TOML: &[u8] = br#"kind = "Project"
[nodes.output]
-def = { path = "./output.toml" }
+ref = "./output.toml"
[nodes.clock]
-def = { path = "./clock.toml" }
+ref = "./clock.toml"
[nodes.texture]
-def = { path = "./texture.toml" }
+ref = "./texture.toml"
[nodes.shader]
-def = { path = "./shader.toml" }
+ref = "./shader.toml"
[nodes.fixture]
-def = { path = "./fixture.toml" }
+ref = "./fixture.toml"
"#;
/// TOML for the default clock node.
diff --git a/lp-app/lpa-server/tests/project_fs_refresh.rs b/lp-app/lpa-server/tests/project_fs_refresh.rs
new file mode 100644
index 000000000..e85880171
--- /dev/null
+++ b/lp-app/lpa-server/tests/project_fs_refresh.rs
@@ -0,0 +1,168 @@
+extern crate alloc;
+
+use alloc::format;
+use alloc::rc::Rc;
+use alloc::sync::Arc;
+use core::cell::RefCell;
+
+use lpa_server::{Graphics, LpGraphics, LpServer, Project};
+use lpc_model::{
+ ArtifactLocation, AsLpPath, LpPathBuf, LpValue, MutationCmd, MutationCmdBatch, MutationCmdId,
+ MutationOp, NodeDef, NodeDefLocation, NodeUseLocation, SlotEdit, SlotPath,
+};
+use lpc_shared::output::MemoryOutputProvider;
+use lpc_wire::{WireOverlayCommitRequest, WireOverlayMutationRequest};
+use lpfs::LpFsMemory;
+
+#[test]
+fn server_tick_refreshes_referenced_artifact_without_recreating_runtime_node() {
+ let (mut server, project_path) = server_with_clock_project("fs-refresh");
+ let handle = server.load_project(project_path.as_path()).expect("load");
+ let before_id = clock_runtime_node_id(project(&server, handle));
+
+ server
+ .base_fs_mut()
+ .write_file(
+ project_file("fs-refresh", "clock.toml").as_path(),
+ clock_toml_with_rate(2.0).as_bytes(),
+ )
+ .expect("write clock");
+
+ server.advance_frame(16).expect("tick");
+
+ let project = project(&server, handle);
+ assert_eq!(clock_rate(project), 2.0);
+ assert_eq!(clock_runtime_node_id(project), before_id);
+}
+
+#[test]
+fn overlay_commit_does_not_echo_as_external_fs_change() {
+ let (mut server, project_path) = server_with_clock_project("commit-echo");
+ let handle = server.load_project(project_path.as_path()).expect("load");
+ let before_id = clock_runtime_node_id(project(&server, handle));
+
+ let version_after_commit = {
+ let project = server
+ .project_manager_mut()
+ .get_project_mut(handle)
+ .expect("project");
+ project
+ .mutate_overlay(WireOverlayMutationRequest::new(MutationCmdBatch::new(
+ vec![MutationCmd {
+ id: MutationCmdId::new(1),
+ mutation: MutationOp::PutSlotEdit {
+ artifact: ArtifactLocation::file("/clock.toml"),
+ edit: SlotEdit::assign_value(
+ SlotPath::parse("controls.rate").expect("rate path"),
+ LpValue::F32(3.0),
+ ),
+ },
+ }],
+ )))
+ .expect("mutate overlay");
+ assert_eq!(clock_rate(project), 3.0);
+ assert_eq!(clock_runtime_node_id(project), before_id);
+
+ project
+ .commit_overlay(WireOverlayCommitRequest)
+ .expect("commit overlay");
+ project.last_fs_version()
+ };
+
+ assert!(
+ server
+ .base_fs()
+ .get_changes_since(version_after_commit)
+ .is_empty()
+ );
+
+ server.advance_frame(16).expect("tick");
+
+ let project = project(&server, handle);
+ assert_eq!(project.last_fs_version(), version_after_commit);
+ assert_eq!(clock_rate(project), 3.0);
+ assert_eq!(clock_runtime_node_id(project), before_id);
+}
+
+fn server_with_clock_project(name: &str) -> (LpServer, LpPathBuf) {
+ let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new()));
+ let graphics: Arc = Arc::new(Graphics::new());
+ let mut server = LpServer::new(
+ output_provider,
+ Box::new(LpFsMemory::new()),
+ "projects".as_path(),
+ None,
+ None,
+ graphics,
+ );
+ let project_path = LpPathBuf::from("/projects").join(name);
+
+ server
+ .base_fs_mut()
+ .write_file(
+ project_file(name, "project.toml").as_path(),
+ br#"
+kind = "Project"
+
+[nodes.clock]
+ref = "./clock.toml"
+"#,
+ )
+ .expect("write project");
+ server
+ .base_fs_mut()
+ .write_file(
+ project_file(name, "clock.toml").as_path(),
+ clock_toml_with_rate(1.0).as_bytes(),
+ )
+ .expect("write clock");
+
+ (server, project_path)
+}
+
+fn project<'a>(server: &'a LpServer, handle: lpc_wire::WireProjectHandle) -> &'a Project {
+ server
+ .project_manager()
+ .get_project(handle)
+ .expect("loaded project")
+}
+
+fn clock_runtime_node_id(project: &Project) -> lpc_model::NodeId {
+ project
+ .engine()
+ .project_runtime_index()
+ .node_id(&clock_use())
+ .expect("clock runtime node")
+}
+
+fn clock_rate(project: &Project) -> f32 {
+ let entry = project
+ .registry()
+ .def(&NodeDefLocation::artifact_root(ArtifactLocation::file(
+ "/clock.toml",
+ )))
+ .expect("clock definition");
+ let NodeDef::Clock(def) = entry.state.loaded_def().expect("loaded clock") else {
+ panic!("expected clock definition");
+ };
+ *def.controls.rate.value()
+}
+
+fn clock_use() -> NodeUseLocation {
+ NodeUseLocation::root().child(SlotPath::parse("nodes[clock]").expect("clock use path"))
+}
+
+fn project_file(project: &str, file: &str) -> LpPathBuf {
+ LpPathBuf::from("/projects").join(project).join(file)
+}
+
+fn clock_toml_with_rate(rate: f32) -> alloc::string::String {
+ format!(
+ r#"
+kind = "Clock"
+
+[controls]
+rate = {rate}
+"#
+ )
+}
diff --git a/lp-base/lp-collection/Cargo.toml b/lp-base/lp-collection/Cargo.toml
new file mode 100644
index 000000000..00c7e2802
--- /dev/null
+++ b/lp-base/lp-collection/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "lp-collection"
+version = "1.0.0"
+authors.workspace = true
+edition.workspace = true
+license = "Apache-2.0 WITH LLVM-exception"
+rust-version.workspace = true
+description = "Embedded/low-memory friendly collections: chunked structures plus sorted-vec map/set"
+
+[features]
+default = []
+serde = ["dep:serde"]
+
+[dependencies]
+rustc-hash = { version = "2.0.0", default-features = false }
+serde = { version = "1", default-features = false, features = ["alloc"], optional = true }
+
+[dev-dependencies]
+serde_json = { version = "1", default-features = false, features = ["alloc"] }
diff --git a/lp-base/lp-collection/src/chunked_hashmap.rs b/lp-base/lp-collection/src/chunked_hashmap.rs
new file mode 100644
index 000000000..7c6066c9c
--- /dev/null
+++ b/lp-base/lp-collection/src/chunked_hashmap.rs
@@ -0,0 +1,577 @@
+//! Chunked hash map for allocation-sensitive contexts (e.g. embedded).
+//!
+//! Uses fixed 64 buckets, each backed by a ChunkedVec, to avoid large contiguous
+//! hash table allocations that cause OOM on fragmented heaps. Allocates in small
+//! chunks (16 entries per chunk) instead of one large SwissTable-style allocation.
+
+use core::hash::{Hash, Hasher};
+use rustc_hash::FxHasher;
+
+use crate::chunked_vec::ChunkedVec;
+
+/// Number of buckets. Fixed to avoid any resize allocation.
+const NUM_BUCKETS: usize = 12;
+
+/// Hash a key to a bucket index using FxHasher.
+#[inline]
+fn bucket_index(key: &Q) -> usize {
+ let mut hasher = FxHasher::default();
+ key.hash(&mut hasher);
+ (hasher.finish() as usize) % NUM_BUCKETS
+}
+
+/// A hash map backed by chunked allocations.
+///
+/// Uses 64 fixed buckets, each containing a ChunkedVec of (K, V) pairs.
+/// Collisions are resolved by linear scan within the bucket. Lookup is O(n/64)
+/// on average. No large contiguous allocation—each ChunkedVec grows in 16-entry
+/// chunks.
+#[derive(Debug)]
+pub struct ChunkedHashMap {
+ buckets: [ChunkedVec<(K, V)>; NUM_BUCKETS],
+ len: usize,
+}
+
+impl Default for ChunkedHashMap {
+ fn default() -> Self {
+ Self {
+ buckets: core::array::from_fn(|_| ChunkedVec::new()),
+ len: 0,
+ }
+ }
+}
+
+impl ChunkedHashMap {
+ /// Create an empty ChunkedHashMap.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Create with capacity hint. Currently a no-op; buckets allocate on demand.
+ pub fn with_capacity(_capacity: usize) -> Self {
+ Self::new()
+ }
+
+ /// Number of key-value pairs.
+ pub fn len(&self) -> usize {
+ self.len
+ }
+
+ /// Returns true if the map is empty.
+ pub fn is_empty(&self) -> bool {
+ self.len == 0
+ }
+
+ /// Inserts a key-value pair. Returns the previous value if the key existed.
+ pub fn insert(&mut self, key: K, value: V) -> Option
+ where
+ K: Eq + Hash,
+ {
+ let bi = bucket_index(&key);
+ let bucket = &mut self.buckets[bi];
+ for i in 0..bucket.len() {
+ if bucket.get(i).map(|(k, _)| k == &key).unwrap_or(false) {
+ let (_, v) = bucket.get_mut(i).unwrap();
+ return Some(core::mem::replace(v, value));
+ }
+ }
+ bucket.push((key, value));
+ self.len += 1;
+ None
+ }
+
+ /// Gets a reference to the value for the key.
+ pub fn get(&self, key: &Q) -> Option<&V>
+ where
+ K: Eq + Hash + core::borrow::Borrow,
+ Q: Hash + Eq + ?Sized,
+ {
+ let bi = bucket_index(key);
+ let bucket = &self.buckets[bi];
+ for i in 0..bucket.len() {
+ let (k, v) = bucket.get(i).unwrap();
+ if k.borrow() == key {
+ return Some(v);
+ }
+ }
+ None
+ }
+
+ /// Gets a mutable reference to the value for the key.
+ pub fn get_mut(&mut self, key: &Q) -> Option<&mut V>
+ where
+ K: Eq + Hash + core::borrow::Borrow,
+ Q: Hash + Eq + ?Sized,
+ {
+ let bi = bucket_index(key);
+ let bucket = &mut self.buckets[bi];
+ let mut found = None;
+ for i in 0..bucket.len() {
+ let (k, _) = bucket.get(i).unwrap();
+ if k.borrow() == key {
+ found = Some(i);
+ break;
+ }
+ }
+ match found {
+ Some(i) => {
+ let (_, v) = bucket.get_mut(i).unwrap();
+ Some(v)
+ }
+ None => None,
+ }
+ }
+
+ /// Returns true if the map contains the key.
+ pub fn contains_key(&self, key: &Q) -> bool
+ where
+ K: Eq + Hash + core::borrow::Borrow,
+ Q: Hash + Eq + ?Sized,
+ {
+ self.get(key).is_some()
+ }
+
+ /// Removes the key from the map, returning the previous value if present.
+ pub fn remove(&mut self, key: &Q) -> Option
+ where
+ K: Eq + Hash + core::borrow::Borrow,
+ Q: Hash + Eq + ?Sized,
+ {
+ let bi = bucket_index(key);
+ let bucket = &mut self.buckets[bi];
+ for i in 0..bucket.len() {
+ let (k, _) = bucket.get(i).unwrap();
+ if k.borrow() == key {
+ let (_, v) = bucket.swap_remove(i).unwrap();
+ self.len -= 1;
+ return Some(v);
+ }
+ }
+ None
+ }
+
+ /// Gets the entry for the given key.
+ pub fn entry(&mut self, key: K) -> Entry<'_, K, V>
+ where
+ K: Eq + Hash,
+ {
+ let bi = bucket_index(&key);
+ let bucket = &mut self.buckets[bi];
+ for i in 0..bucket.len() {
+ let (k, _) = bucket.get(i).unwrap();
+ if k == &key {
+ return Entry::Occupied(OccupiedEntry {
+ map: self,
+ bucket_index: bi,
+ slot_index: i,
+ });
+ }
+ }
+ Entry::Vacant(VacantEntry {
+ map: self,
+ key,
+ bucket_index: bi,
+ })
+ }
+
+ /// Retains only the entries for which the predicate returns true.
+ pub fn retain(&mut self, mut f: F)
+ where
+ K: Eq + Hash,
+ F: FnMut(&K, &mut V) -> bool,
+ {
+ for bucket in &mut self.buckets {
+ let mut i = 0;
+ while i < bucket.len() {
+ let keep = {
+ let (k, v) = bucket.get_mut(i).unwrap();
+ f(k, v)
+ };
+ if !keep {
+ let _ = bucket.swap_remove(i);
+ self.len -= 1;
+ } else {
+ i += 1;
+ }
+ }
+ }
+ }
+
+ /// Returns an iterator over (key, value) references.
+ pub fn iter(&self) -> impl Iterator- {
+ self.buckets.iter().flat_map(|b| {
+ (0..b.len()).map(move |i| {
+ let (k, v) = b.get(i).unwrap();
+ (k, v)
+ })
+ })
+ }
+}
+
+/// A view into a single entry in a map, which may be vacant or occupied.
+pub enum Entry<'a, K, V> {
+ /// An occupied entry.
+ Occupied(OccupiedEntry<'a, K, V>),
+ /// A vacant entry.
+ Vacant(VacantEntry<'a, K, V>),
+}
+
+impl<'a, K: Eq + Hash, V> Entry<'a, K, V> {
+ /// Ensures a value is in the entry by inserting the default if vacant.
+ pub fn or_insert(self, default: V) -> &'a mut V {
+ match self {
+ Entry::Occupied(o) => o.into_mut(),
+ Entry::Vacant(v) => v.insert(default),
+ }
+ }
+
+ /// Ensures a value is in the entry by inserting the result of the closure if vacant.
+ pub fn or_insert_with
(self, default: F) -> &'a mut V
+ where
+ F: FnOnce() -> V,
+ {
+ match self {
+ Entry::Occupied(o) => o.into_mut(),
+ Entry::Vacant(v) => v.insert(default()),
+ }
+ }
+}
+
+/// A view into an occupied entry in a ChunkedHashMap.
+pub struct OccupiedEntry<'a, K, V> {
+ map: &'a mut ChunkedHashMap,
+ bucket_index: usize,
+ slot_index: usize,
+}
+
+impl<'a, K, V> OccupiedEntry<'a, K, V> {
+ /// Gets a reference to the value.
+ pub fn get(&self) -> &V {
+ let (_, v) = self.map.buckets[self.bucket_index]
+ .get(self.slot_index)
+ .unwrap();
+ v
+ }
+
+ /// Gets a mutable reference to the value.
+ pub fn get_mut(&mut self) -> &mut V {
+ let (_, v) = self.map.buckets[self.bucket_index]
+ .get_mut(self.slot_index)
+ .unwrap();
+ v
+ }
+
+ /// Consumes the entry and returns a mutable reference to the value.
+ /// Used by or_insert to return a ref with the map's lifetime.
+ fn into_mut(self) -> &'a mut V {
+ let (_, v) = self.map.buckets[self.bucket_index]
+ .get_mut(self.slot_index)
+ .unwrap();
+ v
+ }
+
+ /// Sets the value of the entry, returning the old value.
+ pub fn insert(&mut self, value: V) -> V {
+ let (_, v) = self.map.buckets[self.bucket_index]
+ .get_mut(self.slot_index)
+ .unwrap();
+ core::mem::replace(v, value)
+ }
+}
+
+/// A view into a vacant entry in a ChunkedHashMap.
+pub struct VacantEntry<'a, K, V> {
+ map: &'a mut ChunkedHashMap,
+ key: K,
+ bucket_index: usize,
+}
+
+impl<'a, K, V> VacantEntry<'a, K, V> {
+ /// Sets the value of the entry with the vacant entry's key.
+ pub fn insert(self, value: V) -> &'a mut V {
+ self.map.buckets[self.bucket_index].push((self.key, value));
+ self.map.len += 1;
+ let (_, v) = self.map.buckets[self.bucket_index]
+ .get_mut(self.map.buckets[self.bucket_index].len() - 1)
+ .unwrap();
+ v
+ }
+
+ /// Sets the value of the entry with the vacant entry's key, or inserts with default.
+ pub fn or_insert(self, default: V) -> &'a mut V
+ where
+ K: Eq + Hash,
+ {
+ self.insert(default)
+ }
+
+ /// Sets the value of the entry with the vacant entry's key, or inserts with the result of the closure.
+ pub fn or_insert_with(self, default: F) -> &'a mut V
+ where
+ K: Eq + Hash,
+ F: FnOnce() -> V,
+ {
+ self.insert(default())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alloc::vec::Vec;
+
+ use super::*;
+
+ fn chunked_hashmap() -> ChunkedHashMap {
+ ChunkedHashMap::new()
+ }
+
+ #[test]
+ fn empty_len() {
+ let m: ChunkedHashMap = chunked_hashmap();
+ assert_eq!(m.len(), 0);
+ }
+
+ #[test]
+ fn default_empty() {
+ let m: ChunkedHashMap = ChunkedHashMap::default();
+ assert_eq!(m.len(), 0);
+ }
+
+ #[test]
+ fn insert_and_get() {
+ let mut m = chunked_hashmap();
+ assert_eq!(m.get(&1), None);
+ m.insert(1, 10);
+ assert_eq!(m.get(&1), Some(&10));
+ assert_eq!(m.get(&2), None);
+ }
+
+ #[test]
+ fn insert_overwrite() {
+ let mut m = chunked_hashmap();
+ assert_eq!(m.insert(1, 10), None);
+ assert_eq!(m.insert(1, 20), Some(10));
+ assert_eq!(m.get(&1), Some(&20));
+ }
+
+ #[test]
+ fn contains_key() {
+ let mut m = chunked_hashmap();
+ assert!(!m.contains_key(&1));
+ m.insert(1, 10);
+ assert!(m.contains_key(&1));
+ assert!(!m.contains_key(&2));
+ }
+
+ #[test]
+ fn entry_vacant_insert() {
+ let mut m = chunked_hashmap();
+ m.entry(1).or_insert(10);
+ assert_eq!(m.get(&1), Some(&10));
+ }
+
+ #[test]
+ fn entry_vacant_or_insert_with() {
+ let mut m = chunked_hashmap();
+ m.entry(1).or_insert_with(|| 42);
+ assert_eq!(m.get(&1), Some(&42));
+ }
+
+ #[test]
+ fn entry_occupied_get() {
+ let mut m = chunked_hashmap();
+ m.insert(1, 10);
+ match m.entry(1) {
+ Entry::Occupied(o) => assert_eq!(*o.get(), 10),
+ Entry::Vacant(_) => panic!("expected occupied"),
+ }
+ }
+
+ #[test]
+ fn entry_occupied_insert() {
+ let mut m = chunked_hashmap();
+ m.insert(1, 10);
+ match m.entry(1) {
+ Entry::Occupied(mut o) => {
+ assert_eq!(o.insert(20), 10);
+ }
+ Entry::Vacant(_) => panic!("expected occupied"),
+ }
+ assert_eq!(m.get(&1), Some(&20));
+ }
+
+ #[test]
+ fn iter_yields_all_pairs() {
+ let mut m = chunked_hashmap();
+ m.insert(1, 10);
+ m.insert(2, 20);
+ m.insert(3, 30);
+ let mut pairs: Vec<_> = m.iter().map(|(k, v)| (*k, *v)).collect();
+ pairs.sort_by_key(|(k, _)| *k);
+ assert_eq!(pairs, [(1, 10), (2, 20), (3, 30)]);
+ }
+
+ #[test]
+ fn retain_removes_matching() {
+ let mut m = chunked_hashmap();
+ m.insert(1, 10);
+ m.insert(2, 20);
+ m.insert(3, 30);
+ m.retain(|k, _| *k != 2);
+ assert_eq!(m.len(), 2);
+ assert_eq!(m.get(&1), Some(&10));
+ assert_eq!(m.get(&2), None);
+ assert_eq!(m.get(&3), Some(&30));
+ }
+
+ #[test]
+ fn many_insertions_no_rehash() {
+ let mut m = chunked_hashmap();
+ for i in 0..600 {
+ m.insert(i, i * 2);
+ }
+ assert_eq!(m.len(), 600);
+ for i in 0..600 {
+ assert_eq!(m.get(&i), Some(&(i * 2)));
+ }
+ }
+
+ #[test]
+ fn collisions_same_bucket() {
+ let mut m: ChunkedHashMap = chunked_hashmap();
+ for i in 0..200 {
+ let k = i * 64;
+ m.insert(k, i);
+ }
+ assert_eq!(m.len(), 200);
+ for i in 0..200 {
+ assert_eq!(m.get(&(i * 64)), Some(&i));
+ }
+ }
+
+ #[test]
+ fn string_keys() {
+ let mut m: ChunkedHashMap = chunked_hashmap();
+ m.insert(alloc::string::String::from("foo"), 1);
+ m.insert(alloc::string::String::from("bar"), 2);
+ assert_eq!(m.get("foo"), Some(&1));
+ assert_eq!(m.get("bar"), Some(&2));
+ }
+
+ #[test]
+ fn tuple_keys() {
+ let mut m: ChunkedHashMap<(i32, i32), i32> = chunked_hashmap();
+ m.insert((1, 2), 10);
+ m.insert((3, 4), 20);
+ assert_eq!(m.get(&(1, 2)), Some(&10));
+ assert_eq!(m.get(&(3, 4)), Some(&20));
+ }
+
+ #[test]
+ fn drop_cleans_up() {
+ let mut m: ChunkedHashMap> = chunked_hashmap();
+ for i in 0..300 {
+ m.insert(i, (0..100).collect());
+ }
+ drop(m);
+ }
+}
+
+// --- ChunkedHashSet ---
+
+/// Hash set backed by ChunkedHashMap; no large contiguous allocations.
+#[derive(Debug)]
+pub struct ChunkedHashSet {
+ map: ChunkedHashMap,
+}
+
+impl Default for ChunkedHashSet {
+ fn default() -> Self {
+ Self {
+ map: ChunkedHashMap::default(),
+ }
+ }
+}
+
+impl ChunkedHashSet {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn insert(&mut self, key: K) -> bool
+ where
+ K: Eq + Hash,
+ {
+ self.map.insert(key, ()).is_none()
+ }
+
+ pub fn contains(&self, key: &Q) -> bool
+ where
+ K: Eq + Hash + core::borrow::Borrow,
+ Q: Hash + Eq + ?Sized,
+ {
+ self.map.contains_key(key)
+ }
+
+ pub fn len(&self) -> usize {
+ self.map.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.map.is_empty()
+ }
+
+ pub fn iter(&self) -> impl Iterator- {
+ self.map.iter().map(|(k, _)| k)
+ }
+}
+
+impl
From<[K; N]> for ChunkedHashSet
+where
+ K: Eq + Hash,
+{
+ fn from(arr: [K; N]) -> Self {
+ let mut set = ChunkedHashSet::new();
+ for k in arr {
+ set.insert(k);
+ }
+ set
+ }
+}
+
+impl