Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lp-app/lpa-studio-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 22 additions & 17 deletions lp-app/lpa-studio-core/src/app/project/project_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -1186,22 +1186,26 @@ 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| {
let key = root_slot_key(node.target().node_id, address.root.name());
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
Expand Down Expand Up @@ -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::{
Expand Down Expand Up @@ -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<WireServerMessage>,
) -> (
Expand Down Expand Up @@ -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(
Expand Down
80 changes: 54 additions & 26 deletions lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs
Original file line number Diff line number Diff line change
@@ -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,
};

Expand Down Expand Up @@ -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 {
Expand All @@ -87,7 +89,7 @@ pub struct SlotController {
body: SlotControllerBody,
revision: Option<Revision>,
semantics: SlotSemantics,
policy: SlotPolicy,
role: SlotRole,
value_shape: Option<SlotValueShape>,
source: UiSlotSourceState,
publish: Option<UiBindingEndpoint>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
})
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion lp-app/lpa-studio-web/src/app/story_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ pub(crate) fn project_workspace_nodes() -> Vec<UiNodeView> {

/// 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<UiConfigSlot> {
use lpa_studio_core::UiSlotFieldState;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions lp-cli/src/debug_ui/slot_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -67,10 +67,10 @@ pub(crate) fn render_slot_value_editor(
root: &str,
path: &SlotPath,
shape: &SlotValueShape,
policy: SlotPolicy,
writable: bool,
value: &LpValue,
) -> Option<LpValue> {
if !policy.writable {
if !writable {
return None;
}

Expand Down
Loading
Loading