From 7b53ad3812d2b36c9326f516ded6133e723fb5d4 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 12:57:33 -0700 Subject: [PATCH 1/2] refactor: SlotRole replaces SlotPolicy across model, registry, macros, studio Replace the two-axis SlotPolicy { writable, persistence } with a single SlotRole::{ Setting, Fixed, Debug } enum (D10), and retire the read_only_transient marking entirely: produced-state protection now derives from direction=Produced (D1), which also fixes the previously unmarked TextureState by construction and deletes the studio-side view-build synthesis patch. Writer omission and overlay commit retention key off role == Debug. Shape dumps carry "role" instead of "policy"; wire proto and project format unchanged. No behavior change intended; schemas regenerated via just schema-gen. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P1) Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-core/README.md | 2 +- .../src/app/project/project_controller.rs | 39 ++- .../src/app/project/slot/slot_controller.rs | 80 +++-- .../src/app/studio/studio_edit_e2e_tests.rs | 4 +- .../app/project/project_settings_section.rs | 2 +- .../lpa-studio-web/src/app/story_fixtures.rs | 2 +- lp-cli/src/debug_ui/slot_edit.rs | 6 +- lp-cli/src/debug_ui/slot_render.rs | 47 +-- .../src/nodes/fixture/fixture_node.rs | 2 +- .../src/nodes/shader/compute_shader_state.rs | 2 +- lp-core/lpc-model/src/lib.rs | 26 +- .../lpc-model/src/nodes/button/button_def.rs | 1 - .../src/nodes/clock/clock_controls.rs | 32 +- .../lpc-model/src/nodes/clock/clock_state.rs | 1 - .../src/nodes/fixture/fixture_state.rs | 1 - .../lpc-model/src/nodes/fluid/fluid_state.rs | 1 - .../src/nodes/playlist/playlist_state.rs | 1 - .../src/nodes/project/project_def.rs | 20 +- .../src/nodes/radio/control_radio_def.rs | 1 - .../src/nodes/shader/shader_state.rs | 1 - .../src/nodes/texture/texture_state.rs | 33 ++ .../lpc-model/src/project/node_attach_site.rs | 2 +- lp-core/lpc-model/src/slot/mod.rs | 14 +- .../lpc-model/src/slot/slot_persistence.rs | 54 +++- lp-core/lpc-model/src/slot/slot_policy.rs | 94 ------ .../lpc-model/src/slot/slot_policy_lookup.rs | 239 -------------- lp-core/lpc-model/src/slot/slot_role.rs | 99 ++++++ .../lpc-model/src/slot/slot_role_lookup.rs | 295 ++++++++++++++++++ lp-core/lpc-model/src/slot/slot_shape.rs | 29 +- .../lpc-model/src/slot/slot_shape_builder.rs | 20 +- lp-core/lpc-model/src/slot/slot_shape_view.rs | 17 +- .../lpc-model/src/slot/static_slot_shape.rs | 12 +- .../src/slot_codec/dynamic_slot_writer.rs | 34 +- lp-core/lpc-registry/README.md | 8 +- .../src/registry/node_authoring.rs | 16 +- .../src/registry/project_registry.rs | 80 +++-- lp-core/lpc-slot-macros/src/attr.rs | 68 ++-- lp-core/lpc-slot-macros/src/slotted_enum.rs | 2 +- lp-core/lpc-slot-macros/src/slotted_record.rs | 27 +- lp-core/lpc-view/src/slot/mirror.rs | 6 +- ...l.nodes.button.button_def.ButtonState.json | 12 - ..._model.nodes.clock.clock_def.ClockDef.json | 12 +- ...el.nodes.clock.clock_state.ClockState.json | 8 - ...es.fixture.fixture_state.FixtureState.json | 16 - ...el.nodes.fluid.fluid_state.FluidState.json | 4 - ...playlist.playlist_state.PlaylistState.json | 16 - ....nodes.project.project_def.ProjectDef.json | 12 +- ...o.control_radio_def.ControlRadioState.json | 4 - ...nodes.shader.shader_state.ShaderState.json | 4 - ...es.texture.texture_state.TextureState.json | 9 + 50 files changed, 800 insertions(+), 717 deletions(-) delete mode 100644 lp-core/lpc-model/src/slot/slot_policy.rs delete mode 100644 lp-core/lpc-model/src/slot/slot_policy_lookup.rs create mode 100644 lp-core/lpc-model/src/slot/slot_role.rs create mode 100644 lp-core/lpc-model/src/slot/slot_role_lookup.rs diff --git a/lp-app/lpa-studio-core/README.md b/lp-app/lpa-studio-core/README.md index 6659ef973..bbe6f69f0 100644 --- a/lp-app/lpa-studio-core/README.md +++ b/lp-app/lpa-studio-core/README.md @@ -287,7 +287,7 @@ stateless views that dispatch ops and render DTOs. The model (recorded in server-derived and mirrored beside the overlay (`ProjectSync:: base_value_at`, pruned to the overlay's paths). The project root's own slots ride `ProjectEditorView.root_slots` (rendered in the project pane, - not as a workspace card); `format`/`nodes` are `read_only_persisted`, + not as a workspace card); `format`/`nodes` carry role `Fixed`, `name` stays writable. **Asset bodies** (ADR D8) extend the same model to whole files diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 219a789fd..2b4f3ed25 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -27,14 +27,14 @@ use crate::{ UiPaneView, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, UiProductRef, UiResult, UiShaderError, UiShaderUniform, UiSlotAsset, UiStatus, UiViewContent, UxUpdateSink, }; -use lpc_model::slot::SlotPersistence; +use lpc_model::slot::{SlotPersistence, effective_persistence}; use lpc_model::{ ArtifactLocation, ArtifactSpec, AssetBodyOverlay, FromLpValue, MutationCmd, MutationCmdBatch, MutationCmdId, MutationCmdStatus, MutationEffect, MutationOp, MutationRejection, - NodeAttachSite, NodeId, NodeKind, NodeStarter, ShaderValueShapeRef, SlotEdit, SlotMapKey, - SlotPath, SlotPathSegment, SlotPolicy, SlotShapeId, SlotShapeLookup, SlotShapeRegistry, - TreePath, glsl_type_for_lp_type, resolve_artifact_specifier, resolve_slot_policy, - starter_for_kind, + NodeAttachSite, NodeId, NodeKind, NodeStarter, ShaderValueShapeRef, SlotDirection, SlotEdit, + SlotMapKey, SlotPath, SlotPathSegment, SlotRole, SlotShapeId, SlotShapeLookup, + SlotShapeRegistry, TreePath, glsl_type_for_lp_type, resolve_artifact_specifier, + resolve_slot_role, starter_for_kind, }; use lpc_view::ProjectView; use lpc_wire::{ @@ -1186,11 +1186,12 @@ impl ProjectController { } /// Classify the persistence governing an edit entry's path through the - /// retained shapes (`lpc_model::resolve_slot_policy`). The walk is + /// retained shapes (`lpc_model::resolve_slot_role`). The walk is /// shape-only, so it classifies paths with no surviving slot row — - /// removed map entries — exactly like paths that still have data. - /// Unresolvable entries (unknown node/shape/path) classify as the default - /// policy's bucket (persisted). + /// removed map entries — exactly like paths that still have data. A + /// produced field (e.g. under the `State` root) always classifies as + /// transient regardless of its role (D1). Unresolvable entries (unknown + /// node/shape/path) classify as the default role's bucket (persisted). fn resolve_edit_persistence(&self, address: &ProjectSlotAddress) -> SlotPersistence { self.node(&address.node) .and_then(|node| { @@ -1198,10 +1199,13 @@ impl ProjectController { let shape = self .slot_shapes .get_shape(*self.root_shape_ids.get(&key)?)?; - let policy = resolve_slot_policy(shape, &self.slot_shapes, &address.path)?; - Some(policy.persistence) + let resolution = resolve_slot_role(shape, &self.slot_shapes, &address.path)?; + Some(effective_persistence(resolution.role, resolution.direction)) }) - .unwrap_or(SlotPolicy::default().persistence) + .unwrap_or(effective_persistence( + SlotRole::default(), + SlotDirection::default(), + )) } /// Entry keys of `artifact`'s `entries` map that carry pending overlay @@ -4885,8 +4889,8 @@ mod tests { use lpc_model::{ ControlExtent, ControlProduct, LpType, LpValue, NodeId, ProductKind, ProductRef, Revision, SlotData, SlotEnum, SlotEnumEncoding, SlotFieldShape, SlotMapDyn, SlotMapKey, - SlotMapKeyShape, SlotMeta, SlotName, SlotOptionDyn, SlotPath, SlotRecord, SlotShape, - SlotShapeId, SlotVariantShape, TreePath, VisualProduct, WithRevision, + SlotMapKeyShape, SlotMeta, SlotName, SlotOptionDyn, SlotPath, SlotRecord, SlotRole, + SlotShape, SlotShapeId, SlotVariantShape, TreePath, VisualProduct, WithRevision, }; use lpc_view::{ProjectView, TreeEntryView}; use lpc_wire::{ @@ -7812,8 +7816,9 @@ mod tests { } /// A ready project with an applied view whose def root has a persisted - /// `brightness` (default policy) and a transient `rate` control, plus the - /// def-artifact map a connect-time inventory read would have installed. + /// `brightness` (default role) and a `Debug`-role `rate` control, plus + /// the def-artifact map a connect-time inventory read would have + /// installed. fn editable_project_with_scripted_client( responses: Vec, ) -> ( @@ -7843,7 +7848,7 @@ mod tests { view.slots.registry = Default::default(); let def_shape = SlotShapeId::new(500); let mut rate = SlotFieldShape::new("rate", SlotShape::value(LpType::F32)).unwrap(); - rate.policy = lpc_model::SlotPolicy::writable_transient(); + rate.role = SlotRole::Debug; view.slots .registry .register_dynamic_shape( diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs index b5de5b0e9..166b2d214 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs @@ -1,9 +1,11 @@ use std::collections::BTreeMap; -use lpc_model::slot::{SlotFieldShapeView, SlotPersistence}; +use lpc_model::slot::{ + SlotFieldShapeView, SlotPersistence, effective_persistence, effective_writable, +}; use lpc_model::{ LpType, LpValue, ProductRef, Revision, SlotData, SlotDirection, SlotMapKey, SlotMapKeyShape, - SlotName, SlotPath, SlotPathSegment, SlotPolicy, SlotSemantics, SlotShapeLookup, + SlotName, SlotPath, SlotPathSegment, SlotRole, SlotSemantics, SlotShapeLookup, SlotShapeRegistry, SlotShapeView, SlotValueShape, SlotValueShapeView, ValueEditorHint, }; @@ -77,7 +79,7 @@ impl Default for SlotControllerState { /// Slot controllers are recursive. Containers and leaves both get controllers /// so future editing, binding, validation, and expansion state have stable /// addressable homes. Each controller also retains the latest mirror-derived -/// value, shape, semantics, and policy facts needed to project node DTOs without +/// value, shape, semantics, and role facts needed to project node DTOs without /// walking the project mirror a second time. #[derive(Clone, Debug, PartialEq)] pub struct SlotController { @@ -87,7 +89,7 @@ pub struct SlotController { body: SlotControllerBody, revision: Option, semantics: SlotSemantics, - policy: SlotPolicy, + role: SlotRole, value_shape: Option, source: UiSlotSourceState, publish: Option, @@ -155,7 +157,7 @@ impl SlotController { body: SlotControllerBody::Issue, revision: None, semantics: context.semantics, - policy: context.policy, + role: context.role, value_shape: None, source: UiSlotSourceState::Direct, publish: None, @@ -929,30 +931,49 @@ impl SlotController { fn context(&self) -> SlotApplyContext { SlotApplyContext { semantics: self.semantics, - policy: self.policy, + role: self.role, } } fn apply_context(&mut self, context: SlotApplyContext) { self.semantics = context.semantics; - self.policy = context.policy; + self.role = context.role; + } + + /// Whether a client may request mutation of this slot: the role allows + /// it and the slot's direction is not produced (D1 — direction implies + /// read-only regardless of role). + fn is_writable(&self) -> bool { + effective_writable(self.role, self.semantics.direction) + } + + /// Whether this slot is "live": a runtime/session value that ordinary + /// save/writeback never persists (a `Debug`-role field, or any produced + /// field regardless of role). + fn is_live(&self) -> bool { + effective_persistence(self.role, self.semantics.direction) == SlotPersistence::Transient } + /// Context for one record field, inheriting from the parent's ambient + /// context (`self.context()`) when the field declares neither its own + /// semantics nor its own role, else taking the field's own declared + /// values directly. Either way the result is safe by construction: a + /// produced field is always effectively read-only and live regardless of + /// its role (D1, [`Self::is_writable`]/[`Self::is_live`]), so no field + /// ever needs a role override to compensate for its direction — a state + /// field that leaves everything default inherits `Produced` from the + /// `State` root's ambient context ([`SlotApplyContext::for_root`]) and a + /// field that declares `#[slot(produced)]` itself carries it directly. fn field_context(&self, field: SlotFieldShapeView<'_>) -> SlotApplyContext { let semantics = field.semantics(); - let policy = field.policy(); + let role = field.role(); let default_semantics = semantics == SlotSemantics::default(); - let default_policy = policy == SlotPolicy::default(); - let mut context = - if self.address.root == ProjectSlotRoot::State && default_semantics && default_policy { - self.context() - } else { - SlotApplyContext { semantics, policy } - }; - if context.semantics.direction == SlotDirection::Produced && default_policy { - context.policy = SlotPolicy::read_only_transient(); + let default_role = role == SlotRole::default(); + if self.address.root == ProjectSlotRoot::State && default_semantics && default_role { + self.context() + } else { + SlotApplyContext { semantics, role } } - context } fn ui_config_slot_body(&self, edits: &SlotEditJoin<'_>) -> UiConfigSlotBody { @@ -1104,9 +1125,9 @@ impl SlotController { return None; }; Some(if *present { - UiSlotOptionality::included(self.policy.writable) + UiSlotOptionality::included(self.is_writable()) } else { - UiSlotOptionality::excluded(self.policy.writable) + UiSlotOptionality::excluded(self.is_writable()) }) } @@ -1169,12 +1190,12 @@ impl SlotController { } fn ui_field_state(&self, edits: &SlotEditJoin<'_>) -> UiSlotFieldState { - let mut state = if self.policy.writable { + let mut state = if self.is_writable() { UiSlotFieldState::editable() } else { UiSlotFieldState::readonly() }; - state = state.with_live(self.policy.persistence == SlotPersistence::Transient); + state = state.with_live(self.is_live()); // Join order: edit buffer (Saving/Error + invalid reason), then the // overlay mirror (Dirty), then — for composite slots only — the @@ -1398,23 +1419,30 @@ impl SlotChildApply<'_> { #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct SlotApplyContext { semantics: SlotSemantics, - policy: SlotPolicy, + role: SlotRole, } impl SlotApplyContext { + /// Ambient context for a slot root, inherited by any field beneath it + /// that declares neither its own semantics nor its own role (see + /// [`SlotController::field_context`]). The `State` root's ambient + /// direction is `Produced`, so any state field that leaves both + /// undeclared still presents read-only and live: [`effective_writable`] + /// and [`effective_persistence`] fold direction in regardless of role + /// (D1 — direction implies the constraint, not a stored role). fn for_root(root: &ProjectSlotRoot) -> Self { match root { ProjectSlotRoot::Def => Self { semantics: SlotSemantics::local(), - policy: SlotPolicy::writable_persisted(), + role: SlotRole::Setting, }, ProjectSlotRoot::State => Self { semantics: SlotSemantics::produced(), - policy: SlotPolicy::read_only_transient(), + role: SlotRole::Setting, }, ProjectSlotRoot::Other(_) => Self { semantics: SlotSemantics::local(), - policy: SlotPolicy::read_only_persisted(), + role: SlotRole::Fixed, }, } } diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs index 9a75b961c..b34367332 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs @@ -57,8 +57,8 @@ fn simulator_session_edit_save_and_revert_end_to_end() { // Flat-root workspace over the real wire: the project root renders no // card (the clock and fixture panes are the top-level entries) and the - // root's own slots ride `root_slots` with the `read_only_persisted` - // policy on `format`/`nodes` intact; `name` stays editable. + // root's own slots ride `root_slots` with the `Fixed` role + // on `format`/`nodes` intact; `name` stays editable. let editor = project_editor(&snapshot); assert_eq!(editor.nodes.len(), 2, "two child panes, no root card"); let root_slot = |path: &str| { diff --git a/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs b/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs index ccd8c46fc..a8388cbb6 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs @@ -20,7 +20,7 @@ //! node tree, which is the pane's whole body; repeating it as a slot //! editor in the popup was noise. //! -//! `uid`, `format`, and `nodes` are all `read_only_persisted` in +//! `uid`, `format`, and `nodes` all carry role `Fixed` in //! `lpc_model::ProjectDef`, so the read-only presentation here agrees with //! the model rather than merely hiding a writable slot. diff --git a/lp-app/lpa-studio-web/src/app/story_fixtures.rs b/lp-app/lpa-studio-web/src/app/story_fixtures.rs index 54e7184ce..64ba9316f 100644 --- a/lp-app/lpa-studio-web/src/app/story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/story_fixtures.rs @@ -636,7 +636,7 @@ pub(crate) fn project_workspace_nodes() -> Vec { /// The project root's own config rows for the project popup's settings /// section: `name` editable, `format`/`uid`/`nodes` read-only — matching -/// the `read_only_persisted` policy each carries on `lpc_model::ProjectDef` +/// the `Fixed` role each carries on `lpc_model::ProjectDef` /// (`uid` joined them 2026-07-28; it had been writable by default). pub(crate) fn project_root_slots() -> Vec { use lpa_studio_core::UiSlotFieldState; diff --git a/lp-cli/src/debug_ui/slot_edit.rs b/lp-cli/src/debug_ui/slot_edit.rs index 75f79f97f..05ddd5abe 100644 --- a/lp-cli/src/debug_ui/slot_edit.rs +++ b/lp-cli/src/debug_ui/slot_edit.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use eframe::egui; -use lpc_model::{LpValue, SlotPath, SlotPolicy, SlotValueShape, ValueEditorHint}; +use lpc_model::{LpValue, SlotPath, SlotValueShape, ValueEditorHint}; /// Stable UI key for a slot mutation target. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -67,10 +67,10 @@ pub(crate) fn render_slot_value_editor( root: &str, path: &SlotPath, shape: &SlotValueShape, - policy: SlotPolicy, + writable: bool, value: &LpValue, ) -> Option { - if !policy.writable { + if !writable { return None; } diff --git a/lp-cli/src/debug_ui/slot_render.rs b/lp-cli/src/debug_ui/slot_render.rs index d0e8bfab4..5d5d507b9 100644 --- a/lp-cli/src/debug_ui/slot_render.rs +++ b/lp-cli/src/debug_ui/slot_render.rs @@ -2,8 +2,8 @@ use eframe::egui; use lpc_model::{ - LpValue, ProductRef, SlotData, SlotMapKey, SlotName, SlotPath, SlotPolicy, SlotShape, - SlotShapeId, SlotShapeRegistry, SlotValueShape, + LpValue, ProductRef, SlotData, SlotMapKey, SlotName, SlotPath, SlotShape, SlotShapeId, + SlotShapeRegistry, SlotValueShape, }; use super::format::{ @@ -116,7 +116,7 @@ pub(crate) fn render_top_field_row( status: Option<&SlotEditStatusContext<'_>>, edit_intents: Option<&mut Vec>, ) -> bool { - let Some((shape, data, policy, path)) = + let Some((shape, data, writable, path)) = top_record_field_info(registry, shape_id, data, field_name) else { return false; @@ -126,7 +126,7 @@ pub(crate) fn render_top_field_row( registry, root.unwrap_or("root"), path, - policy, + writable, label, shape, data, @@ -154,7 +154,7 @@ fn top_record_field_info<'a>( shape_id: SlotShapeId, data: &'a SlotData, field_name: &str, -) -> Option<(&'a SlotShape, &'a SlotData, SlotPolicy, SlotPath)> { +) -> Option<(&'a SlotShape, &'a SlotData, bool, SlotPath)> { let shape = resolve_shape(registry, registry.get(&shape_id)?)?; let SlotShape::Record { fields, .. } = shape else { return None; @@ -170,7 +170,7 @@ fn top_record_field_info<'a>( Some(( &field.shape, child, - field.policy, + field.is_writable(), SlotPath::root().child(field.name.clone()), )) } @@ -252,7 +252,7 @@ fn render_slot_shape_rows( registry, root, child_path, - field.policy, + field.is_writable(), field.name.as_str(), &field.shape, child, @@ -269,7 +269,7 @@ fn render_slot_shape_rows( registry, root, path, - SlotPolicy::default(), + true, "value", shape, data, @@ -323,7 +323,7 @@ fn render_slot_shape_rows_filtered( registry, root, child_path, - field.policy, + field.is_writable(), field.name.as_str(), &field.shape, child, @@ -375,7 +375,7 @@ fn render_slot_shape_rows_filtered( registry, root, path, - SlotPolicy::default(), + true, "value", shape, data, @@ -393,7 +393,7 @@ fn render_named_slot_shape_row( registry: &SlotShapeRegistry, root: &str, path: SlotPath, - policy: SlotPolicy, + writable: bool, name: &str, shape: &SlotShape, data: &SlotData, @@ -418,7 +418,7 @@ fn render_named_slot_shape_row( registry, root, path, - policy, + writable, name, shape, data, @@ -440,7 +440,7 @@ fn render_named_slot_shape_row( ui, root, &path, - policy, + writable, depth, name, shape, @@ -465,7 +465,7 @@ fn render_named_slot_shape_row( registry, root, child_path, - field.policy, + field.is_writable(), field.name.as_str(), &field.shape, child, @@ -493,7 +493,7 @@ fn render_named_slot_shape_row( registry, root, child_path, - policy, + writable, &key_label, value, child, @@ -520,7 +520,7 @@ fn render_named_slot_shape_row( registry, root, child_path, - policy, + writable, value.variant.as_str(), &variant.shape, &value.data, @@ -558,7 +558,7 @@ fn render_named_slot_shape_row( registry, root, child_path, - policy, + writable, "some", some, child, @@ -593,7 +593,7 @@ fn render_value_row( ui: &mut egui::Ui, root: &str, path: &SlotPath, - policy: SlotPolicy, + writable: bool, depth: usize, name: &str, shape: &SlotValueShape, @@ -619,7 +619,7 @@ fn render_value_row( ui, root, path, - policy, + writable, shape, value, selection, @@ -637,7 +637,7 @@ fn render_value_row( ui, root, path, - policy, + writable, shape, value, selection.as_deref_mut(), @@ -655,7 +655,7 @@ fn render_value_cell( ui: &mut egui::Ui, root: &str, path: &SlotPath, - policy: SlotPolicy, + writable: bool, shape: &SlotValueShape, value: &LpValue, selection: Option<&mut Option>, @@ -669,9 +669,10 @@ fn render_value_cell( render_resource_skeleton(ui, *resource, selection); } _ => { - let rendered_editor = policy.writable && slot_value_editor_supported(shape, value); + let rendered_editor = writable && slot_value_editor_supported(shape, value); if rendered_editor { - if let Some(edited) = render_slot_value_editor(ui, root, path, shape, policy, value) + if let Some(edited) = + render_slot_value_editor(ui, root, path, shape, writable, value) && let Some(edit_intents) = edit_intents { edit_intents.push(SlotEditIntent { diff --git a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs index 243931508..9192e1649 100644 --- a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs +++ b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs @@ -537,7 +537,7 @@ fn ensure_path_exists_in_fixture_def_shape( let shape = shapes .get_shape(lpc_model::nodes::FixtureDef::SHAPE_ID) .ok_or_else(|| NodeError::msg("FixtureDef slot shape is not registered"))?; - if lpc_model::resolve_slot_policy(shape, shapes, slot).is_none() { + if lpc_model::resolve_slot_role(shape, shapes, slot).is_none() { return Err(NodeError::msg(alloc::format!( "fixture def path {slot} does not exist in the FixtureDef shape" ))); diff --git a/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs b/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs index cfb00a18a..8a60e5bd0 100644 --- a/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs +++ b/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs @@ -75,7 +75,7 @@ impl ComputeShaderState { .map_err(|e| ComputeStateError::InvalidSlotName(field.name.clone(), e))?, shape: shape_for_shader_slot(&field.slot, registry)?, semantics: lpc_model::SlotSemantics::produced(), - policy: Default::default(), + role: Default::default(), default_bind: None, }); } diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index d975cf7c3..c9d527efc 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -162,19 +162,19 @@ pub use slot::{ SlotMapDyn, SlotMapKey, SlotMapKeyShape, SlotMapValueAccessMut, SlotMapValueMutAccess, SlotMerge, SlotMeta, SlotMutAccess, SlotMutationError, SlotName, SlotNameError, SlotOptionAccess, SlotOptionAccessMut, SlotOptionDyn, SlotOptionMutAccess, SlotOptionReader, - SlotOwner, SlotPath, SlotPathError, SlotPathSegment, SlotPolicy, SlotPolicyResolution, - SlotReadContext, SlotRecord, SlotRecordAccess, SlotRecordAccessMut, SlotRecordMutAccess, - SlotRecordShape, SlotRef, SlotSemantics, SlotShape, SlotShapeEntry, SlotShapeId, - SlotShapeIdError, SlotShapeLookup, SlotShapeRegistry, SlotShapeRegistryError, - SlotShapeRegistrySnapshot, SlotShapeView, SlotValueAccess, SlotValueMut, SlotValueMutAccess, - SlotValueShapeView, SlotVariantShape, SlotVariantShapeView, SlottedEnum, SlottedEnumMut, - StaticLpType, StaticModelEnumVariant, StaticModelStructMember, StaticSlotAccess, - StaticSlotEnumEncoding, StaticSlotEnumOption, StaticSlotFieldShape, StaticSlotMeta, - StaticSlotShape, StaticSlotShapeDescriptor, StaticSlotValueShape, StaticSlotVariantShape, - StaticValueEditorHint, ValueRef, ValueSlot, create_dynamic_slot_data, ensure_slot_present, + SlotOwner, SlotPath, SlotPathError, SlotPathSegment, SlotReadContext, SlotRecord, + SlotRecordAccess, SlotRecordAccessMut, SlotRecordMutAccess, SlotRecordShape, SlotRef, SlotRole, + SlotRoleResolution, SlotSemantics, SlotShape, SlotShapeEntry, SlotShapeId, SlotShapeIdError, + SlotShapeLookup, SlotShapeRegistry, SlotShapeRegistryError, SlotShapeRegistrySnapshot, + SlotShapeView, SlotValueAccess, SlotValueMut, SlotValueMutAccess, SlotValueShapeView, + SlotVariantShape, SlotVariantShapeView, SlottedEnum, SlottedEnumMut, StaticLpType, + StaticModelEnumVariant, StaticModelStructMember, StaticSlotAccess, StaticSlotEnumEncoding, + StaticSlotEnumOption, StaticSlotFieldShape, StaticSlotMeta, StaticSlotShape, + StaticSlotShapeDescriptor, StaticSlotValueShape, StaticSlotVariantShape, StaticValueEditorHint, + ValueRef, ValueSlot, create_dynamic_slot_data, ensure_slot_present, insert_slot_map_entry_default, lookup_slot_data, lookup_slot_data_and_shape, - lookup_slot_data_mut, lp_value_matches_type, remove_slot_map_entry, resolve_slot_policy, - resolve_slot_policy_and_leaf, set_slot_option_none, set_slot_option_some_default, - set_slot_value, set_slot_variant_default, slot_data_revision, + lookup_slot_data_mut, lp_value_matches_type, remove_slot_map_entry, resolve_slot_role, + set_slot_option_none, set_slot_option_some_default, set_slot_value, set_slot_variant_default, + slot_data_revision, }; pub use value::value_path::ValuePath; diff --git a/lp-core/lpc-model/src/nodes/button/button_def.rs b/lp-core/lpc-model/src/nodes/button/button_def.rs index 37207a3c9..aea4b2a6a 100644 --- a/lp-core/lpc-model/src/nodes/button/button_def.rs +++ b/lp-core/lpc-model/src/nodes/button/button_def.rs @@ -47,7 +47,6 @@ impl ButtonDef { /// Runtime button state published to shader-compatible control maps. #[derive(Debug, Clone, Default, PartialEq, Slotted)] -#[slot(default_policy = "read_only_transient")] pub struct ButtonState { /// Present for one tick when the button transitions to pressed. #[slot(produced, map(key = "u32", value_ref = "lp::control::Message"))] diff --git a/lp-core/lpc-model/src/nodes/clock/clock_controls.rs b/lp-core/lpc-model/src/nodes/clock/clock_controls.rs index d338dc0fb..a8cb31411 100644 --- a/lp-core/lpc-model/src/nodes/clock/clock_controls.rs +++ b/lp-core/lpc-model/src/nodes/clock/clock_controls.rs @@ -2,7 +2,7 @@ use alloc::vec; use crate::{ FieldSlot, FieldSlotMut, LpType, OrderedF32, Revision, SlotDataAccess, SlotDataAccessMut, - SlotMapValueAccessMut, SlotMeta, SlotPolicy, SlotRecordAccess, SlotRecordAccessMut, SlotShape, + SlotMapValueAccessMut, SlotMeta, SlotRecordAccess, SlotRecordAccessMut, SlotRole, SlotShape, SlotShapeId, SlotValueShape, StaticLpType, StaticSlotFieldShape, StaticSlotMeta, StaticSlotShapeDescriptor, StaticSlotValueShape, StaticValueEditorHint, ValueEditorHint, ValueSlot, @@ -13,8 +13,9 @@ const FRAME_SECONDS_60HZ: f32 = 1.0 / 60.0; /// Transient user controls for the project clock. /// /// Clock controls live in authored node-def slot data so the UI can mutate them -/// through the same path as ordinary config. Their slot policy marks them as -/// writable and transient: they are runtime controls, not durable defaults. +/// through the same path as ordinary config. Their slot role marks them as +/// `Debug`: writable but never persisted, since they are runtime controls, +/// not durable defaults. #[derive(Debug, Clone, PartialEq)] pub struct ClockControls { pub running: ValueSlot, @@ -42,7 +43,7 @@ impl FieldSlot for ClockControls { name: "running", shape: running_shape, semantics: crate::SlotSemantics::local(), - policy: SlotPolicy::writable_transient(), + role: SlotRole::Debug, default_bind: None, }, StaticSlotFieldShape { @@ -51,7 +52,7 @@ impl FieldSlot for ClockControls { shape: static_clock_rate_shape(), }, semantics: crate::SlotSemantics::local(), - policy: SlotPolicy::writable_transient(), + role: SlotRole::Debug, default_bind: None, }, StaticSlotFieldShape { @@ -60,7 +61,7 @@ impl FieldSlot for ClockControls { shape: static_clock_scrub_offset_shape(), }, semantics: crate::SlotSemantics::local(), - policy: SlotPolicy::writable_transient(), + role: SlotRole::Debug, default_bind: None, }, ], @@ -72,20 +73,20 @@ impl FieldSlot for ClockControls { SlotShape::Record { meta: SlotMeta::empty(), fields: vec![ - crate::slot::shape::field_with_policy( + crate::slot::shape::field_with_role( "running", ValueSlot::::slot_field_shape(), - SlotPolicy::writable_transient(), + SlotRole::Debug, ), - crate::slot::shape::field_with_policy( + crate::slot::shape::field_with_role( "rate", SlotShape::leaf(clock_rate_shape()), - SlotPolicy::writable_transient(), + SlotRole::Debug, ), - crate::slot::shape::field_with_policy( + crate::slot::shape::field_with_role( "scrub_offset_seconds", SlotShape::leaf(clock_scrub_offset_shape()), - SlotPolicy::writable_transient(), + SlotRole::Debug, ), ], } @@ -197,17 +198,16 @@ const fn static_clock_scrub_offset_shape() -> StaticSlotValueShape { #[cfg(test)] mod tests { use super::*; - use crate::slot::SlotPersistence; #[test] - fn clock_controls_fields_are_writable_transient() { + fn clock_controls_fields_have_debug_role() { let SlotShape::Record { fields, .. } = ClockControls::slot_field_shape() else { panic!("record shape"); }; assert_eq!(fields.len(), 3); for field in fields { - assert!(field.policy.writable); - assert_eq!(field.policy.persistence, SlotPersistence::Transient); + assert_eq!(field.role, SlotRole::Debug); + assert!(field.is_writable()); } } } diff --git a/lp-core/lpc-model/src/nodes/clock/clock_state.rs b/lp-core/lpc-model/src/nodes/clock/clock_state.rs index 56bb5d3d3..ec6e72c6f 100644 --- a/lp-core/lpc-model/src/nodes/clock/clock_state.rs +++ b/lp-core/lpc-model/src/nodes/clock/clock_state.rs @@ -2,7 +2,6 @@ use crate::{Slotted, ValueSlot}; /// Runtime state exposed by the clock node. #[derive(Slotted)] -#[slot(default_policy = "read_only_transient")] pub struct ClockState { /// Clock time in seconds after rate and scrub offset are applied. #[slot(produced, default_bind = "bus:time")] diff --git a/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs b/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs index 51554643e..16dba1b2a 100644 --- a/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs +++ b/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs @@ -4,7 +4,6 @@ use crate::{ControlExtent, ControlProduct, ControlProductSlot, NodeId, Slotted, /// Runtime state exposed by a fixture node. #[derive(Slotted)] -#[slot(default_policy = "read_only_transient")] pub struct FixtureState { /// Renderable control output produced by this fixture node. #[slot(produced, default_bind = "bus:control.out")] diff --git a/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs b/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs index 17fb8c044..ebd2ff516 100644 --- a/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs +++ b/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs @@ -4,7 +4,6 @@ use crate::{Slotted, VisualProduct, VisualProductSlot}; /// Runtime state exposed by a fluid node. #[derive(Default, Slotted)] -#[slot(default_policy = "read_only_transient")] pub struct FluidState { /// Renderable visual output produced by this fluid node. #[slot(produced, default_bind = "bus:visual.out")] diff --git a/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs b/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs index 45fe64325..14b51d164 100644 --- a/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs +++ b/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs @@ -4,7 +4,6 @@ use crate::{Slotted, ValueSlot, VisualProduct, VisualProductSlot}; /// Runtime state exposed by a playlist node. #[derive(Default, Slotted)] -#[slot(default_policy = "read_only_transient")] pub struct PlaylistState { /// Renderable visual output produced by this playlist node. #[slot(produced, default_bind = "bus:visual.out")] diff --git a/lp-core/lpc-model/src/nodes/project/project_def.rs b/lp-core/lpc-model/src/nodes/project/project_def.rs index 52e6c0fc2..986618be2 100644 --- a/lp-core/lpc-model/src/nodes/project/project_def.rs +++ b/lp-core/lpc-model/src/nodes/project/project_def.rs @@ -27,7 +27,7 @@ pub struct ProjectDef { /// /// Read-only through mutations: only the loader format gate and the /// (future) offline upgrader own this value. - #[slot(policy = "read_only_persisted")] + #[slot(role = "fixed")] pub format: OptionSlot>, /// Stable project identity (`prj_…`, base-62), minted by the library /// when a project enters it. Travels with the files: parity checks, @@ -38,7 +38,7 @@ pub struct ProjectDef { /// with its source). Editing it in place would silently reassign a /// project's history and device associations, so no surface may offer /// it — the constraint lives here rather than in each view. - #[slot(policy = "read_only_persisted")] + #[slot(role = "fixed")] pub uid: OptionSlot>, /// Human-readable project name — the one authored field of the root's /// identity, and the Studio project pane's title. @@ -48,7 +48,7 @@ pub struct ProjectDef { /// Read-only through mutations: node create/remove will arrive as /// dedicated project operations (Studio authoring M2), never as raw /// slot edits under this map. - #[slot(policy = "read_only_persisted")] + #[slot(role = "fixed")] pub nodes: MapSlot, } @@ -160,22 +160,22 @@ mod tests { } #[test] - fn project_def_format_and_nodes_are_read_only_persisted_name_writable() { - use crate::{SlotPolicy, SlotShape, StaticSlotShape}; + fn project_def_format_and_nodes_are_fixed_name_is_setting() { + use crate::{SlotRole, SlotShape, StaticSlotShape}; let SlotShape::Record { fields, .. } = crate::ProjectDef::slot_shape() else { panic!("project def shape must be a record"); }; - let policy = |name: &str| { + let role = |name: &str| { fields .iter() .find(|field| field.name.as_str() == name) .unwrap_or_else(|| panic!("{name} field")) - .policy + .role }; - assert_eq!(policy("format"), SlotPolicy::read_only_persisted()); - assert_eq!(policy("nodes"), SlotPolicy::read_only_persisted()); - assert_eq!(policy("name"), SlotPolicy::writable_persisted()); + assert_eq!(role("format"), SlotRole::Fixed); + assert_eq!(role("nodes"), SlotRole::Fixed); + assert_eq!(role("name"), SlotRole::Setting); } fn registry() -> SlotShapeRegistry { diff --git a/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs b/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs index f99585b24..b97fd924b 100644 --- a/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs +++ b/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs @@ -59,7 +59,6 @@ impl ControlRadioDef { /// Runtime control radio state. #[derive(Debug, Clone, Default, PartialEq, Slotted)] -#[slot(default_policy = "read_only_transient")] pub struct ControlRadioState { /// Accepted local and remote control messages for this tick. #[slot(produced, map(key = "u32", value_ref = "lp::control::Message"))] diff --git a/lp-core/lpc-model/src/nodes/shader/shader_state.rs b/lp-core/lpc-model/src/nodes/shader/shader_state.rs index 0366d3f8a..a5e4720f0 100644 --- a/lp-core/lpc-model/src/nodes/shader/shader_state.rs +++ b/lp-core/lpc-model/src/nodes/shader/shader_state.rs @@ -4,7 +4,6 @@ use crate::{Slotted, VisualProduct, VisualProductSlot}; /// Runtime state exposed by a shader node. #[derive(Default, Slotted)] -#[slot(default_policy = "read_only_transient")] pub struct ShaderState { /// Renderable visual output produced by this shader node. #[slot(produced, default_bind = "bus:visual.out")] diff --git a/lp-core/lpc-model/src/nodes/texture/texture_state.rs b/lp-core/lpc-model/src/nodes/texture/texture_state.rs index e184fb339..02b63dfb0 100644 --- a/lp-core/lpc-model/src/nodes/texture/texture_state.rs +++ b/lp-core/lpc-model/src/nodes/texture/texture_state.rs @@ -5,8 +5,11 @@ use crate::{Revision, Slotted, ValueSlot}; /// Runtime metadata exposed by a texture node. #[derive(Default, Slotted)] pub struct TextureState { + #[slot(produced)] pub width: ValueSlot, + #[slot(produced)] pub height: ValueSlot, + #[slot(produced)] pub format: ValueSlot, } @@ -31,3 +34,33 @@ impl TextureState { self.format.set_with_version(revision, format); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SlotDirection, SlotShape, StaticSlotShape}; + + /// TextureState carries no explicit role on any field (unlike its sibling + /// state records, which used to need a container-wide + /// `read_only_transient` marking) — it is safe by construction because + /// direction alone implies read-only/never-serialized (D1). + #[test] + fn texture_state_fields_are_produced_and_present_read_only_with_default_role() { + let SlotShape::Record { fields, .. } = TextureState::slot_shape() else { + panic!("record shape"); + }; + + for name in ["width", "height", "format"] { + let field = fields + .iter() + .find(|field| field.name.as_str() == name) + .expect("texture state field"); + assert_eq!(field.semantics.direction, SlotDirection::Produced); + assert!(field.role.is_default(), "no explicit role is declared"); + assert!( + !field.is_writable(), + "a produced field is never writable, regardless of its default role" + ); + } + } +} diff --git a/lp-core/lpc-model/src/project/node_attach_site.rs b/lp-core/lpc-model/src/project/node_attach_site.rs index d94a4867f..b1fadfda5 100644 --- a/lp-core/lpc-model/src/project/node_attach_site.rs +++ b/lp-core/lpc-model/src/project/node_attach_site.rs @@ -13,7 +13,7 @@ use crate::{ArtifactLocation, SlotPath}; /// /// Two site families exist today: /// -/// - the project root's `nodes` map, which is `read_only_persisted` for +/// - the project root's `nodes` map, whose role is `Fixed` (read-only) for /// generic slot edits and reachable **only** through dedicated node /// operations, and /// - any writable [`crate::NodeInvocationSlot`]-shaped slot in another diff --git a/lp-core/lpc-model/src/slot/mod.rs b/lp-core/lpc-model/src/slot/mod.rs index c94095ef3..cce4a56f8 100644 --- a/lp-core/lpc-model/src/slot/mod.rs +++ b/lp-core/lpc-model/src/slot/mod.rs @@ -19,11 +19,11 @@ mod slot_name; mod slot_owner; mod slot_path; mod slot_persistence; -mod slot_policy; -mod slot_policy_lookup; mod slot_reader; mod slot_record_shape; mod slot_ref; +mod slot_role; +mod slot_role_lookup; mod slot_semantics; mod slot_shape; mod slot_shape_builder; @@ -70,14 +70,12 @@ pub use slot_mutation::{ pub use slot_name::{SlotName, SlotNameError}; pub use slot_owner::SlotOwner; pub use slot_path::{SlotPath, SlotPathError, SlotPathSegment}; -pub use slot_persistence::SlotPersistence; -pub use slot_policy::SlotPolicy; -pub use slot_policy_lookup::{ - SlotPolicyResolution, resolve_slot_policy, resolve_slot_policy_and_leaf, -}; +pub use slot_persistence::{SlotPersistence, effective_persistence}; pub use slot_reader::{SlotFieldReader, SlotOptionReader, SlotReadContext}; pub use slot_record_shape::SlotRecordShape; pub use slot_ref::SlotRef; +pub use slot_role::{SlotRole, effective_writable}; +pub use slot_role_lookup::{SlotRoleResolution, resolve_slot_role}; pub use slot_semantics::SlotSemantics; pub use slot_shape::{ SlotEnumEncoding, SlotFieldShape, SlotMapKeyShape, SlotShape, SlotShapeId, SlotShapeIdError, @@ -91,7 +89,7 @@ pub use slot_value::{ pub mod shape { pub use super::slot_shape_builder::{ custom, enum_external, enum_tagged, enum_with_encoding, field, field_with_dataflow, - field_with_policy, field_with_semantics, field_with_semantics_and_policy, id, leaf, map, + field_with_role, field_with_semantics, field_with_semantics_and_role, id, leaf, map, option, record, reference, unit, value, variant, }; } diff --git a/lp-core/lpc-model/src/slot/slot_persistence.rs b/lp-core/lpc-model/src/slot/slot_persistence.rs index 7c01b1eee..d3270ec11 100644 --- a/lp-core/lpc-model/src/slot/slot_persistence.rs +++ b/lp-core/lpc-model/src/slot/slot_persistence.rs @@ -1,11 +1,18 @@ -//! Persistence hints for slot-shaped authored data. +//! Derived persistence classification for slot-shaped authored data. //! -//! Persistence is a tooling/writeback concern. It tells project editors whether -//! a user-editable slot should be saved by default. It does not affect resolver -//! behavior, dataflow direction, merge policy, or value validation. +//! Persistence is a tooling/writeback concern: it tells project editors +//! whether a user-editable slot should be saved by default. Unlike the +//! former `SlotPolicy`, it is not itself stored on a slot field shape — +//! [`effective_persistence`] derives it on demand from a field's +//! [`SlotRole`](super::SlotRole) and [`SlotDirection`](super::SlotDirection), +//! so a produced field always classifies as transient regardless of role. It +//! does not affect resolver behavior, dataflow direction, merge policy, or +//! value validation. use serde::{Deserialize, Serialize}; +use super::{SlotDirection, SlotRole}; + /// Whether a slot is durable authored data or transient session control data. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -24,6 +31,17 @@ impl SlotPersistence { } } +/// Derive the persistence classification governing a field carrying `role` +/// and `direction`: transient unless the role persists (not [`SlotRole::Debug`]) +/// and the direction does not imply a produced (never-serialized) field. +pub fn effective_persistence(role: SlotRole, direction: SlotDirection) -> SlotPersistence { + if role.is_persisted() && direction != SlotDirection::Produced { + SlotPersistence::Persisted + } else { + SlotPersistence::Transient + } +} + #[cfg(test)] mod tests { use super::*; @@ -41,4 +59,32 @@ mod tests { let back: SlotPersistence = serde_json::from_str(&json).unwrap(); assert_eq!(back, SlotPersistence::Transient); } + + #[test] + fn produced_direction_is_always_transient() { + assert_eq!( + effective_persistence(SlotRole::Setting, SlotDirection::Produced), + SlotPersistence::Transient + ); + assert_eq!( + effective_persistence(SlotRole::Fixed, SlotDirection::Produced), + SlotPersistence::Transient + ); + } + + #[test] + fn debug_role_is_always_transient() { + assert_eq!( + effective_persistence(SlotRole::Debug, SlotDirection::Local), + SlotPersistence::Transient + ); + } + + #[test] + fn setting_role_local_direction_is_persisted() { + assert_eq!( + effective_persistence(SlotRole::Setting, SlotDirection::Local), + SlotPersistence::Persisted + ); + } } diff --git a/lp-core/lpc-model/src/slot/slot_policy.rs b/lp-core/lpc-model/src/slot/slot_policy.rs deleted file mode 100644 index 5f0dbf349..000000000 --- a/lp-core/lpc-model/src/slot/slot_policy.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Tooling and mutation policy attached to slot fields. -//! -//! Policy is distinct from [`SlotMeta`](crate::SlotMeta), which describes -//! presentation, and from [`SlotSemantics`](crate::SlotSemantics), which -//! describes resolver-facing dataflow behavior. - -use serde::{Deserialize, Serialize}; - -use super::SlotPersistence; - -/// Client mutation and persistence policy for one slot field. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -pub struct SlotPolicy { - /// True when clients may request mutation of this slot. - #[serde(default = "default_writable", skip_serializing_if = "is_true")] - pub writable: bool, - - /// Save/writeback hint for user-editable slot data. - #[serde(default, skip_serializing_if = "SlotPersistence::is_persisted")] - pub persistence: SlotPersistence, -} - -impl SlotPolicy { - /// Read-only persisted authored data. - pub const fn read_only_persisted() -> Self { - Self { - writable: false, - persistence: SlotPersistence::Persisted, - } - } - - /// Writable persisted authored data. - pub const fn writable_persisted() -> Self { - Self { - writable: true, - persistence: SlotPersistence::Persisted, - } - } - - /// Read-only transient data. - pub const fn read_only_transient() -> Self { - Self { - writable: false, - persistence: SlotPersistence::Transient, - } - } - - /// Writable transient user control data. - pub const fn writable_transient() -> Self { - Self { - writable: true, - persistence: SlotPersistence::Transient, - } - } - - pub fn is_default(self: &Self) -> bool { - *self == Self::default() - } -} - -impl Default for SlotPolicy { - fn default() -> Self { - Self::writable_persisted() - } -} - -fn default_writable() -> bool { - true -} - -fn is_true(value: &bool) -> bool { - *value -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slot_policy_defaults_to_writable_persisted() { - assert_eq!(SlotPolicy::default(), SlotPolicy::writable_persisted()); - } - - #[test] - fn writable_transient_policy_round_trips() { - let policy = SlotPolicy::writable_transient(); - let json = serde_json::to_string(&policy).unwrap(); - assert!(!json.contains("writable")); - assert!(json.contains("transient")); - let back: SlotPolicy = serde_json::from_str(&json).unwrap(); - assert_eq!(back, policy); - } -} diff --git a/lp-core/lpc-model/src/slot/slot_policy_lookup.rs b/lp-core/lpc-model/src/slot/slot_policy_lookup.rs deleted file mode 100644 index 15940bb09..000000000 --- a/lp-core/lpc-model/src/slot/slot_policy_lookup.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Shape-only resolution of the [`SlotPolicy`] governing a slot path. -//! -//! Unlike the data walkers in [`super::slot_lookup`], this walk consults only -//! the shape tree. It exists for mutate-time enforcement, where a slot edit -//! must be validated before any data exists at its path (missing map entries -//! and inactive enum variants are created or selected when the edit is -//! applied). - -use crate::{LpType, SlotPath, SlotPathSegment, SlotPolicy, SlotShapeLookup, SlotShapeView}; - -/// Policy plus leaf value type resolved for one slot path. -#[derive(Clone, Debug, PartialEq)] -pub struct SlotPolicyResolution { - /// Policy governing the path. See [`resolve_slot_policy`] for the - /// inheritance rule. - pub policy: SlotPolicy, - /// Value type when the path lands on a value leaf (including custom - /// shapes that project to a value leaf). `None` for structural targets - /// (records, maps, options, enums, units). - pub leaf_type: Option, -} - -/// Resolve the [`SlotPolicy`] governing `path` within `shape`. -/// -/// # Inheritance rule -/// -/// Policy is declared per record field ([`crate::SlotFieldShape::policy`]). -/// On the walk from the root to `path`, the innermost record field with a -/// declared policy governs paths into its subtree: -/// -/// - A field whose policy differs from [`SlotPolicy::default()`] -/// (`writable_persisted`) counts as declaring a policy; every path into its -/// subtree is governed by it unless a deeper field declares its own. -/// - A field carrying the default policy inherits from the nearest ancestor -/// field with a declared policy. When no field on the walk declares one, -/// the default `writable_persisted` governs. -/// - Non-field segments (map keys, option `some`, enum variants) never carry -/// policy and pass the inherited policy through unchanged. -/// -/// Because [`SlotPolicy`] is not optional on the field shape, a field that -/// explicitly declares `writable_persisted` is indistinguishable from one -/// that declares nothing, and therefore inherits. -/// -/// The walk is shape-only: enum variant segments resolve against any declared -/// variant (not just the active one) and map key segments resolve for any -/// key. Returns `None` when the path does not resolve in the shape. -pub fn resolve_slot_policy<'s>( - shape: SlotShapeView<'s>, - registry: &'s (impl SlotShapeLookup + ?Sized), - path: &SlotPath, -) -> Option { - resolve_slot_policy_and_leaf(shape, registry, path).map(|resolution| resolution.policy) -} - -/// Resolve the governing [`SlotPolicy`] plus the leaf value type at `path`. -/// -/// Same walk and inheritance rule as [`resolve_slot_policy`]; additionally -/// reports the [`LpType`] of the value leaf the path lands on, so callers can -/// type-check assignments without a second traversal. -pub fn resolve_slot_policy_and_leaf<'s>( - shape: SlotShapeView<'s>, - registry: &'s (impl SlotShapeLookup + ?Sized), - path: &SlotPath, -) -> Option { - walk_policy(shape, registry, path.segments(), SlotPolicy::default()) -} - -fn walk_policy<'s>( - shape: SlotShapeView<'s>, - registry: &'s (impl SlotShapeLookup + ?Sized), - segments: &[SlotPathSegment], - inherited: SlotPolicy, -) -> Option { - let shape = resolve_projected_shape(shape, registry)?; - let Some((head, tail)) = segments.split_first() else { - return Some(SlotPolicyResolution { - policy: inherited, - leaf_type: shape.value_shape().map(|value| value.ty_owned()), - }); - }; - - match head { - SlotPathSegment::Field(name) => { - if let Some((_, field)) = shape.record_field_by_name(name) { - let declared = field.policy(); - let governing = if declared.is_default() { - inherited - } else { - declared - }; - walk_policy(field.shape(), registry, tail, governing) - } else if name.as_str() == "some" - && let Some(some) = shape.option_some() - { - walk_policy(some, registry, tail, inherited) - } else if let Some(variant) = shape.enum_variant_by_name(name) { - walk_policy(variant.shape(), registry, tail, inherited) - } else { - None - } - } - SlotPathSegment::Key(_) => walk_policy(shape.map_value()?, registry, tail, inherited), - } -} - -/// Chase `Ref` indirections and `Custom` projections to a concrete shape. -fn resolve_projected_shape<'s>( - mut shape: SlotShapeView<'s>, - registry: &'s (impl SlotShapeLookup + ?Sized), -) -> Option> { - loop { - if let Some(id) = shape.ref_id() { - shape = registry.get_shape(id)?; - } else if let Some(projected) = shape.custom_shape() { - shape = projected; - } else { - return Some(shape); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::slot::SlotPersistence; - use crate::slot::shape::{field, field_with_policy, map, record, value}; - use crate::{ - ClockDef, LpType, SlotMapKeyShape, SlotShape, SlotShapeId, SlotShapeRegistry, - StaticSlotShape, - }; - use alloc::vec; - - #[test] - fn root_field_policy_governs_its_leaf() { - let (registry, id) = registry_with(record(vec![ - field_with_policy( - "locked", - value(LpType::F32), - SlotPolicy::read_only_persisted(), - ), - field("free", value(LpType::Bool)), - ])); - - let locked = resolve(®istry, id, "locked"); - assert_eq!(locked.policy, SlotPolicy::read_only_persisted()); - assert_eq!(locked.leaf_type, Some(LpType::F32)); - - let free = resolve(®istry, id, "free"); - assert_eq!(free.policy, SlotPolicy::writable_persisted()); - assert_eq!(free.leaf_type, Some(LpType::Bool)); - } - - #[test] - fn nested_composite_member_inherits_from_governing_field() { - let (registry, id) = registry_with(record(vec![field_with_policy( - "state", - record(vec![field("inner", value(LpType::F32))]), - SlotPolicy::read_only_transient(), - )])); - - let inner = resolve(®istry, id, "state.inner"); - assert_eq!(inner.policy, SlotPolicy::read_only_transient()); - assert_eq!(inner.leaf_type, Some(LpType::F32)); - } - - #[test] - fn innermost_declared_policy_overrides_ancestor() { - let (registry, id) = registry_with(record(vec![field_with_policy( - "outer", - record(vec![field_with_policy( - "inner", - value(LpType::F32), - SlotPolicy::writable_transient(), - )]), - SlotPolicy::read_only_persisted(), - )])); - - let inner = resolve(®istry, id, "outer.inner"); - assert_eq!(inner.policy, SlotPolicy::writable_transient()); - } - - #[test] - fn map_key_segments_pass_policy_through() { - let (registry, id) = registry_with(record(vec![field_with_policy( - "params", - map(SlotMapKeyShape::String, value(LpType::F32)), - SlotPolicy::read_only_transient(), - )])); - - let entry = resolve(®istry, id, "params[gain]"); - assert_eq!(entry.policy, SlotPolicy::read_only_transient()); - assert_eq!(entry.leaf_type, Some(LpType::F32)); - } - - #[test] - fn unresolvable_path_returns_none() { - let (registry, id) = registry_with(record(vec![field("free", value(LpType::F32))])); - let shape = registry.get_shape(id).expect("shape"); - - assert_eq!( - resolve_slot_policy(shape, ®istry, &SlotPath::parse("missing").unwrap()), - None - ); - assert_eq!( - resolve_slot_policy(shape, ®istry, &SlotPath::parse("free.deeper").unwrap()), - None - ); - } - - #[test] - fn clock_controls_fields_resolve_writable_transient() { - let registry = SlotShapeRegistry::default(); - let shape = registry.get_shape(ClockDef::SHAPE_ID).expect("clock shape"); - - let rate = resolve_slot_policy_and_leaf( - shape, - ®istry, - &SlotPath::parse("controls.rate").unwrap(), - ) - .expect("controls.rate resolves"); - - assert!(rate.policy.writable); - assert_eq!(rate.policy.persistence, SlotPersistence::Transient); - assert_eq!(rate.leaf_type, Some(LpType::F32)); - } - - fn registry_with(shape: SlotShape) -> (SlotShapeRegistry, SlotShapeId) { - let id = SlotShapeId::from_static_name("test.policy_lookup.root"); - let mut registry = SlotShapeRegistry::default(); - registry.register_dynamic_shape(id, shape).unwrap(); - (registry, id) - } - - fn resolve(registry: &SlotShapeRegistry, id: SlotShapeId, path: &str) -> SlotPolicyResolution { - let shape = registry.get_shape(id).expect("root shape"); - resolve_slot_policy_and_leaf(shape, registry, &SlotPath::parse(path).unwrap()) - .expect("path resolves") - } -} diff --git a/lp-core/lpc-model/src/slot/slot_role.rs b/lp-core/lpc-model/src/slot/slot_role.rs new file mode 100644 index 000000000..bae98d923 --- /dev/null +++ b/lp-core/lpc-model/src/slot/slot_role.rs @@ -0,0 +1,99 @@ +//! Editing and persistence role attached to slot fields. +//! +//! A role is distinct from [`SlotMeta`](crate::SlotMeta), which describes +//! presentation, and from [`SlotSemantics`](crate::SlotSemantics), which +//! describes resolver-facing dataflow behavior. It is also distinct from a +//! field's [`SlotDirection`](crate::SlotDirection): a produced field is +//! always effectively read-only and never persisted regardless of its role +//! (see [`effective_writable`]) — role governs *authored* fields only. + +use serde::{Deserialize, Serialize}; + +use super::SlotDirection; + +/// Client mutation and persistence role for one slot field. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum SlotRole { + /// Authored node config: writable, persisted. The default. + #[default] + Setting, + /// Read-only persisted authored data (today: three fields on + /// `ProjectDef`). + Fixed, + /// Writable, transient by nature: diagnostics/authoring overrides. Never + /// serialized; lives only in the session overlay. + Debug, +} + +impl SlotRole { + /// True when clients may request mutation of this role's slot. + pub fn is_writable(self) -> bool { + !matches!(self, Self::Fixed) + } + + /// Save/writeback hint: true unless the role is [`Self::Debug`]. + pub fn is_persisted(self) -> bool { + !matches!(self, Self::Debug) + } + + pub fn is_default(self: &Self) -> bool { + *self == Self::default() + } +} + +/// Whether a slot governed by `role` accepts client-requested mutation. +/// +/// A produced field is always effectively read-only: it is written by its +/// owning node at runtime, so no declared role can make it writable (D1 — +/// direction implies the constraint, no `read_only_transient` marking +/// needed). +pub fn effective_writable(role: SlotRole, direction: SlotDirection) -> bool { + role.is_writable() && direction != SlotDirection::Produced +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_role_defaults_to_setting() { + assert_eq!(SlotRole::default(), SlotRole::Setting); + assert!(SlotRole::default().is_writable()); + assert!(SlotRole::default().is_persisted()); + } + + #[test] + fn debug_role_is_writable_but_not_persisted() { + assert!(SlotRole::Debug.is_writable()); + assert!(!SlotRole::Debug.is_persisted()); + } + + #[test] + fn fixed_role_is_persisted_but_not_writable() { + assert!(!SlotRole::Fixed.is_writable()); + assert!(SlotRole::Fixed.is_persisted()); + } + + #[test] + fn produced_direction_is_never_effectively_writable() { + assert!(!effective_writable( + SlotRole::Setting, + SlotDirection::Produced + )); + assert!(!effective_writable( + SlotRole::Debug, + SlotDirection::Produced + )); + assert!(effective_writable(SlotRole::Setting, SlotDirection::Local)); + } + + #[test] + fn slot_role_serde_is_snake_case_and_skips_default() { + let json = serde_json::to_string(&SlotRole::Debug).unwrap(); + assert_eq!(json, "\"debug\""); + let back: SlotRole = serde_json::from_str(&json).unwrap(); + assert_eq!(back, SlotRole::Debug); + } +} diff --git a/lp-core/lpc-model/src/slot/slot_role_lookup.rs b/lp-core/lpc-model/src/slot/slot_role_lookup.rs new file mode 100644 index 000000000..bee6aba3a --- /dev/null +++ b/lp-core/lpc-model/src/slot/slot_role_lookup.rs @@ -0,0 +1,295 @@ +//! Shape-only resolution of the [`SlotRole`] governing a slot path. +//! +//! Unlike the data walkers in [`super::slot_lookup`], this walk consults only +//! the shape tree. It exists for mutate-time enforcement, where a slot edit +//! must be validated before any data exists at its path (missing map entries +//! and inactive enum variants are created or selected when the edit is +//! applied). + +use crate::{ + LpType, SlotDirection, SlotPath, SlotPathSegment, SlotRole, SlotSemantics, SlotShapeLookup, + SlotShapeView, slot::effective_writable, +}; + +/// Role, governing direction, and leaf value type resolved for one slot path. +#[derive(Clone, Debug, PartialEq)] +pub struct SlotRoleResolution { + /// Role governing the path. See [`resolve_slot_role`] for the + /// inheritance rule. + pub role: SlotRole, + /// Dataflow direction governing the path, inherited the same way as + /// `role`. A produced direction always wins over the role: see + /// [`Self::is_writable`]. + pub direction: SlotDirection, + /// Value type when the path lands on a value leaf (including custom + /// shapes that project to a value leaf). `None` for structural targets + /// (records, maps, options, enums, units). + pub leaf_type: Option, +} + +impl SlotRoleResolution { + /// Whether a client may request mutation of this path: the role allows + /// it and the path is not produced (D1 — direction implies read-only + /// regardless of role). + pub fn is_writable(&self) -> bool { + effective_writable(self.role, self.direction) + } +} + +/// Resolve the [`SlotRole`] plus governing direction and leaf type for `path` +/// within `shape`. +/// +/// # Inheritance rule +/// +/// Role and direction are declared per record field +/// ([`crate::SlotFieldShape::role`], [`crate::SlotFieldShape::semantics`]). +/// On the walk from the root to `path`, the innermost record field with a +/// declared value governs paths into its subtree, tracked independently for +/// role and direction: +/// +/// - A field whose role differs from [`SlotRole::default()`] (`Setting`) +/// counts as declaring a role; every path into its subtree is governed by +/// it unless a deeper field declares its own. The same rule applies to +/// direction against [`SlotDirection::default()`] (`Local`). +/// - A field carrying the default role/direction inherits from the nearest +/// ancestor field with a declared value. When no field on the walk +/// declares one, the defaults govern. +/// - Non-field segments (map keys, option `some`, enum variants) never carry +/// role or direction and pass the inherited values through unchanged. +/// +/// Because neither [`SlotRole`] nor [`SlotDirection`] is optional on the +/// field shape, a field that explicitly declares the default value is +/// indistinguishable from one that declares nothing, and therefore inherits. +/// +/// The walk is shape-only: enum variant segments resolve against any declared +/// variant (not just the active one) and map key segments resolve for any +/// key. Returns `None` when the path does not resolve in the shape. +pub fn resolve_slot_role<'s>( + shape: SlotShapeView<'s>, + registry: &'s (impl SlotShapeLookup + ?Sized), + path: &SlotPath, +) -> Option { + walk_role( + shape, + registry, + path.segments(), + SlotRole::default(), + SlotDirection::default(), + ) +} + +fn walk_role<'s>( + shape: SlotShapeView<'s>, + registry: &'s (impl SlotShapeLookup + ?Sized), + segments: &[SlotPathSegment], + inherited_role: SlotRole, + inherited_direction: SlotDirection, +) -> Option { + let shape = resolve_projected_shape(shape, registry)?; + let Some((head, tail)) = segments.split_first() else { + return Some(SlotRoleResolution { + role: inherited_role, + direction: inherited_direction, + leaf_type: shape.value_shape().map(|value| value.ty_owned()), + }); + }; + + match head { + SlotPathSegment::Field(name) => { + if let Some((_, field)) = shape.record_field_by_name(name) { + let declared_role = field.role(); + let governing_role = if declared_role.is_default() { + inherited_role + } else { + declared_role + }; + let declared_direction = field.semantics().direction; + let governing_direction = + if declared_direction == SlotSemantics::default().direction { + inherited_direction + } else { + declared_direction + }; + walk_role( + field.shape(), + registry, + tail, + governing_role, + governing_direction, + ) + } else if name.as_str() == "some" + && let Some(some) = shape.option_some() + { + walk_role(some, registry, tail, inherited_role, inherited_direction) + } else if let Some(variant) = shape.enum_variant_by_name(name) { + walk_role( + variant.shape(), + registry, + tail, + inherited_role, + inherited_direction, + ) + } else { + None + } + } + SlotPathSegment::Key(_) => walk_role( + shape.map_value()?, + registry, + tail, + inherited_role, + inherited_direction, + ), + } +} + +/// Chase `Ref` indirections and `Custom` projections to a concrete shape. +fn resolve_projected_shape<'s>( + mut shape: SlotShapeView<'s>, + registry: &'s (impl SlotShapeLookup + ?Sized), +) -> Option> { + loop { + if let Some(id) = shape.ref_id() { + shape = registry.get_shape(id)?; + } else if let Some(projected) = shape.custom_shape() { + shape = projected; + } else { + return Some(shape); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::slot::shape::{field, field_with_role, map, record, value}; + use crate::{ + ClockDef, LpType, SlotMapKeyShape, SlotShape, SlotShapeId, SlotShapeRegistry, + StaticSlotShape, + }; + use alloc::vec; + + #[test] + fn root_field_role_governs_its_leaf() { + let (registry, id) = registry_with(record(vec![ + field_with_role("locked", value(LpType::F32), SlotRole::Fixed), + field("free", value(LpType::Bool)), + ])); + + let locked = resolve(®istry, id, "locked"); + assert_eq!(locked.role, SlotRole::Fixed); + assert_eq!(locked.leaf_type, Some(LpType::F32)); + + let free = resolve(®istry, id, "free"); + assert_eq!(free.role, SlotRole::Setting); + assert_eq!(free.leaf_type, Some(LpType::Bool)); + } + + #[test] + fn nested_composite_member_inherits_from_governing_field() { + let (registry, id) = registry_with(record(vec![field_with_role( + "state", + record(vec![field("inner", value(LpType::F32))]), + SlotRole::Fixed, + )])); + + let inner = resolve(®istry, id, "state.inner"); + assert_eq!(inner.role, SlotRole::Fixed); + assert_eq!(inner.leaf_type, Some(LpType::F32)); + } + + #[test] + fn innermost_declared_role_overrides_ancestor() { + let (registry, id) = registry_with(record(vec![field_with_role( + "outer", + record(vec![field_with_role( + "inner", + value(LpType::F32), + SlotRole::Debug, + )]), + SlotRole::Fixed, + )])); + + let inner = resolve(®istry, id, "outer.inner"); + assert_eq!(inner.role, SlotRole::Debug); + } + + #[test] + fn map_key_segments_pass_role_through() { + let (registry, id) = registry_with(record(vec![field_with_role( + "params", + map(SlotMapKeyShape::String, value(LpType::F32)), + SlotRole::Debug, + )])); + + let entry = resolve(®istry, id, "params[gain]"); + assert_eq!(entry.role, SlotRole::Debug); + assert_eq!(entry.leaf_type, Some(LpType::F32)); + } + + #[test] + fn unresolvable_path_returns_none() { + let (registry, id) = registry_with(record(vec![field("free", value(LpType::F32))])); + let shape = registry.get_shape(id).expect("shape"); + + assert_eq!( + resolve_slot_role(shape, ®istry, &SlotPath::parse("missing").unwrap()), + None + ); + assert_eq!( + resolve_slot_role(shape, ®istry, &SlotPath::parse("free.deeper").unwrap()), + None + ); + } + + #[test] + fn clock_controls_fields_resolve_debug_role() { + let registry = SlotShapeRegistry::default(); + let shape = registry.get_shape(ClockDef::SHAPE_ID).expect("clock shape"); + + let rate = resolve_slot_role(shape, ®istry, &SlotPath::parse("controls.rate").unwrap()) + .expect("controls.rate resolves"); + + assert_eq!(rate.role, SlotRole::Debug); + assert!(rate.is_writable()); + assert_eq!(rate.leaf_type, Some(LpType::F32)); + } + + #[test] + fn produced_direction_forces_read_only_regardless_of_role() { + let (registry, id) = registry_with(record(vec![crate::slot::shape::field_with_semantics( + "output", + value(LpType::F32), + crate::SlotSemantics::produced(), + )])); + + let output = resolve(®istry, id, "output"); + assert_eq!(output.direction, SlotDirection::Produced); + assert!(!output.is_writable(), "produced fields are never writable"); + } + + #[test] + fn produced_direction_is_inherited_by_nested_leaves() { + let (registry, id) = registry_with(record(vec![crate::slot::shape::field_with_semantics( + "output", + record(vec![field("inner", value(LpType::F32))]), + crate::SlotSemantics::produced(), + )])); + + let inner = resolve(®istry, id, "output.inner"); + assert_eq!(inner.direction, SlotDirection::Produced); + assert!(!inner.is_writable()); + } + + fn registry_with(shape: SlotShape) -> (SlotShapeRegistry, SlotShapeId) { + let id = SlotShapeId::from_static_name("test.role_lookup.root"); + let mut registry = SlotShapeRegistry::default(); + registry.register_dynamic_shape(id, shape).unwrap(); + (registry, id) + } + + fn resolve(registry: &SlotShapeRegistry, id: SlotShapeId, path: &str) -> SlotRoleResolution { + let shape = registry.get_shape(id).expect("root shape"); + resolve_slot_role(shape, registry, &SlotPath::parse(path).unwrap()).expect("path resolves") + } +} diff --git a/lp-core/lpc-model/src/slot/slot_shape.rs b/lp-core/lpc-model/src/slot/slot_shape.rs index 69ab5b001..e497b5956 100644 --- a/lp-core/lpc-model/src/slot/slot_shape.rs +++ b/lp-core/lpc-model/src/slot/slot_shape.rs @@ -1,4 +1,4 @@ -use crate::{LpType, SlotName, SlotNameError, SlotPolicy, SlotSemantics, SlotValueShape}; +use crate::{LpType, SlotName, SlotNameError, SlotRole, SlotSemantics, SlotValueShape}; use alloc::boxed::Box; use alloc::string::String; use alloc::vec::Vec; @@ -237,8 +237,8 @@ pub struct SlotFieldShape { pub shape: SlotShape, #[serde(default, skip_serializing_if = "SlotSemantics::is_default")] pub semantics: SlotSemantics, - #[serde(default, skip_serializing_if = "SlotPolicy::is_default")] - pub policy: SlotPolicy, + #[serde(default, skip_serializing_if = "SlotRole::is_default")] + pub role: SlotRole, /// Declarative default binding endpoint (`bus:`) materialized at /// load when no authored binding names this slot — a produced slot's /// default publishes, a consumed slot's default sources (ADR @@ -252,12 +252,8 @@ impl SlotFieldShape { Self::with_semantics(name, shape, SlotSemantics::default()) } - pub fn with_policy( - name: &str, - shape: SlotShape, - policy: SlotPolicy, - ) -> Result { - Self::with_semantics_and_policy(name, shape, SlotSemantics::default(), policy) + pub fn with_role(name: &str, shape: SlotShape, role: SlotRole) -> Result { + Self::with_semantics_and_role(name, shape, SlotSemantics::default(), role) } pub fn with_semantics( @@ -265,23 +261,30 @@ impl SlotFieldShape { shape: SlotShape, semantics: SlotSemantics, ) -> Result { - Self::with_semantics_and_policy(name, shape, semantics, SlotPolicy::default()) + Self::with_semantics_and_role(name, shape, semantics, SlotRole::default()) } - pub fn with_semantics_and_policy( + pub fn with_semantics_and_role( name: &str, shape: SlotShape, semantics: SlotSemantics, - policy: SlotPolicy, + role: SlotRole, ) -> Result { Ok(Self { name: SlotName::parse(name)?, shape, semantics, - policy, + role, default_bind: None, }) } + + /// Whether a client may request mutation of this field: the role allows + /// it and the field's own declared direction is not produced (D1 — + /// direction implies read-only regardless of role). + pub fn is_writable(&self) -> bool { + crate::slot::effective_writable(self.role, self.semantics.direction) + } } impl SlotSemantics { diff --git a/lp-core/lpc-model/src/slot/slot_shape_builder.rs b/lp-core/lpc-model/src/slot/slot_shape_builder.rs index a4bf7cf3b..63c4b6bc5 100644 --- a/lp-core/lpc-model/src/slot/slot_shape_builder.rs +++ b/lp-core/lpc-model/src/slot/slot_shape_builder.rs @@ -5,7 +5,7 @@ //! underlying types remain explicit and serializable. use crate::{ - LpType, SlotEnumEncoding, SlotFieldShape, SlotMapKeyShape, SlotMeta, SlotPolicy, SlotSemantics, + LpType, SlotEnumEncoding, SlotFieldShape, SlotMapKeyShape, SlotMeta, SlotRole, SlotSemantics, SlotShape, SlotShapeId, SlotValueShape, SlotVariantShape, }; use alloc::boxed::Box; @@ -89,12 +89,12 @@ pub fn field(name: &str, shape: SlotShape) -> SlotFieldShape { SlotFieldShape::new(name, shape).expect("valid static slot field name") } -/// Build one record field with explicit tooling and mutation policy. +/// Build one record field with an explicit editing/persistence role. /// /// This panics for invalid names because these names are authored in Rust /// source. -pub fn field_with_policy(name: &str, shape: SlotShape, policy: SlotPolicy) -> SlotFieldShape { - SlotFieldShape::with_policy(name, shape, policy).expect("valid static slot field name") +pub fn field_with_role(name: &str, shape: SlotShape, role: SlotRole) -> SlotFieldShape { + SlotFieldShape::with_role(name, shape, role).expect("valid static slot field name") } /// Build one record field with explicit dataflow semantics. @@ -109,17 +109,17 @@ pub fn field_with_semantics( SlotFieldShape::with_semantics(name, shape, semantics).expect("valid static slot field name") } -/// Build one record field with explicit dataflow semantics and policy. +/// Build one record field with explicit dataflow semantics and role. /// /// This panics for invalid names because these names are authored in Rust /// source. -pub fn field_with_semantics_and_policy( +pub fn field_with_semantics_and_role( name: &str, shape: SlotShape, semantics: SlotSemantics, - policy: SlotPolicy, + role: SlotRole, ) -> SlotFieldShape { - SlotFieldShape::with_semantics_and_policy(name, shape, semantics, policy) + SlotFieldShape::with_semantics_and_role(name, shape, semantics, role) .expect("valid static slot field name") } @@ -129,10 +129,10 @@ pub fn field_with_dataflow( name: &str, shape: SlotShape, semantics: SlotSemantics, - policy: SlotPolicy, + role: SlotRole, default_bind: Option<&str>, ) -> SlotFieldShape { - let mut field = SlotFieldShape::with_semantics_and_policy(name, shape, semantics, policy) + let mut field = SlotFieldShape::with_semantics_and_role(name, shape, semantics, role) .expect("valid static slot field name"); field.default_bind = default_bind.map(ToString::to_string); field diff --git a/lp-core/lpc-model/src/slot/slot_shape_view.rs b/lp-core/lpc-model/src/slot/slot_shape_view.rs index 1783bf29a..8f9e9b9a3 100644 --- a/lp-core/lpc-model/src/slot/slot_shape_view.rs +++ b/lp-core/lpc-model/src/slot/slot_shape_view.rs @@ -1,7 +1,7 @@ //! Borrowed shape views over static descriptors or dynamic owned shapes. use crate::{ - LpType, SlotFieldShape, SlotMapKeyShape, SlotName, SlotPolicy, SlotSemantics, SlotShape, + LpType, SlotFieldShape, SlotMapKeyShape, SlotName, SlotRole, SlotSemantics, SlotShape, SlotShapeId, SlotVariantShape, }; use alloc::vec::Vec; @@ -230,12 +230,19 @@ impl<'a> SlotFieldShapeView<'a> { } } - pub fn policy(self) -> SlotPolicy { + pub fn role(self) -> SlotRole { match self { - Self::Static(field) => field.policy, - Self::Dynamic(field) => field.policy, + Self::Static(field) => field.role, + Self::Dynamic(field) => field.role, } } + + /// Whether a client may request mutation of this field: the role allows + /// it and the field's own declared direction is not produced (D1 — + /// direction implies read-only regardless of role). + pub fn is_writable(self) -> bool { + crate::slot::effective_writable(self.role(), self.semantics().direction) + } } /// Borrowed view of one enum variant shape. @@ -276,7 +283,7 @@ mod tests { name: "enabled", shape: &BOOL_SHAPE, semantics: SlotSemantics::new(SlotDirection::Local, SlotMerge::Latest), - policy: SlotPolicy::writable_persisted(), + role: SlotRole::Setting, default_bind: None, }]; static RECORD: StaticSlotShapeDescriptor = StaticSlotShapeDescriptor::Record { diff --git a/lp-core/lpc-model/src/slot/static_slot_shape.rs b/lp-core/lpc-model/src/slot/static_slot_shape.rs index 1366dbbe7..2502d983d 100644 --- a/lp-core/lpc-model/src/slot/static_slot_shape.rs +++ b/lp-core/lpc-model/src/slot/static_slot_shape.rs @@ -6,7 +6,7 @@ use crate::{ LpType, ModelEnumVariant, ModelStructMember, OrderedF32, ProductKind, SlotEnumEncoding, - SlotEnumOption, SlotFieldShape, SlotMapKeyShape, SlotMeta, SlotName, SlotPolicy, SlotSemantics, + SlotEnumOption, SlotFieldShape, SlotMapKeyShape, SlotMeta, SlotName, SlotRole, SlotSemantics, SlotShape, SlotShapeId, SlotValueShape, SlotVariantShape, ValueEditorHint, }; use alloc::boxed::Box; @@ -439,8 +439,8 @@ pub struct StaticSlotFieldShape { pub shape: &'static StaticSlotShapeDescriptor, #[serde(default, skip_serializing_if = "SlotSemantics::is_default")] pub semantics: SlotSemantics, - #[serde(default, skip_serializing_if = "SlotPolicy::is_default")] - pub policy: SlotPolicy, + #[serde(default, skip_serializing_if = "SlotRole::is_default")] + pub role: SlotRole, /// Declarative default binding endpoint (`bus:`); see /// [`crate::SlotFieldShape::default_bind`]. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -449,11 +449,11 @@ pub struct StaticSlotFieldShape { impl StaticSlotFieldShape { pub fn to_owned_field(self) -> SlotFieldShape { - let mut field = SlotFieldShape::with_semantics_and_policy( + let mut field = SlotFieldShape::with_semantics_and_role( self.name, self.shape.to_owned_shape(), self.semantics, - self.policy, + self.role, ) .expect("valid static slot field name"); field.default_bind = self.default_bind.map(ToString::to_string); @@ -487,7 +487,7 @@ mod tests { name: "enabled", shape: &BOOL_SHAPE, semantics: SlotSemantics::new(SlotDirection::Local, SlotMerge::Latest), - policy: SlotPolicy::writable_persisted(), + role: SlotRole::Setting, default_bind: None, }]; static RECORD_SHAPE: StaticSlotShapeDescriptor = StaticSlotShapeDescriptor::Record { diff --git a/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs b/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs index b410b1924..4afb861b1 100644 --- a/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs +++ b/lp-core/lpc-model/src/slot_codec/dynamic_slot_writer.rs @@ -1,10 +1,9 @@ use alloc::format; use alloc::string::{String, ToString}; -use crate::slot::SlotPersistence; use crate::{ - SlotAccess, SlotDataAccess, SlotEnumEncoding, SlotFieldShape, SlotMapKey, SlotShape, - SlotShapeId, SlotShapeLookup, SlotShapeRegistry, SlotVariantShape, + SlotAccess, SlotDataAccess, SlotDirection, SlotEnumEncoding, SlotFieldShape, SlotMapKey, + SlotRole, SlotShape, SlotShapeId, SlotShapeLookup, SlotShapeRegistry, SlotVariantShape, }; use super::{SlotValueWriter, SlotWrite, SlotWriteError, SlotWriter, write_lp_value}; @@ -311,18 +310,21 @@ where /// Whether one record field is omitted from authored JSON. /// -/// Transient never serializes: a field whose policy persistence is -/// [`SlotPersistence::Transient`] is omitted together with its whole subtree -/// (the field's policy governs everything below it), so transient runtime -/// controls never reach def files regardless of the write path. Structurally -/// empty fields (none options, empty maps, unit/empty records) are omitted as -/// well via [`should_omit_field`]. +/// A [`SlotRole::Debug`] field never serializes: it is omitted together with +/// its whole subtree (the field's role governs everything below it), so +/// transient runtime controls never reach def files regardless of the write +/// path. A produced field is omitted too — defensively, since state roots +/// (the only holders of produced fields today) are never written through +/// this path in the first place, but direction implies never-serialized +/// regardless of role (D1). Structurally empty fields (none options, empty +/// maps, unit/empty records) are omitted as well via [`should_omit_field`]. fn omit_record_field( field: &SlotFieldShape, data: SlotDataAccess<'_>, registry: &SlotShapeRegistry, ) -> bool { - field.policy.persistence == SlotPersistence::Transient + field.role == SlotRole::Debug + || field.semantics.direction == SlotDirection::Produced || should_omit_field(&field.shape, data, registry) } @@ -413,7 +415,7 @@ mod tests { use crate::{ LpType, LpValue, Revision, SlotData, SlotMapDyn, SlotName, SlotOptionDyn, SlotRecord, SlotVariantShape, WithRevision, - slot::shape::{enum_external, field, field_with_policy, map, option, record, unit, value}, + slot::shape::{enum_external, field, field_with_role, map, option, record, unit, value}, }; use alloc::vec; use alloc::vec::Vec; @@ -522,11 +524,7 @@ mod tests { shape_id, record(vec![ field("pin", value(LpType::U32)), - field_with_policy( - "rate", - value(LpType::F32), - crate::SlotPolicy::writable_transient(), - ), + field_with_role("rate", value(LpType::F32), crate::SlotRole::Debug), ]), ) .unwrap(); @@ -549,10 +547,10 @@ mod tests { shape_id, record(vec![field( "controls", - record(vec![field_with_policy( + record(vec![field_with_role( "rate", value(LpType::F32), - crate::SlotPolicy::writable_transient(), + crate::SlotRole::Debug, )]), )]), ) diff --git a/lp-core/lpc-registry/README.md b/lp-core/lpc-registry/README.md index c31e8d022..7e021471f 100644 --- a/lp-core/lpc-registry/README.md +++ b/lp-core/lpc-registry/README.md @@ -17,10 +17,10 @@ commands individually (`MutationRejectionReason`) while the rest of the batch proceeds: - `UnknownArtifact` / `UnknownSlotPath` — the target does not resolve; -- `NotWritable` — the governing `SlotPolicy` is not writable; +- `NotWritable` — the governing `SlotRole` is not writable; - `TypeMismatch` — an `AssignValue` whose value does not match the leaf type. -Policy resolution is shape-only (`lpc-model`'s `resolve_slot_policy`), so +Role resolution is shape-only (`lpc-model`'s `resolve_slot_role`), so edits validate at paths where no data exists yet (missing map entries, inactive enum variants). `RemoveSlotEdit` is allowed regardless of writability — it only removes pending overlay state. @@ -34,8 +34,8 @@ wire-facing caller and any new caller must route through the same validation `create_node` / `remove_node` (`registry/node_authoring.rs`) are the dedicated node-lifecycle operations behind the `CreateNode` / `RemoveNode` wire commands (`docs/adr/2026-07-27-node-authoring-operations.md`). They are -the sanctioned path around the `nodes` map's `read_only_persisted` policy: -generic slot gestures on the map stay rejected, while the ops validate +the sanctioned path around the `nodes` map's `Fixed` role: generic slot +gestures on the map stay rejected, while the ops validate everything up front and then act atomically. - `create_node` **commits immediately**: it writes asset and def files diff --git a/lp-core/lpc-registry/src/registry/node_authoring.rs b/lp-core/lpc-registry/src/registry/node_authoring.rs index 2692e6c9b..629cc81cb 100644 --- a/lp-core/lpc-registry/src/registry/node_authoring.rs +++ b/lp-core/lpc-registry/src/registry/node_authoring.rs @@ -1,7 +1,7 @@ //! Dedicated node-authoring semantics on the project registry. //! //! `create_node` and `remove_node` are the operations the `ProjectDef.nodes` -//! `read_only_persisted` policy promises: node creation and removal never +//! `Fixed` role promises: node creation and removal never //! arrive as raw slot edits, they arrive here. Both address the node through //! a [`NodeAttachSite`] — either the policy-locked project `nodes` map (the //! dedicated-op bypass applies **only** to that site) or any writable @@ -203,9 +203,9 @@ impl ProjectRegistry { ), )); }; - // The dedicated-op policy bypass applies only to the + // The dedicated-op role bypass applies only to the // `ProjectNodes` site; slot sites take the standard check. - if !resolution.policy.writable { + if !resolution.is_writable() { return Err(reject( MutationRejectionReason::NotWritable, format!("attach slot {path} is not writable"), @@ -418,8 +418,8 @@ impl ProjectRegistry { /// The site must resolve in the **effective** inventory. Everything is /// validated before anything is staged; a rejection leaves the overlay /// and inventory untouched. The `ProjectNodes` site bypasses the `nodes` - /// map's `read_only_persisted` policy (the dedicated-op contract); slot - /// sites take the standard writable-policy check. + /// map's `Fixed` role (the dedicated-op contract); slot sites take the + /// standard writable check. pub fn remove_node( &mut self, fs: &dyn LpFs, @@ -446,7 +446,7 @@ impl ProjectRegistry { // Stage the entry removal through the singular mutation path: it is // deliberately unvalidated (the dedicated-op bypass of the `nodes` - // map's read_only_persisted policy), normalizes an overlay-only entry + // map's `Fixed` role), normalizes an overlay-only entry // away instead of storing a no-op edit, and re-derives the effective // inventory. `PutSlotEdit` cannot fail on that path. let removal = self @@ -573,9 +573,9 @@ impl ProjectRegistry { ), )); }; - // The dedicated-op policy bypass applies only to the + // The dedicated-op role bypass applies only to the // `ProjectNodes` site; slot sites take the standard check. - if !resolution.policy.writable { + if !resolution.is_writable() { return Err(reject( MutationRejectionReason::NotWritable, format!("remove slot {path} is not writable"), diff --git a/lp-core/lpc-registry/src/registry/project_registry.rs b/lp-core/lpc-registry/src/registry/project_registry.rs index ba271467c..082091b13 100644 --- a/lp-core/lpc-registry/src/registry/project_registry.rs +++ b/lp-core/lpc-registry/src/registry/project_registry.rs @@ -4,17 +4,15 @@ use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; -use lpc_model::slot::SlotPersistence; use lpc_model::{ ArtifactChangeSummary, ArtifactLocation, ArtifactOverlay, AssetBodyOverlay, CommitResult, MutationBatchResults, MutationCmdBatch, MutationCmdBatchResult, MutationCmdResult, MutationEffect, MutationOp, MutationRejection, MutationRejectionReason, MutationResult, NodeArtifact, NodeDef, NodeDefEntry, NodeDefLocation, NodeDefState, PROJECT_FORMAT_VERSION, ProjectFormatProbe, ProjectInventory, ProjectOverlay, Revision, SlotAccess, SlotDataAccess, - SlotEditOp, SlotMapKey, SlotName, SlotPath, SlotPathSegment, SlotPolicyResolution, + SlotEditOp, SlotMapKey, SlotName, SlotPath, SlotPathSegment, SlotRole, SlotRoleResolution, SlotShapeLookup, SlotShapeView, StaticSlotShape, StoredSlotEdit, WithRevision, - lookup_slot_data, lp_value_matches_type, read_project_format_json, - resolve_slot_policy_and_leaf, + lookup_slot_data, lp_value_matches_type, read_project_format_json, resolve_slot_role, }; use lpfs::{FsEvent, FsEventKind, LpFs, LpPath}; @@ -517,13 +515,13 @@ impl ProjectRegistry { } } - // Commit retains transient slot edits: the writer never serializes - // transient values into def files, so their pending state must stay - // live after save. Persisted slot edits and asset overlays are on - // disk now and drop. The overlay revision only advances when this - // actually changed the overlay's content, so clients re-fetch exactly - // when the pending set changed. - let retained = self.retain_transient_edits(&overlay, ctx); + // Commit retains Debug-role slot edits: the writer never serializes + // them into def files, so their pending state must stay live after + // save. Persisted slot edits and asset overlays are on disk now and + // drop. The overlay revision only advances when this actually + // changed the overlay's content, so clients re-fetch exactly when + // the pending set changed. + let retained = self.retain_debug_edits(&overlay, ctx); if retained != overlay { self.overlay.set(frame, retained); } @@ -539,19 +537,15 @@ impl ProjectRegistry { Ok(CommitResult { artifact_changes }) } - /// Post-commit overlay: keep slot edits whose governing policy is - /// transient (they never serialize to def files, so commit does not - /// resolve them; they stay pending and runtime-effective). Persisted slot - /// edits and asset overlays drop — their content is now on disk. A stale - /// edit whose path no longer resolves in the def shape drops like a - /// persisted one: it was already unenforceable. Artifacts left without - /// edits are not retained, preserving the empty-artifact-overlay removal + /// Post-commit overlay: keep slot edits whose governing role is `Debug` + /// (they never serialize to def files, so commit does not resolve them; + /// they stay pending and runtime-effective). Persisted slot edits and + /// asset overlays drop — their content is now on disk. A stale edit + /// whose path no longer resolves in the def shape drops like a persisted + /// one: it was already unenforceable. Artifacts left without edits are + /// not retained, preserving the empty-artifact-overlay removal /// invariant. - fn retain_transient_edits( - &self, - committed: &ProjectOverlay, - ctx: &ParseCtx<'_>, - ) -> ProjectOverlay { + fn retain_debug_edits(&self, committed: &ProjectOverlay, ctx: &ParseCtx<'_>) -> ProjectOverlay { let mut retained = ProjectOverlay::new(); for (location, artifact_overlay) in committed.iter() { let Some(slot_overlay) = artifact_overlay.as_slot() else { @@ -565,15 +559,14 @@ impl ProjectRegistry { else { continue; }; - let transient = slot_overlay.filtered(|path, _| { - resolve_edit_policy(def, path, ctx).is_some_and(|resolution| { - resolution.policy.persistence == SlotPersistence::Transient - }) + let debug = slot_overlay.filtered(|path, _| { + resolve_edit_policy(def, path, ctx) + .is_some_and(|resolution| resolution.role == SlotRole::Debug) }); - if !transient.is_empty() { + if !debug.is_empty() { retained .artifacts - .insert(location.clone(), ArtifactOverlay::slot(transient)); + .insert(location.clone(), ArtifactOverlay::slot(debug)); } } retained @@ -765,7 +758,7 @@ impl ProjectRegistry { ), )); }; - if !resolution.policy.writable { + if !resolution.is_writable() { return Err(MutationRejection::new( MutationRejectionReason::NotWritable, format!("slot {} is not writable", edit.path), @@ -845,7 +838,7 @@ impl ProjectRegistry { artifact.file_path() ))); }; - if !resolution.policy.writable { + if !resolution.is_writable() { return Err(MutationRejection::new( MutationRejectionReason::NotWritable, format!("slot {to} is not writable"), @@ -1008,7 +1001,7 @@ impl ProjectRegistry { /// variant must clear, or empty when the mutation is no such edit. /// /// Detection is a shape-only walk (the same resolution family as - /// [`resolve_edit_policy`] / [`resolve_slot_policy_and_leaf`], so it works + /// [`resolve_edit_policy`] / [`resolve_slot_role`], so it works /// regardless of which variant is currently active): the edit's terminal /// segment must name a declared variant of the enum its parent path /// resolves to in the effective definition's shape. Returns the paths of @@ -1343,7 +1336,7 @@ pub(crate) fn resolve_edit_policy( def: &NodeDef, path: &SlotPath, ctx: &ParseCtx<'_>, -) -> Option { +) -> Option { let root_shape_id = match path.segments().first() { Some(SlotPathSegment::Field(name)) if NodeDef::is_variant_name(name.as_str()) => { NodeArtifact::SHAPE_ID @@ -1351,7 +1344,7 @@ pub(crate) fn resolve_edit_policy( _ => def.shape_id(), }; let shape = ctx.shapes.get_shape(root_shape_id)?; - resolve_slot_policy_and_leaf(shape, ctx.shapes, path) + resolve_slot_role(shape, ctx.shapes, path) } /// Paths of the other declared variants when `path` terminates at an enum @@ -1438,7 +1431,7 @@ fn ensure_effective_scope(def: &NodeDef, path: &SlotPath, ctx: &ParseCtx<'_>) -> } /// Shape at `segments` under `shape`: the same shape-only walk as -/// [`resolve_slot_policy_and_leaf`] (chasing `Ref` indirections and `Custom` +/// [`resolve_slot_role`] (chasing `Ref` indirections and `Custom` /// projections, resolving enum variant segments against any declared /// variant), returning the shape view instead of a policy. `None` when the /// path does not resolve in the shape. @@ -1560,7 +1553,7 @@ mod tests { use alloc::vec; use alloc::vec::Vec; use lpc_model::{ - ClockDef, LpValue, MutationCmd, MutationCmdId, MutationCmdStatus, SlotEdit, SlotPolicy, + ClockDef, LpValue, MutationCmd, MutationCmdId, MutationCmdStatus, SlotEdit, SlotRole, SlotShape, SlotShapeRegistry, }; use lpfs::LpFsMemory; @@ -1607,11 +1600,10 @@ mod tests { #[test] fn project_root_format_and_nodes_reject_writes_but_name_stays_writable() { // P6 flat-root policy: `ProjectDef.format` and `ProjectDef.nodes` are - // `read_only_persisted` — only the loader format gate / future - // upgrader (format) and dedicated project ops (nodes, Studio - // authoring M2) own them. The policy inherits into the subtree (map - // entries, option interior); `name` stays writable (project rename - // is legitimate). + // role `Fixed` — only the loader format gate / future upgrader + // (format) and dedicated project ops (nodes, Studio authoring M2) + // own them. The role inherits into the subtree (map entries, option + // interior); `name` stays writable (project rename is legitimate). let shapes = SlotShapeRegistry::default(); let (fs, mut registry) = clock_project(&shapes); let project = ArtifactLocation::file("/project.json"); @@ -1805,7 +1797,7 @@ mod tests { } #[test] - fn clock_controls_writable_transient_fields_accept_writes() { + fn clock_controls_debug_role_fields_accept_writes() { let shapes = SlotShapeRegistry::default(); let (fs, mut registry) = clock_project(&shapes); @@ -3614,7 +3606,7 @@ mod tests { /// Shape registry where `controls.rate` on the clock definition is /// read-only. No authored definition declares a non-writable field today, - /// so the fixture flips one policy in the real clock shape. + /// so the fixture flips one role in the real clock shape. fn shapes_with_read_only_rate() -> SlotShapeRegistry { let mut shape = ClockDef::slot_shape(); let SlotShape::Record { fields, .. } = &mut shape else { @@ -3631,7 +3623,7 @@ mod tests { .iter_mut() .find(|field| field.name.as_str() == "rate") .expect("rate field"); - rate.policy = SlotPolicy::read_only_transient(); + rate.role = SlotRole::Fixed; let mut shapes = SlotShapeRegistry::default(); shapes.replace_shape(ClockDef::SHAPE_ID, shape); diff --git a/lp-core/lpc-slot-macros/src/attr.rs b/lp-core/lpc-slot-macros/src/attr.rs index 95b1a4ba0..0817bed3b 100644 --- a/lp-core/lpc-slot-macros/src/attr.rs +++ b/lp-core/lpc-slot-macros/src/attr.rs @@ -5,7 +5,7 @@ use syn::{ pub(crate) struct ContainerAttrs { pub(crate) shape_id: Option, - pub(crate) default_policy: Option, + pub(crate) default_role: Option, pub(crate) enum_encoding: Option, pub(crate) rename_all: Option, } @@ -15,7 +15,7 @@ pub(crate) struct FieldAttrs { pub(crate) shape: FieldShapeAttr, pub(crate) direction: FieldDirectionAttr, pub(crate) merge: FieldMergeAttr, - pub(crate) policy: Option, + pub(crate) role: Option, pub(crate) default_bind: Option, } @@ -48,11 +48,10 @@ pub(crate) enum FieldMergeAttr { } #[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum SlotPolicyAttr { - ReadOnlyPersisted, - WritablePersisted, - ReadOnlyTransient, - WritableTransient, +pub(crate) enum SlotRoleAttr { + Setting, + Fixed, + Debug, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -69,7 +68,7 @@ pub(crate) enum RenameAllAttr { pub(crate) fn parse_container(attrs: &[Attribute]) -> Result { let mut parsed = ContainerAttrs { shape_id: None, - default_policy: None, + default_role: None, enum_encoding: None, rename_all: None, }; @@ -79,10 +78,10 @@ pub(crate) fn parse_container(attrs: &[Attribute]) -> Result { let value = meta.value()?; parsed.shape_id = Some(value.parse()?); Ok(()) - } else if meta.path.is_ident("default_policy") { + } else if meta.path.is_ident("default_role") { let value = meta.value()?; let value: LitStr = value.parse()?; - parsed.default_policy = Some(parse_policy(&value)?); + parsed.default_role = Some(parse_role(&value)?); Ok(()) } else if meta.path.is_ident("enum_encoding") { let value = meta.value()?; @@ -111,7 +110,7 @@ pub(crate) fn parse_field(attrs: &[Attribute]) -> Result { let mut shape = None; let mut direction = FieldDirectionAttr::Local; let mut merge = FieldMergeAttr::Latest; - let mut policy = None; + let mut role = None; let mut default_bind: Option = None; for attr in slot_attrs(attrs) { attr.parse_nested_meta(|meta| { @@ -161,10 +160,10 @@ pub(crate) fn parse_field(attrs: &[Attribute]) -> Result { let value: LitStr = value.parse()?; merge = parse_merge(&value)?; Ok(()) - } else if meta.path.is_ident("policy") { + } else if meta.path.is_ident("role") { let value = meta.value()?; let value: LitStr = value.parse()?; - policy = Some(parse_policy(&value)?); + role = Some(parse_role(&value)?); Ok(()) } else if meta.path.is_ident("option_ref") { let value = meta.value()?; @@ -189,7 +188,7 @@ pub(crate) fn parse_field(attrs: &[Attribute]) -> Result { shape: shape.unwrap_or(FieldShapeAttr::Infer), direction, merge, - policy, + role, default_bind, }) } @@ -419,34 +418,14 @@ pub(crate) fn field_semantics_tokens( } } -pub(crate) fn field_policy_tokens(policy: SlotPolicyAttr) -> TokenStream { - match policy { - SlotPolicyAttr::ReadOnlyPersisted => { - quote::quote! { ::lpc_model::SlotPolicy::read_only_persisted() } - } - SlotPolicyAttr::WritablePersisted => { - quote::quote! { ::lpc_model::SlotPolicy::writable_persisted() } - } - SlotPolicyAttr::ReadOnlyTransient => { - quote::quote! { ::lpc_model::SlotPolicy::read_only_transient() } - } - SlotPolicyAttr::WritableTransient => { - quote::quote! { ::lpc_model::SlotPolicy::writable_transient() } - } +pub(crate) fn field_role_tokens(role: SlotRoleAttr) -> TokenStream { + match role { + SlotRoleAttr::Setting => quote::quote! { ::lpc_model::SlotRole::Setting }, + SlotRoleAttr::Fixed => quote::quote! { ::lpc_model::SlotRole::Fixed }, + SlotRoleAttr::Debug => quote::quote! { ::lpc_model::SlotRole::Debug }, } } -/// Whether a field with this policy omits its dynamic mut-access arm. -/// -/// Only read-only **transient** fields (produced state) drop mut access: they -/// are never authored, so nothing legitimate writes them dynamically. A -/// read-only **persisted** field is still authored JSON — the dynamic reader -/// must be able to deserialize it — and its write protection is mutate-time -/// policy enforcement (`resolve_slot_policy`), not a codec-level hole. -pub(crate) fn policy_is_read_only_transient(policy: SlotPolicyAttr) -> bool { - matches!(policy, SlotPolicyAttr::ReadOnlyTransient) -} - fn slot_attrs(attrs: &[Attribute]) -> impl Iterator { attrs.iter().filter(|attr| attr.path().is_ident("slot")) } @@ -522,15 +501,14 @@ fn parse_merge(value: &LitStr) -> Result { } } -fn parse_policy(value: &LitStr) -> Result { +fn parse_role(value: &LitStr) -> Result { match value.value().as_str() { - "read_only_persisted" => Ok(SlotPolicyAttr::ReadOnlyPersisted), - "writable_persisted" => Ok(SlotPolicyAttr::WritablePersisted), - "read_only_transient" => Ok(SlotPolicyAttr::ReadOnlyTransient), - "writable_transient" => Ok(SlotPolicyAttr::WritableTransient), + "setting" => Ok(SlotRoleAttr::Setting), + "fixed" => Ok(SlotRoleAttr::Fixed), + "debug" => Ok(SlotRoleAttr::Debug), _ => Err(syn::Error::new_spanned( value, - "unsupported slot policy; expected \"read_only_persisted\", \"writable_persisted\", \"read_only_transient\", or \"writable_transient\"", + "unsupported slot role; expected \"setting\", \"fixed\", or \"debug\"", )), } } diff --git a/lp-core/lpc-slot-macros/src/slotted_enum.rs b/lp-core/lpc-slot-macros/src/slotted_enum.rs index ee0dec86e..23e9b4465 100644 --- a/lp-core/lpc-slot-macros/src/slotted_enum.rs +++ b/lp-core/lpc-slot-macros/src/slotted_enum.rs @@ -143,7 +143,7 @@ pub(crate) fn derive_enum( name: #field_name, shape: #static_shape_binding, semantics: ::lpc_model::SlotSemantics::local(), - policy: ::lpc_model::SlotPolicy::writable_persisted(), + role: ::lpc_model::SlotRole::Setting, default_bind: None, } }); diff --git a/lp-core/lpc-slot-macros/src/slotted_record.rs b/lp-core/lpc-slot-macros/src/slotted_record.rs index a9800a489..8e7d1bf7c 100644 --- a/lp-core/lpc-slot-macros/src/slotted_record.rs +++ b/lp-core/lpc-slot-macros/src/slotted_record.rs @@ -40,13 +40,13 @@ pub(crate) fn derive_record( let static_shape = attr::field_static_shape_tokens(&field_attr.shape, &field_ty); let static_shape_binding = format_ident!("__field_shape_{}", static_shape_bindings.len()); let semantics = attr::field_semantics_tokens(field_attr.direction, field_attr.merge); - let selected_policy = field_attr.policy.or(container_attrs.default_policy); - let policy = selected_policy - .map(attr::field_policy_tokens) - .unwrap_or_else(|| quote! { ::lpc_model::SlotPolicy::default() }); - let static_policy = selected_policy - .map(attr::field_policy_tokens) - .unwrap_or_else(|| quote! { ::lpc_model::SlotPolicy::writable_persisted() }); + let selected_role = field_attr.role.or(container_attrs.default_role); + let role = selected_role + .map(attr::field_role_tokens) + .unwrap_or_else(|| quote! { ::lpc_model::SlotRole::default() }); + let static_role = selected_role + .map(attr::field_role_tokens) + .unwrap_or_else(|| quote! { ::lpc_model::SlotRole::Setting }); let default_bind = match &field_attr.default_bind { Some(endpoint) => quote! { Some(#endpoint) }, None => quote! { None }, @@ -56,7 +56,7 @@ pub(crate) fn derive_record( #field_name, #shape, #semantics, - #policy, + #role, #default_bind, ) }); @@ -66,7 +66,7 @@ pub(crate) fn derive_record( name: #field_name, shape: #static_shape_binding, semantics: #semantics, - policy: #static_policy, + role: #static_role, default_bind: #default_bind, } }); @@ -78,7 +78,14 @@ pub(crate) fn derive_record( access_arms.push(quote! { #index => Some(#access), }); - if selected_policy.is_none_or(|policy| !attr::policy_is_read_only_transient(policy)) + // Only produced fields drop dynamic mut access: they are never + // authored, so nothing legitimate writes them dynamically + // (direction implies read-only regardless of role, D1). A + // read-only-but-persisted (`Fixed`) field is still authored + // JSON — the dynamic reader must be able to deserialize it — + // and its write protection is mutate-time role enforcement + // (`resolve_slot_role`), not a codec-level hole. + if field_attr.direction != attr::FieldDirectionAttr::Produced && let Some(mut_access) = attr::field_mut_access_tokens(&field_attr.shape, &field_ty, &field_ident) { diff --git a/lp-core/lpc-view/src/slot/mirror.rs b/lp-core/lpc-view/src/slot/mirror.rs index a394eccc2..316865c3c 100644 --- a/lp-core/lpc-view/src/slot/mirror.rs +++ b/lp-core/lpc-view/src/slot/mirror.rs @@ -200,19 +200,19 @@ mod tests { name: SlotName::parse("exposure").unwrap(), shape: SlotShape::value(LpType::F32), semantics: Default::default(), - policy: Default::default(), + role: Default::default(), default_bind: None, }], }, semantics: Default::default(), - policy: Default::default(), + role: Default::default(), default_bind: None, }, SlotFieldShape { name: SlotName::parse("compile_error").unwrap(), shape: SlotShape::value(LpType::String), semantics: Default::default(), - policy: Default::default(), + role: Default::default(), default_bind: None, }, ], diff --git a/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json b/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json index b5bc0af07..191b07269 100644 --- a/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json +++ b/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json @@ -3,10 +3,6 @@ "fields": [ { "name": "down", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -24,10 +20,6 @@ }, { "name": "held", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -45,10 +37,6 @@ }, { "name": "up", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.clock.clock_def.ClockDef.json b/schemas/shapes/lpc_model.nodes.clock.clock_def.ClockDef.json index b494b16c0..dec451b8a 100644 --- a/schemas/shapes/lpc_model.nodes.clock.clock_def.ClockDef.json +++ b/schemas/shapes/lpc_model.nodes.clock.clock_def.ClockDef.json @@ -22,9 +22,7 @@ "fields": [ { "name": "running", - "policy": { - "persistence": "transient" - }, + "role": "debug", "shape": { "value": { "shape": { @@ -38,9 +36,7 @@ }, { "name": "rate", - "policy": { - "persistence": "transient" - }, + "role": "debug", "shape": { "value": { "shape": { @@ -60,9 +56,7 @@ }, { "name": "scrub_offset_seconds", - "policy": { - "persistence": "transient" - }, + "role": "debug", "shape": { "value": { "shape": { diff --git a/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json b/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json index a3f0bbbc9..f49b291ea 100644 --- a/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json +++ b/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json @@ -4,10 +4,6 @@ { "default_bind": "bus:time", "name": "seconds", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -24,10 +20,6 @@ }, { "name": "delta_seconds", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json b/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json index d80af4db1..540e24cee 100644 --- a/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json +++ b/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json @@ -4,10 +4,6 @@ { "default_bind": "bus:control.out", "name": "output", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -26,10 +22,6 @@ }, { "name": "estimated_draw_ma", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -46,10 +38,6 @@ }, { "name": "power_scale", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -66,10 +54,6 @@ }, { "name": "power_budget_ma", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json b/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json index 2d1b30470..077772a4b 100644 --- a/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json +++ b/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json @@ -4,10 +4,6 @@ { "default_bind": "bus:visual.out", "name": "output", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json b/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json index 256cbfaf9..100fbaf46 100644 --- a/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json +++ b/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json @@ -4,10 +4,6 @@ { "default_bind": "bus:visual.out", "name": "output", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -26,10 +22,6 @@ }, { "name": "entry_time", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -46,10 +38,6 @@ }, { "name": "entry_progress", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, @@ -66,10 +54,6 @@ }, { "name": "active_entry", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json b/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json index 5bb485555..2e115d260 100644 --- a/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json +++ b/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json @@ -3,9 +3,7 @@ "fields": [ { "name": "format", - "policy": { - "writable": false - }, + "role": "fixed", "shape": { "option": { "meta": {}, @@ -24,9 +22,7 @@ }, { "name": "uid", - "policy": { - "writable": false - }, + "role": "fixed", "shape": { "option": { "meta": {}, @@ -63,9 +59,7 @@ }, { "name": "nodes", - "policy": { - "writable": false - }, + "role": "fixed", "shape": { "map": { "key": "string", diff --git a/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json b/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json index e39877593..a49065f52 100644 --- a/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json +++ b/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json @@ -3,10 +3,6 @@ "fields": [ { "name": "output", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json b/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json index 2d1b30470..077772a4b 100644 --- a/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json +++ b/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json @@ -4,10 +4,6 @@ { "default_bind": "bus:visual.out", "name": "output", - "policy": { - "persistence": "transient", - "writable": false - }, "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json b/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json index c783210d8..440bd8c27 100644 --- a/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json +++ b/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json @@ -3,6 +3,9 @@ "fields": [ { "name": "width", + "semantics": { + "direction": "produced" + }, "shape": { "value": { "shape": { @@ -16,6 +19,9 @@ }, { "name": "height", + "semantics": { + "direction": "produced" + }, "shape": { "value": { "shape": { @@ -29,6 +35,9 @@ }, { "name": "format", + "semantics": { + "direction": "produced" + }, "shape": { "value": { "shape": { From 664e6926f566c674d3a9ca4419b54481b11a1004 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:03:29 +0000 Subject: [PATCH 2/2] chore(studio): auto-refresh story baselines [validate-stories] Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30727837839 --- ...ration__node-ui__status-indicators__sm.png | Bin 58309 -> 58284 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png index 2d41f7663be9537da272a7026613002576ec603c..5b6cc790840d2045de57e315851b291c7bcc2bde 100644 GIT binary patch delta 23034 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 delta 23059 zcmXV#bzBtR*T(^ol8^-{0f|{)0ZA#55Ej^7LRd;f8YHAUghiy5X6UY^Q#zFvL>iSY zN$FAnfyeLf_563{&dfdM&YbhP?>STP=~myTTX6t>)O+YU79;;IUz4mpjwgKzU?Q|C z#ab~wLOkkuI`b}^OX(Dyp%lc0m<*)|x}dSse8+at89LnOw&1&6qH+-W!1c-RC%-KA z4h&vrc^q)D**=p2JnSK$H*w$9xBltAu0KxX^WU(*v&z~y+OzYSlREBaUzI>-7`T3L zTw7E{GGCG7j#=JFvkj>!o`|!9PN&B%_suSkTR>Z}ht)s1$JJsA= zElvs_gS>d>3LU00$BO;K`?L9ADlhRIy{RM2cMWfQbu=5F> zh>$Rb(6O9-d+tj`-5*+Gb1njjt{>GQw-@2vl#>Wll(Qm&jNIdCsRXBmeM7J2oUM0H zQgLV%0}UtFXByM~S3l_YzY{r5au%!@-xe$lPH{ARhg*2fSy~AP;M`aP?PT|BS?t zhBO;`eaTg;i=G)Q^`@`9xY&4D!?i2)7I#Ww;ev2#YoJIqq(H`;>OcM{-Z9)aNu73} zNpBwX2g|?ad2_d*EJT*%TTv55^M{WN_nUH5T1XN3pE*mD%8Us${ci)hbq-1Q04=e( zM$%QYqief_bqWrwI3+SMUZ+~8HoYH1t82)c7M~bD6-U_eR~Grq=RIebFBu}gNFMDs z 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 P?Z5+HDM%Gc7=HRc|2d;5