Add state diagram form and representation contract parity

This commit is contained in:
2026-09-06 02:33:18 +03:00
parent 24595810a1
commit 53cd096a56
7 changed files with 737 additions and 38 deletions
+203 -13
View File
@@ -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<Id> {
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::<Layer>(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<Id> {
};
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<Id> {
.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::<Vec<_>>();
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::<ProjectionKind>(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<_>>(),
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::<BTreeSet<_>>(),
project.diagrams.keys().copied().collect::<BTreeSet<_>>()
);
// 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();
@@ -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::<Vec<_>>()
.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(&current.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(&current);
// Source writes use the identical context reference and conditional commit.
let source = language::format(&current.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::<ProjectionKind>(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::<ProjectionKind>(example.clone()).unwrap()),
*example
);
}
server.stop().await;
}
+32 -2
View File
@@ -987,8 +987,9 @@ reinterpret old mixed lifelines as a strict new viewpoint.</p>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 3; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> 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.</p>
</section>
<section id="state-diagram-representation">
<h2>State diagram representation</h2>
<p><span class="docutils literal">Diagram.projection</span> keeps the existing string values <span class="docutils literal">Overview</span>,
<span class="docutils literal">Decomposition</span>, <span class="docutils literal">Allocation</span>, <span class="docutils literal">Traceability</span>, <span class="docutils literal">FunctionalDataflow</span> and
<span class="docutils literal">Capabilities</span>. A dedicated state view instead uses the required payload:</p>
<pre class="literal-block">{&quot;StateMachine&quot;:{&quot;machine&quot;:&quot;11111111-1111-4111-8111-111111111111&quot;}}</pre>
<p>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
<span class="docutils literal">projection { StateMachine { machine &#64;modes; }; };</span> with the same reference.</p>
<p>Use the ordinary <span class="docutils literal">CreateDiagram</span> or <span class="docutils literal">EditDiagram</span> transaction commands,
JSON model import/replacement, or source/source-project writes. A bare
<span class="docutils literal">&quot;StateMachine&quot;</span>, missing or null <span class="docutils literal">machine</span>, malformed UUID, or additional
payload field is malformed JSON model data (400). A missing, wrong-kind or
cross-layer machine produces the semantic issue <span class="docutils literal">state_diagram_context</span>;
an incompatible placement produces <span class="docutils literal">state_diagram_member</span>. These reject
writes with 422. Transaction rejections use the <span class="docutils literal">command_rejected</span> problem
code and include the first core failure in <span class="docutils literal">detail</span>; candidate validation
provides the structured <span class="docutils literal">issues</span> collection.
Source syntax errors use the source rejection response (422) with locations.
The validation endpoint returns 200 with <span class="docutils literal">valid:false</span> for a well-formed
invalid candidate, as described above.</p>
<p>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.</p>
</section>
<section id="cross-layer-transitions">
<h2>Cross-layer transitions</h2>
<p>Preview accepts the shared <span class="docutils literal">TransitionRequest</span> record, including direction
+37 -2
View File
@@ -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
-----------------------
+36 -6
View File
@@ -1285,17 +1285,47 @@ Studio profile; no running history state is persisted or synthesized.</p>
<h2>5. Diagrams, artifacts, and cross-layer traceability</h2>
<p><span class="docutils literal">diagram</span> has name, identity, alias, description, <span class="docutils literal">layer</span> (Oa by default),
<span class="docutils literal">projection</span> (Overview by default), and <span class="docutils literal">routes</span> (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.</p>
The string projections are Overview, Decomposition, Allocation,
FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
constructor with one required <span class="docutils literal">machine</span> reference. Traceability diagrams
can show both layers; other diagrams show their chosen layer.</p>
<p>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.</p>
<p>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 <span class="docutils literal">state_diagram_context</span>; incompatible placements use
<span class="docutils literal">state_diagram_member</span>. Neither case silently hides invalid members.</p>
<p>This complete source example places one state from an existing machine:</p>
<pre class="code text literal-block"><code>arcadia 3;
project operating &quot;Operating modes&quot; {
oa_entity crew &quot;Crew&quot; {}
oa_state_machine modes &quot;Modes&quot; { parent &#64;crew; }
oa_region main &quot;Main region&quot; { parent &#64;modes; }
oa_state ready &quot;Ready&quot; { parent &#64;main; }
diagram mode_view &quot;Operating modes&quot; {
layer Oa;
projection { StateMachine { machine &#64;modes; }; };
}
node ready_view { diagram &#64;mode_view; element &#64;ready; }
}</code></pre>
<p>The JSON projection is
<span class="docutils literal"><span class="pre">{&quot;StateMachine&quot;:{&quot;machine&quot;:&quot;11111111-1111-4111-8111-111111111111&quot;}}</span></span>,
where the UUID is the referenced machine's identity. A bare <span class="docutils literal">&quot;StateMachine&quot;</span>,
a missing or null <span class="docutils literal">machine</span>, an invalid UUID, or additional payload fields
is malformed. Existing projections retain their string representation, for
example <span class="docutils literal">&quot;Overview&quot;</span> in JSON and <span class="docutils literal">projection Overview;</span> in source.</p>
<p>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.</p>
must accept the element's layer, satisfy StateMachine membership when applicable,
and must not already contain another placement of that element.</p>
<p>Each <span class="docutils literal">port_anchors</span> entry is a PortAnchor record with required <span class="docutils literal">port</span>
reference, <span class="docutils literal">side</span> (Left, Right, Top, or Bottom), and <span class="docutils literal">offset</span> (finite
binary32 from 0 through 1). It has no separate identity or alias. The port MUST
+42 -6
View File
@@ -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
+48 -9
View File
@@ -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",