diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs
index 49236637..a63c0c3d 100644
--- a/crates/oa-desktop/src/forms.rs
+++ b/crates/oa-desktop/src/forms.rs
@@ -337,6 +337,8 @@ fn friendly(name: &str) -> String {
"Named" => "Data type".into(),
"body" => "Occurrence type".into(),
"diagram" => "Diagram".into(),
+ "StateMachine" => "State machine".into(),
+ "machine" => "State machine context".into(),
"uri" => "Artifact URI".into(),
"definition" => "Data type definition".into(),
"steps" | "occurrences" => "Ordered occurrences".into(),
@@ -408,6 +410,7 @@ fn reference(path: &str) -> bool {
| "sa"
| "Named"
| "diagram"
+ | "machine"
) || matches!(
parent,
"items"
@@ -542,7 +545,12 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec {
let layer = element
.as_ref()
.map(Element::layer)
- .or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())));
+ .or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())))
+ .or_else(|| {
+ (draft.kind == RecordKind::Diagram)
+ .then(|| serde_json::from_value::(value["layer"].clone()).ok())
+ .flatten()
+ });
let field = path.rsplit('/').next().unwrap_or_default();
if field == "edge" {
return project
@@ -567,6 +575,8 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec {
};
if field == "diagram" {
+ // Keep destinations selectable before retargeting the element. The
+ // element picker below uses the draft destination's membership rules.
return project.diagrams.keys().copied().collect();
}
if field == "owner" || list == "constrained" {
@@ -688,18 +698,16 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec {
.and_then(Value::as_str)
.and_then(|id| id.parse().ok())
.unwrap_or(diagram);
- return project.diagrams.get(&diagram).is_some_and(|d| {
- (d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
- && !d
- .nodes
- .values()
- .any(|n| n.id != draft.original && n.element == candidate.id)
- });
+ return project
+ .diagrams
+ .get(&diagram)
+ .is_some_and(|d| placement_allowed(project, d, candidate, draft.original));
}
if !same_layer(candidate) {
return false;
}
match field {
+ "machine" => candidate.kind() == Kind::StateMachine,
"parent" => element.as_ref().is_some_and(|child| {
if candidate.id == draft.original
|| candidate.id == child.id
@@ -1002,6 +1010,18 @@ fn group_label(path: &str) -> Option<&'static str> {
}
}
+fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool {
+ (diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
+ && diagram
+ .projection
+ .state_machine()
+ .is_none_or(|machine| projection::state_machine_contains(project, machine, element.id))
+ && !diagram
+ .nodes
+ .values()
+ .any(|node| node.id != current && node.element == element.id)
+}
+
fn unit_body(path: &str, value: &Value) -> bool {
value.is_string()
&& matches!(
@@ -1013,17 +1033,31 @@ fn unit_body(path: &str, value: &Value) -> bool {
/// Closed choices for the concrete model enums. No runtime schema/plugin layer.
fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> {
let field = path.rsplit('/').next().unwrap_or_default();
- let words: &[&str] = match field {
- "side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
- "layer" => &["Oa", "Sa"],
- "projection" => &[
+ if path == "/projection" && kind == RecordKind::Diagram {
+ let mut choices = [
"Overview",
"Decomposition",
"Allocation",
"Traceability",
"FunctionalDataflow",
"Capabilities",
- ],
+ ]
+ .into_iter()
+ .map(|name| (friendly(name), json!(name)))
+ .collect::>();
+ choices.push((
+ "State machine".into(),
+ if value.get("StateMachine").is_some() {
+ value.clone()
+ } else {
+ json!({"StateMachine": {"machine": Id::nil()}})
+ },
+ ));
+ return choices;
+ }
+ let words: &[&str] = match field {
+ "side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
+ "layer" => &["Oa", "Sa"],
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
"viewpoint" if path.contains("/Oa/") => &[
"OperationalActivity",
@@ -1515,6 +1549,162 @@ mod tests {
reference_ids(draft, project, path)
}
+ fn state_diagram_form_fixture() -> Project {
+ language::parse(
+ r#"arcadia 3; project state_forms "State forms" {
+ oa_entity crew "Crew" {}
+ oa_state_machine modes "Modes" { parent @crew; }
+ oa_region main "Main" { parent @modes; }
+ oa_state ready "Ready" { parent @main; }
+ oa_region nested "Nested" { parent @ready; }
+ oa_state leaf "Leaf" { parent @nested; }
+ oa_state_machine other "Other modes" { parent @crew; }
+ oa_region other_region "Other region" { parent @other; }
+ oa_state foreign "Foreign" { parent @other_region; }
+ sa_system system "System" {}
+ sa_state_machine sa_modes "System modes" { parent @system; }
+ diagram overview "Overview" {}
+ diagram states "States" {
+ projection { StateMachine { machine @modes; }; };
+ }
+ diagram other_states "Other states" {
+ projection { StateMachine { machine @other; }; };
+ }
+ diagram duplicate "Duplicate" {
+ projection { StateMachine { machine @modes; }; };
+ }
+ node ready_view { diagram @states; element @ready; }
+ node region_view { diagram @states; element @main; }
+ node duplicate_view { diagram @duplicate; element @ready; }
+ }"#,
+ )
+ .unwrap()
+ }
+
+ fn alias_id(project: &Project, alias: &str) -> Id {
+ project
+ .identities()
+ .into_iter()
+ .find(|(_, candidate)| *candidate == alias)
+ .unwrap()
+ .0
+ }
+
+ #[test]
+ fn state_projection_form_keeps_string_variants_and_requires_a_typed_context() {
+ let project = state_diagram_form_fixture();
+ let diagram = alias_id(&project, "overview");
+ let machine = alias_id(&project, "modes");
+ let mut draft = Draft::from_project(&project, diagram).unwrap();
+ let choices = variants("/projection", &draft.value["projection"], draft.kind);
+ assert_eq!(choices.len(), 7);
+ for (_, value) in &choices[..6] {
+ let decoded = serde_json::from_value::(value.clone()).unwrap();
+ assert!(value.is_string());
+ assert_eq!(json!(decoded), *value);
+ }
+ draft.shape("/projection", choices[6].1.clone()).unwrap();
+ assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
+ let path = "/projection/StateMachine/machine";
+ assert!(reference(path));
+ assert!(!optional_reference(path));
+ assert_eq!(
+ ids(&draft, &project, path)
+ .into_iter()
+ .collect::>(),
+ BTreeSet::from([machine, alias_id(&project, "other")])
+ );
+ draft.shape(path, json!(machine)).unwrap();
+ let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
+ assert_eq!(
+ accepted.diagrams[&diagram].projection.state_machine(),
+ Some(machine)
+ );
+ assert_eq!(accepted.elements, project.elements);
+ assert!(accepted.diagrams[&diagram].nodes.is_empty());
+ assert_eq!(
+ variants("/projection", &draft.value["projection"], draft.kind)[6].1,
+ json!({"StateMachine": {"machine": machine}})
+ );
+
+ draft.shape("/layer", json!("Sa")).unwrap();
+ assert_eq!(
+ ids(&draft, &project, path),
+ vec![alias_id(&project, "sa_modes")]
+ );
+ assert!(
+ reference_choices(&draft, &project, path, machine)
+ .iter()
+ .any(|choice| {
+ choice.id == machine && choice.label.starts_with("Current choice needs review")
+ })
+ );
+ assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
+ assert_eq!(
+ draft.materialize().unwrap().pointer(path),
+ Some(&json!(machine))
+ );
+ }
+
+ #[test]
+ fn state_placement_form_uses_draft_destination_membership_without_changing_other_views() {
+ let project = state_diagram_form_fixture();
+ let node = alias_id(&project, "ready_view");
+ let mut draft = Draft::from_project(&project, node).unwrap();
+ let offered = ids(&draft, &project, "/element");
+ for alias in ["modes", "ready", "nested", "leaf"] {
+ assert!(offered.contains(&alias_id(&project, alias)), "{alias}");
+ }
+ for alias in [
+ "main",
+ "other",
+ "other_region",
+ "foreign",
+ "crew",
+ "sa_modes",
+ ] {
+ assert!(!offered.contains(&alias_id(&project, alias)), "{alias}");
+ }
+ assert_eq!(
+ ids(&draft, &project, "/diagram")
+ .into_iter()
+ .collect::>(),
+ project.diagrams.keys().copied().collect::>()
+ );
+
+ // A destination must remain selectable before its compatible element;
+ // the current element is retained visibly until the draft is complete.
+
+ draft
+ .shape("/diagram", json!(alias_id(&project, "other_states")))
+ .unwrap();
+ let ready = alias_id(&project, "ready");
+ assert!(!ids(&draft, &project, "/element").contains(&ready));
+ assert!(
+ reference_choices(&draft, &project, "/element", ready)
+ .iter()
+ .any(|choice| {
+ choice.id == ready && choice.label.starts_with("Current choice needs review")
+ })
+ );
+ assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
+ draft
+ .shape("/element", json!(alias_id(&project, "foreign")))
+ .unwrap();
+ let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
+ assert!(
+ accepted.diagrams[&alias_id(&project, "other_states")]
+ .nodes
+ .contains_key(&node)
+ );
+
+ draft
+ .shape("/diagram", json!(alias_id(&project, "overview")))
+ .unwrap();
+ assert!(ids(&draft, &project, "/element").contains(&alias_id(&project, "crew")));
+ assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok());
+ }
+
#[test]
fn editing_allocation_offers_other_performers_without_counting_itself_twice() {
let mut p = Project::empty();
diff --git a/crates/oa-server/tests/state_representation.rs b/crates/oa-server/tests/state_representation.rs
new file mode 100644
index 00000000..3bfedc40
--- /dev/null
+++ b/crates/oa-server/tests/state_representation.rs
@@ -0,0 +1,339 @@
+//! The public state-view representation is shared by the language, REST and UI.
+use oa_core::{Command, Id, Kind, Layer, Project, ProjectionKind, language, validate_candidate};
+use oa_server::{
+ app,
+ store::{Store, StoredProject},
+};
+use reqwest::{Client, StatusCode};
+use serde_json::{Value, json};
+use std::{path::Path, sync::Arc};
+use tokio::{sync::oneshot, task::JoinHandle};
+
+struct Server {
+ base: String,
+ client: Client,
+ stop: oneshot::Sender<()>,
+ task: JoinHandle<()>,
+}
+
+impl Server {
+ async fn start(path: &Path) -> Self {
+ let path = path.to_owned();
+ let store = tokio::task::spawn_blocking(move || Store::open(path))
+ .await
+ .unwrap()
+ .unwrap();
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let base = format!("http://{}/api/v1", listener.local_addr().unwrap());
+ let (stop, done) = oneshot::channel();
+ let task = tokio::spawn(async move {
+ axum::serve(listener, app(Arc::new(store), None))
+ .with_graceful_shutdown(async {
+ let _ = done.await;
+ })
+ .await
+ .unwrap();
+ });
+ Self {
+ base,
+ client: Client::new(),
+ stop,
+ task,
+ }
+ }
+
+ async fn get(&self, id: Id) -> StoredProject {
+ self.client
+ .get(format!("{}/projects/{id}", self.base))
+ .send()
+ .await
+ .unwrap()
+ .error_for_status()
+ .unwrap()
+ .json()
+ .await
+ .unwrap()
+ }
+
+ async fn edit(&self, current: &StoredProject, projection: Value) -> reqwest::Response {
+ let diagram = current.model.diagrams.values().next().unwrap();
+ let mut record = json!(diagram);
+ record["projection"] = projection;
+ self.client
+ .post(format!(
+ "{}/projects/{}/transactions",
+ self.base, current.resource_id
+ ))
+ .header("if-match", current.etag())
+ .json(&json!({"commands":[{"EditDiagram":[diagram.id, record]}],"dry_run":false}))
+ .send()
+ .await
+ .unwrap()
+ }
+
+ async fn stop(self) {
+ let _ = self.stop.send(());
+ drop(self.client);
+ tokio::time::timeout(std::time::Duration::from_secs(5), self.task)
+ .await
+ .unwrap()
+ .unwrap();
+ }
+}
+
+fn id(project: &Project, alias: &str) -> Id {
+ project
+ .identities()
+ .into_iter()
+ .find(|(_, a)| *a == alias)
+ .unwrap()
+ .0
+}
+
+fn documented_model() -> Project {
+ // Parse the actual complete example, so an invalid published constructor or
+ // reference cannot drift away from the server's accepted representation.
+ let contract = include_str!("../../../docs/language.rst");
+ let example = contract
+ .split("This complete source example places one state from an existing machine:")
+ .nth(1)
+ .unwrap()
+ .split(".. code-block:: text")
+ .nth(1)
+ .unwrap();
+ let source = example
+ .lines()
+ .skip_while(|line| line.trim().is_empty())
+ .take_while(|line| line.starts_with(" ") || line.is_empty())
+ .map(|line| line.strip_prefix(" ").unwrap_or(line))
+ .collect::>()
+ .join("\n");
+ let model = language::parse(&source).unwrap();
+ validate_candidate(&model).unwrap();
+ model
+}
+
+fn assert_pair(current: &StoredProject) {
+ use oa_core::source_project::{ProjectLimits, compile_bundle};
+ let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap();
+ assert_eq!(compiled.decoded.project, current.model);
+ assert_eq!(compiled.bundle, current.source_project);
+}
+
+#[tokio::test]
+async fn state_view_source_json_transactions_and_restart_share_the_published_contract() {
+ let mut model = documented_model();
+ let machine = id(&model, "modes");
+ let crew = id(&model, "crew");
+ let other = model
+ .add_element(Kind::StateMachine, Layer::Oa, "Other modes", Some(crew))
+ .unwrap();
+ let system = model
+ .add_element(Kind::System, Layer::Sa, "System", None)
+ .unwrap();
+ let wrong_layer = model
+ .add_element(Kind::StateMachine, Layer::Sa, "System modes", Some(system))
+ .unwrap();
+ let diagram = id(&model, "mode_view");
+ let directory = tempfile::tempdir().unwrap();
+ let server = Server::start(directory.path()).await;
+ let response = server
+ .client
+ .post(format!("{}/projects/import", server.base))
+ .json(&model)
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::CREATED);
+ let imported: Value = response.json().await.unwrap();
+ let resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
+ let original = server.get(resource).await;
+ assert_eq!(original.model, model);
+ assert_pair(&original);
+
+ let source = server
+ .client
+ .get(format!("{}/projects/{resource}/source", server.base))
+ .send()
+ .await
+ .unwrap()
+ .error_for_status()
+ .unwrap()
+ .text()
+ .await
+ .unwrap();
+ assert!(source.contains("machine @modes;"));
+ assert_eq!(language::parse(&source).unwrap(), model);
+ let response = server
+ .client
+ .post(format!("{}/projects/import", server.base))
+ .header("content-type", "text/plain")
+ .body(source)
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::CREATED);
+ let imported: Value = response.json().await.unwrap();
+ let source_resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
+ assert_eq!(server.get(source_resource).await.model, model);
+
+ for malformed in [
+ json!("StateMachine"),
+ json!({"StateMachine":{}}),
+ json!({"StateMachine":{"machine":null}}),
+ json!({"StateMachine":{"machine":"not-a-uuid"}}),
+ json!({"StateMachine":{"machine":machine,"extra":1}}),
+ json!({"StateMachine":{"machine":machine},"Overview":null}),
+ ] {
+ assert_eq!(
+ server.edit(&original, malformed.clone()).await.status(),
+ StatusCode::BAD_REQUEST,
+ "{malformed}"
+ );
+ }
+ for (context, code) in [
+ (Id::nil(), "state_diagram_context"),
+ (crew, "state_diagram_context"),
+ (wrong_layer, "state_diagram_context"),
+ (other, "state_diagram_member"),
+ ] {
+ let response = server
+ .edit(&original, json!({"StateMachine":{"machine":context}}))
+ .await;
+ assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
+ let problem: Value = response.json().await.unwrap();
+ assert_eq!(problem["code"], "command_rejected");
+ // Commands report the shared validator's first failure as detail.
+ // Candidate validation provides the structured diagnostic collection.
+ let mut candidate = original.model.clone();
+ candidate.diagrams.get_mut(&diagram).unwrap().projection =
+ ProjectionKind::StateMachine { machine: context };
+ let checked = server
+ .client
+ .post(format!("{}/projects/{resource}/validation", server.base))
+ .json(&json!({"format":"json","model":candidate}))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(checked.status(), StatusCode::OK);
+ let checked: Value = checked.json().await.unwrap();
+ assert_eq!(checked["valid"], false);
+ assert!(
+ checked["issues"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|issue| issue["code"] == code),
+ "{checked}"
+ );
+ }
+ let unchanged = server.get(resource).await;
+ assert_eq!(json!(unchanged), json!(original));
+
+ // A context change and removal of incompatible placements is one accepted
+ // diagram edit; no semantic record or implied replacement placement appears.
+ let mut changed = model.diagrams[&diagram].clone();
+ changed.projection = ProjectionKind::StateMachine { machine: other };
+ changed.nodes.clear();
+ let response = server
+ .client
+ .post(format!("{}/projects/{resource}/transactions", server.base))
+ .header("if-match", original.etag())
+ .json(&json!({"commands":[Command::EditDiagram(diagram, changed.clone())],"dry_run":false}))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::OK);
+ let current = server.get(resource).await;
+ assert_eq!(current.revision, original.revision + 1);
+ assert_eq!(current.model.diagrams[&diagram], changed);
+ assert_eq!(current.model.elements, model.elements);
+ assert_pair(¤t);
+
+ // Source writes use the identical context reference and conditional commit.
+ let source = language::format(¤t.model);
+ let invalid = source.replace("machine @other_modes;", "machine @crew;");
+ assert_ne!(invalid, source);
+ let response = server
+ .client
+ .put(format!("{}/projects/{resource}/source", server.base))
+ .header("if-match", current.etag())
+ .header("content-type", "text/plain")
+ .body(invalid)
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
+ assert_eq!(json!(server.get(resource).await), json!(current));
+ let response = server
+ .client
+ .put(format!("{}/projects/{resource}/source", server.base))
+ .header("if-match", current.etag())
+ .header("content-type", "text/plain")
+ .body(source.replace("machine @other_modes;", "machine @modes;"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::OK);
+ let final_state = server.get(resource).await;
+ assert_eq!(final_state.revision, current.revision + 1);
+ assert_eq!(
+ final_state.model.diagrams[&diagram]
+ .projection
+ .state_machine(),
+ Some(machine)
+ );
+ assert!(final_state.model.diagrams[&diagram].nodes.is_empty());
+ assert_pair(&final_state);
+ server.stop().await;
+ let server = Server::start(directory.path()).await;
+ assert_eq!(json!(server.get(resource).await), json!(final_state));
+ server.stop().await;
+}
+
+#[tokio::test]
+async fn served_state_projection_schema_preserves_closed_string_and_payload_variants() {
+ let directory = tempfile::tempdir().unwrap();
+ let server = Server::start(directory.path()).await;
+ let spec: Value = server
+ .client
+ .get(format!("{}/openapi.json", server.base))
+ .send()
+ .await
+ .unwrap()
+ .error_for_status()
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
+ let schemas = &spec["components"]["schemas"];
+ assert_eq!(
+ schemas["Diagram"]["properties"]["projection"]["$ref"],
+ "#/components/schemas/ProjectionKind"
+ );
+ let variants = schemas["ProjectionKind"]["oneOf"].as_array().unwrap();
+ assert_eq!(variants.len(), 2);
+ let strings = variants[0]["enum"].as_array().unwrap();
+ assert_eq!(strings.len(), 6);
+ for value in strings {
+ assert_eq!(
+ json!(serde_json::from_value::(value.clone()).unwrap()),
+ *value
+ );
+ }
+ assert!(!strings.contains(&json!("StateMachine")));
+ assert_eq!(variants[1]["required"], json!(["StateMachine"]));
+ assert_eq!(variants[1]["additionalProperties"], false);
+ let payload = &variants[1]["properties"]["StateMachine"];
+ assert_eq!(payload["required"], json!(["machine"]));
+ assert_eq!(payload["additionalProperties"], false);
+ assert_eq!(payload["properties"]["machine"]["type"], "string");
+ assert_eq!(payload["properties"]["machine"]["format"], "uuid");
+ for example in schemas["ProjectionKind"]["examples"].as_array().unwrap() {
+ assert_eq!(
+ json!(serde_json::from_value::(example.clone()).unwrap()),
+ *example
+ );
+ }
+ server.stop().await;
+}
diff --git a/docs/api.html b/docs/api.html
index de022d65..a8eb873b 100644
--- a/docs/api.html
+++ b/docs/api.html
@@ -987,8 +987,9 @@ reinterpret old mixed lifelines as a strict new viewpoint.
or:
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
A well-formed validation request returns 200 with valid and issues even
-if its candidate is invalid. A malformed request envelope or JSON model record
-is a 400. Source diagnostics include one-based line and column according to the
+if its candidate is invalid, including a model record that fails typed decoding.
+Malformed request JSON or an invalid format/payload envelope is a 400.
+Source diagnostics include one-based line and column according to the
shared language parser. Candidate validation does not replace current state.
Source-project validation locations refer to the submitted bytes before UUID
insertion. Successful source-project PUT diagnostics refer to the returned
@@ -1018,6 +1019,35 @@ introduce a completion transition. Invalid typed references reject the whole
candidate and preserve revision/ETag. All writes retain the same conditional
commit and disk persistence contract as other semantic records.
+
+State diagram representation
+Diagram.projection keeps the existing string values Overview,
+Decomposition, Allocation, Traceability, FunctionalDataflow and
+Capabilities. A dedicated state view instead uses the required payload:
+{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}
+The UUID MUST identify an existing StateMachine in the diagram's layer.
+Only that machine, its Regions and its state vertices may be placed, including
+nested members. Empty and partial views are valid. No machine, Region, vertex
+or placement is synthesized by accepting or reading a diagram. Source uses
+projection { StateMachine { machine @modes; }; }; with the same reference.
+Use the ordinary CreateDiagram or EditDiagram transaction commands,
+JSON model import/replacement, or source/source-project writes. A bare
+"StateMachine", missing or null machine, malformed UUID, or additional
+payload field is malformed JSON model data (400). A missing, wrong-kind or
+cross-layer machine produces the semantic issue state_diagram_context;
+an incompatible placement produces state_diagram_member. These reject
+writes with 422. Transaction rejections use the command_rejected problem
+code and include the first core failure in detail; candidate validation
+provides the structured issues collection.
+Source syntax errors use the source rejection response (422) with locations.
+The validation endpoint returns 200 with valid:false for a well-formed
+invalid candidate, as described above.
+When changing a diagram's machine or selecting StateMachine for an existing
+diagram, remove or relocate incompatible placements in the same transaction.
+Other projections continue to preserve hidden placements. Rejected writes
+retain the accepted model, source bundle, revision and ETag. Reading a state
+view has no semantic or persistence side effects.
+
Cross-layer transitions
Preview accepts the shared TransitionRequest record, including direction
diff --git a/docs/api.rst b/docs/api.rst
index 61c0fbb5..a533a137 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -386,8 +386,9 @@ or::
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
-if its candidate is invalid. A malformed request envelope or JSON model record
-is a 400. Source diagnostics include one-based line and column according to the
+if its candidate is invalid, including a model record that fails typed decoding.
+Malformed request JSON or an invalid format/payload envelope is a 400.
+Source diagnostics include one-based line and column according to the
shared language parser. Candidate validation does not replace current state.
Source-project validation locations refer to the submitted bytes before UUID
insertion. Successful source-project PUT diagnostics refer to the returned
@@ -420,6 +421,40 @@ introduce a completion transition. Invalid typed references reject the whole
candidate and preserve revision/ETag. All writes retain the same conditional
commit and disk persistence contract as other semantic records.
+State diagram representation
+----------------------------
+
+``Diagram.projection`` keeps the existing string values ``Overview``,
+``Decomposition``, ``Allocation``, ``Traceability``, ``FunctionalDataflow`` and
+``Capabilities``. A dedicated state view instead uses the required payload::
+
+ {"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}
+
+The UUID MUST identify an existing StateMachine in the diagram's layer.
+Only that machine, its Regions and its state vertices may be placed, including
+nested members. Empty and partial views are valid. No machine, Region, vertex
+or placement is synthesized by accepting or reading a diagram. Source uses
+``projection { StateMachine { machine @modes; }; };`` with the same reference.
+
+Use the ordinary ``CreateDiagram`` or ``EditDiagram`` transaction commands,
+JSON model import/replacement, or source/source-project writes. A bare
+``"StateMachine"``, missing or null ``machine``, malformed UUID, or additional
+payload field is malformed JSON model data (400). A missing, wrong-kind or
+cross-layer machine produces the semantic issue ``state_diagram_context``;
+an incompatible placement produces ``state_diagram_member``. These reject
+writes with 422. Transaction rejections use the ``command_rejected`` problem
+code and include the first core failure in ``detail``; candidate validation
+provides the structured ``issues`` collection.
+Source syntax errors use the source rejection response (422) with locations.
+The validation endpoint returns 200 with ``valid:false`` for a well-formed
+invalid candidate, as described above.
+
+When changing a diagram's machine or selecting StateMachine for an existing
+diagram, remove or relocate incompatible placements in the same transaction.
+Other projections continue to preserve hidden placements. Rejected writes
+retain the accepted model, source bundle, revision and ETag. Reading a state
+view has no semantic or persistence side effects.
+
Cross-layer transitions
-----------------------
diff --git a/docs/language.html b/docs/language.html
index 5dfa1ed3..99b82952 100644
--- a/docs/language.html
+++ b/docs/language.html
@@ -1285,17 +1285,47 @@ Studio profile; no running history state is persisted or synthesized.
5. Diagrams, artifacts, and cross-layer traceability
diagram has name, identity, alias, description, layer (Oa by default),
projection (Overview by default), and routes (empty list by default).
-Other projections are Decomposition,
-Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
-other diagrams show their chosen layer.
+The string projections are Overview, Decomposition, Allocation,
+FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
+constructor with one required machine reference. Traceability diagrams
+can show both layers; other diagrams show their chosen layer.
Allocation is an architecture view: it shows participants and work, nests
allocated work under its performer, and displays interactions, functional
exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
and its exchanges. Capabilities shows participants, roles, missions and
capabilities with their involvement, exploitation, include, extend and
generalization links. Decomposition and Overview retain general containment;
-Overview also exposes all relationships. Changing the projection hides
+Overview also exposes all relationships. These string projections hide
inapplicable placements without deleting them or changing semantic records.
+A StateMachine projection MUST reference an existing StateMachine in the
+diagram's layer. Its placements MUST refer to that machine, its Regions, or
+its state vertices, including nested Regions and vertices. Events, other
+machines and their members, and unrelated semantic elements are not valid
+placements in this view. Empty and partial state views are valid; neither
+creating nor reading a view synthesizes semantic elements or placements.
+Changing an existing diagram to StateMachine, or changing its machine, MUST
+remove or relocate incompatible placements in the same transaction. Context
+errors use state_diagram_context; incompatible placements use
+state_diagram_member. Neither case silently hides invalid members.
+This complete source example places one state from an existing machine:
+arcadia 3;
+project operating "Operating modes" {
+ oa_entity crew "Crew" {}
+ oa_state_machine modes "Modes" { parent @crew; }
+ oa_region main "Main region" { parent @modes; }
+ oa_state ready "Ready" { parent @main; }
+ diagram mode_view "Operating modes" {
+ layer Oa;
+ projection { StateMachine { machine @modes; }; };
+ }
+ node ready_view { diagram @mode_view; element @ready; }
+}
+The JSON projection is
+{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}},
+where the UUID is the referenced machine's identity. A bare "StateMachine",
+a missing or null machine, an invalid UUID, or additional payload fields
+is malformed. Existing projections retain their string representation, for
+example "Overview" in JSON and projection Overview; in source.
Ports are boundary anchors of visible owners, with their existing
semantic identity. An explicitly placed port is not rendered as a separate
large node. Functional exchanges use their declared source/target ports;
@@ -1312,8 +1342,8 @@ allocation, or realization. Hiding an element from a diagram must remain
distinguishable from deleting it from the model.
Changing a node's diagram preserves the placement identity, alias, coordinates,
size, collapse state, manual port anchors, and attached artifacts. The destination
-must accept the element's layer and must not already contain another placement
-of that element.
+must accept the element's layer, satisfy StateMachine membership when applicable,
+and must not already contain another placement of that element.
Each port_anchors entry is a PortAnchor record with required port
reference, side (Left, Right, Top, or Bottom), and offset (finite
binary32 from 0 through 1). It has no separate identity or alias. The port MUST
diff --git a/docs/language.rst b/docs/language.rst
index 73f31a5a..12cd8d97 100644
--- a/docs/language.rst
+++ b/docs/language.rst
@@ -625,9 +625,10 @@ Studio profile; no running history state is persisted or synthesized.
``diagram`` has name, identity, alias, description, ``layer`` (Oa by default),
``projection`` (Overview by default), and ``routes`` (empty list by default).
-Other projections are Decomposition,
-Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
-other diagrams show their chosen layer.
+The string projections are Overview, Decomposition, Allocation,
+FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
+constructor with one required ``machine`` reference. Traceability diagrams
+can show both layers; other diagrams show their chosen layer.
Allocation is an architecture view: it shows participants and work, nests
allocated work under its performer, and displays interactions, functional
@@ -635,9 +636,44 @@ exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
and its exchanges. Capabilities shows participants, roles, missions and
capabilities with their involvement, exploitation, include, extend and
generalization links. Decomposition and Overview retain general containment;
-Overview also exposes all relationships. Changing the projection hides
+Overview also exposes all relationships. These string projections hide
inapplicable placements without deleting them or changing semantic records.
+A StateMachine projection MUST reference an existing StateMachine in the
+diagram's layer. Its placements MUST refer to that machine, its Regions, or
+its state vertices, including nested Regions and vertices. Events, other
+machines and their members, and unrelated semantic elements are not valid
+placements in this view. Empty and partial state views are valid; neither
+creating nor reading a view synthesizes semantic elements or placements.
+Changing an existing diagram to StateMachine, or changing its machine, MUST
+remove or relocate incompatible placements in the same transaction. Context
+errors use ``state_diagram_context``; incompatible placements use
+``state_diagram_member``. Neither case silently hides invalid members.
+
+This complete source example places one state from an existing machine:
+
+.. code-block:: text
+
+ arcadia 3;
+ project operating "Operating modes" {
+ oa_entity crew "Crew" {}
+ oa_state_machine modes "Modes" { parent @crew; }
+ oa_region main "Main region" { parent @modes; }
+ oa_state ready "Ready" { parent @main; }
+ diagram mode_view "Operating modes" {
+ layer Oa;
+ projection { StateMachine { machine @modes; }; };
+ }
+ node ready_view { diagram @mode_view; element @ready; }
+ }
+
+The JSON projection is
+``{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}``,
+where the UUID is the referenced machine's identity. A bare ``"StateMachine"``,
+a missing or null ``machine``, an invalid UUID, or additional payload fields
+is malformed. Existing projections retain their string representation, for
+example ``"Overview"`` in JSON and ``projection Overview;`` in source.
+
Ports are boundary anchors of visible owners, with their existing
semantic identity. An explicitly placed port is not rendered as a separate
large node. Functional exchanges use their declared source/target ports;
@@ -655,8 +691,8 @@ allocation, or realization. Hiding an element from a diagram must remain
distinguishable from deleting it from the model.
Changing a node's diagram preserves the placement identity, alias, coordinates,
size, collapse state, manual port anchors, and attached artifacts. The destination
-must accept the element's layer and must not already contain another placement
-of that element.
+must accept the element's layer, satisfy StateMachine membership when applicable,
+and must not already contain another placement of that element.
Each ``port_anchors`` entry is a PortAnchor record with required ``port``
reference, ``side`` (Left, Right, Top, or Bottom), and ``offset`` (finite
diff --git a/docs/openapi.json b/docs/openapi.json
index 08d4fabf..256fdafc 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -3685,6 +3685,53 @@
],
"additionalProperties": false
},
+ "ProjectionKind": {
+ "description": "Existing projections remain strings. StateMachine carries one required UUID reference to an existing StateMachine in the diagram layer (state_diagram_context). Its placements may contain only that machine and its regions/state vertices, including nested members (state_diagram_member). Empty and partial views are valid. Incompatible placements must be removed or relocated in the same transaction when changing context; accepting or reading a view does not synthesize semantic records or placements.",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "FunctionalDataflow",
+ "Capabilities",
+ "Overview",
+ "Decomposition",
+ "Allocation",
+ "Traceability"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "StateMachine": {
+ "type": "object",
+ "properties": {
+ "machine": {
+ "type": "string",
+ "format": "uuid",
+ "description": "Required identity of the same-layer StateMachine that owns this view's members."
+ }
+ },
+ "required": [
+ "machine"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "StateMachine"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "examples": [
+ "Overview",
+ {
+ "StateMachine": {
+ "machine": "11111111-1111-4111-8111-111111111111"
+ }
+ }
+ ]
+ },
"Diagram": {
"type": "object",
"properties": {
@@ -3707,15 +3754,7 @@
"$ref": "#/components/schemas/Layer"
},
"projection": {
- "type": "string",
- "enum": [
- "FunctionalDataflow",
- "Capabilities",
- "Overview",
- "Decomposition",
- "Allocation",
- "Traceability"
- ]
+ "$ref": "#/components/schemas/ProjectionKind"
},
"nodes": {
"type": "object",