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 7f6ea6c9c..1a96c3a1b 100644 --- a/lp-app/lpa-studio-web/src/app/story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/story_fixtures.rs @@ -355,7 +355,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-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 2d41f7663..5b6cc7908 100644 Binary files a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png differ 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 565f74a64..a585bd2a7 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 9846ec5dc..8db6dcdb8 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -166,19 +166,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": {