Preserve editable routes across source projects and REST

This commit is contained in:
2026-09-05 20:19:38 +03:00
parent 4e5a88dc4d
commit a7c6f9678c
8 changed files with 718 additions and 6 deletions
+3 -2
View File
@@ -66,7 +66,7 @@ impl ParseLimits {
project
.diagrams
.values()
.map(|d| d.nodes.len())
.map(|d| d.nodes.len().saturating_add(d.routes.len()))
.sum::<usize>(),
)
.saturating_add(project.artifact_links.len())
@@ -348,6 +348,7 @@ fn reference_field(key: &str) -> bool {
| "oa"
| "sa"
| "diagram"
| "edge"
)
}
fn enum_field(key: &str) -> bool {
@@ -1532,7 +1533,7 @@ pub(crate) fn resolve_declarations(
}
DeclKind::Diagram => {
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview"});
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
+1 -1
View File
@@ -1429,7 +1429,7 @@ fn item_key(
"literal:{}",
&source[value.span.start..value.span.end]
)),
EditKind::Record(fields) => ["field", "referenced"].into_iter().find_map(|key| {
EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| {
fields.get(key).and_then(|field| {
if let EditKind::Reference(alias) = &field.value.kind {
aliases.get(alias).map(|id| format!("{key}:{id}"))
+6 -2
View File
@@ -811,6 +811,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
}
}
}
for diagram in retained["diagrams"].as_object_mut().unwrap().values_mut() {
assert_eq!(diagram["routes"], serde_json::json!([]));
diagram.as_object_mut().unwrap().remove("routes");
}
assert_eq!(
retained, original,
"every original V2 field survives; only explicit new compatibility defaults are added"
@@ -825,8 +829,8 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
original["relations"].as_object().unwrap().len()
);
assert_eq!(
serde_json::to_value(&from_json.diagrams).unwrap(),
original["diagrams"]
from_json.diagrams.len(),
original["diagrams"].as_object().unwrap().len()
);
assert_eq!(
serde_json::to_value(&from_json.traces).unwrap(),
+198
View File
@@ -0,0 +1,198 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::json;
const SOURCE: &str = r#"arcadia 3;
project route_example "Connection review" {
oa_activity report "Report" {}
oa_activity assess "Assess" {}
sa_function selected_assess "Selected assessment" {}
interaction signal "Signal" { source @report; target @assess; }
trace refinement "Assessment refinement" { oa @assess; sa @selected_assess; }
diagram architecture "Architecture" {
routes [
{ edge @signal; waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; }; },
{ edge @refinement; }
];
}
diagram review "Review" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, candidate)| *candidate == alias)
.unwrap()
.0
}
#[test]
fn route_source_json_and_bundle_roundtrips_preserve_order_defaults_and_edge_aliases() {
let project = language::parse(SOURCE).unwrap();
let diagram = &project.diagrams[&id(&project, "architecture")];
assert_eq!(diagram.routes.len(), 2);
assert_eq!(diagram.routes[0].edge, id(&project, "signal"));
assert_eq!(
diagram.routes[0].waypoints[1],
Position { x: 690., y: 260. }
);
assert_eq!(diagram.routes[0].label.as_ref().unwrap().fraction, 0.55);
assert_eq!(diagram.routes[1].edge, id(&project, "refinement"));
assert!(diagram.routes[1].waypoints.is_empty());
assert!(diagram.routes[1].label.is_none());
assert!(
diagram.nodes.is_empty(),
"hidden edges can retain authored routes"
);
assert_eq!(
project.diagrams[&id(&project, "review")].routes[0].waypoints,
[]
);
let formatted = language::format(&project);
assert!(formatted.contains("edge @signal;"));
assert!(formatted.contains("edge @refinement;"));
assert!(!formatted.contains(&format!("edge \"{}\"", id(&project, "signal"))));
assert_eq!(language::parse(&formatted).unwrap(), project);
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
let compiled = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, project);
}
#[test]
fn omitted_schema_three_routes_default_to_empty_and_v2_reader_stays_frozen() {
let project =
language::parse("arcadia 3; project p \"P\" { diagram view \"View\" {} }").unwrap();
assert!(project.diagrams.values().next().unwrap().routes.is_empty());
let mut json = serde_json::to_value(&project).unwrap();
json["diagrams"]
.as_object_mut()
.unwrap()
.values_mut()
.next()
.unwrap()
.as_object_mut()
.unwrap()
.remove("routes");
assert_eq!(
persistence::decode(&serde_json::to_vec(&json).unwrap()).unwrap(),
project
);
let v2 = include_str!("fixtures/v2-coastal-rescue.arcadia");
let migrated = language::parse(v2).unwrap();
assert!(
migrated
.diagrams
.values()
.all(|diagram| diagram.routes.is_empty())
);
assert!(
language::parse("arcadia 2; project p \"P\" { diagram view \"View\" { routes []; } }")
.is_err()
);
}
#[test]
fn invalid_route_source_has_located_errors_and_no_silent_nested_defaults() {
for route in [
"{}",
"{ edge @signal; label { offset { x 0; y 0; }; }; }",
"{ edge @signal; label { fraction 0.5; }; }",
"{ edge @signal; label { fraction 1.1; offset { x 0; y 0; }; }; }",
"{ edge @signal; waypoints [{ x 1000001; y 0; }]; }",
"{ edge @signal; waypoints [{ x 0; }]; }",
"{ edge @signal; label { fraction 0.5; offset { x 0; y 0; }; unknown true; }; }",
"{ edge @report; }",
"{ edge @absent; }",
"{ edge @signal; }, { edge @signal; }",
] {
let source = format!(
"arcadia 3; project p \"P\" {{\n oa_activity report \"Report\" {{}}\n oa_activity assess \"Assess\" {{}}\n interaction signal \"Signal\" {{ source @report; target @assess; }}\n diagram view \"View\" {{\n routes [{route}];\n }}\n}}"
);
let errors = language::parse(&source).expect_err(route);
assert!(
errors.iter().all(|error| error.line >= 5),
"{route}: {errors:?}"
);
}
}
#[test]
fn route_records_share_model_admission_for_source_json_and_bundles() {
let project = language::parse(SOURCE).unwrap();
// Project + three elements + relation + trace + two diagrams = eight,
// plus three route records = eleven. Points do not create identities.
let limits = language::ParseLimits {
max_records: 10,
..language::ParseLimits::UNLIMITED
};
assert!(limits.check_model(&project).is_err());
assert!(
language::parse_limited(&language::format(&project), limits)
.unwrap_err()
.iter()
.any(|error| error.code == "model_limit")
);
assert!(matches!(
persistence::decode_limited(&serde_json::to_vec(&project).unwrap(), limits),
Err(persistence::FileError::Limit(_))
));
let bundle = format_bundle(&project);
assert!(
compile_bundle(
&bundle,
ProjectLimits {
model: limits,
..ProjectLimits::default()
}
)
.unwrap_err()
.iter()
.any(|error| error.diagnostic.code == "model_limit")
);
let admitted = language::ParseLimits {
max_records: 11,
..limits
};
assert!(admitted.check_model(&project).is_ok());
}
#[test]
fn route_commands_json_and_one_step_undo_recover_exact_model_and_canonical_source() {
let original = language::parse(SOURCE).unwrap();
let diagram = id(&original, "architecture");
let edge = id(&original, "signal");
let before = language::format(&original);
let mut session = Session::new(original.clone());
let set_json = json!({"SetEdgeRoute": {"diagram":diagram,"route":{
"edge":edge,"waypoints":[{"x":515.5,"y":-42.0}],
"label":{"fraction":0.25,"offset":{"x":12.0,"y":-18.0}}
}}});
let set: Command = serde_json::from_value(set_json.clone()).unwrap();
assert_eq!(serde_json::to_value(&set).unwrap(), set_json);
session.execute(set).unwrap();
let updated = session.project().clone();
assert_eq!(
updated.diagrams[&id(&updated, "review")],
original.diagrams[&id(&original, "review")]
);
assert_eq!(
language::parse(&language::format(&updated)).unwrap(),
updated
);
session.undo();
assert_eq!(session.project(), &original);
assert_eq!(language::format(session.project()), before);
session.redo();
assert_eq!(session.project(), &updated);
let remove: Command =
serde_json::from_value(json!({"RemoveEdgeRoute":{"diagram":diagram,"edge":edge}})).unwrap();
session.execute(remove).unwrap();
assert_eq!(session.project().diagrams[&diagram].routes.len(), 1);
session.undo();
assert_eq!(session.project(), &updated);
}
+98
View File
@@ -27,6 +27,104 @@ fn id(project: &Project, alias: &str) -> Id {
.0
}
#[test]
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
let input = bundle(
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction first \"First\" { source @a; target @b; }\ninteraction second \"Second\" { source @b; target @a; }\n",
),
(
"views.arcadia",
r#"// Diagram routing decisions stay here.
diagram review "Review" {
routes [
// First edge's detour, including unusual numeric spelling.
{ edge @ // stable edge
first; waypoints [{ x 4.20e2; y // clearance
260; }];
label { fraction // near target
0.7; offset { x 0; y -16; }; }; },
// Second edge stays automatic.
{ edge @second; }
];
}
"#,
),
],
);
let original = compile(&input);
let mut changed = original.decoded.project.clone();
let diagram = id(&changed, "review");
let first = id(&changed, "first");
let routes = &mut changed.diagrams.get_mut(&diagram).unwrap().routes;
routes[0].waypoints[0].y = 300.;
routes[0].label.as_mut().unwrap().fraction = 0.6;
routes.swap(0, 1);
changed.relations.get_mut(&first).unwrap().alias = "detour".into();
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, changed);
assert_eq!(updated.index.declarations[&diagram].path, "views.arcadia");
assert_eq!(updated.bundle.files.len(), original.bundle.files.len());
let text = &updated.bundle.files["views.arcadia"];
assert!(
text.contains("edge @ // stable edge\n detour;"),
"{text}"
);
assert!(text.contains("x 4.20e2; y // clearance\n 300"), "{text}");
assert!(text.contains("fraction // near target\n 0.6"), "{text}");
assert!(
text.find("// Second edge stays automatic.").unwrap()
< text.find("// First edge's detour").unwrap(),
"{text}"
);
assert!(
text.find("// First edge's detour").unwrap()
< text.find("edge @ // stable edge\n detour").unwrap(),
"{text}"
);
let mut old_comments = comments(&original.bundle.files["views.arcadia"]);
let mut new_comments = comments(text);
old_comments.sort();
new_comments.sort();
assert_eq!(new_comments, old_comments);
}
#[test]
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
let mut input = bundle(
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction link \"Link\" { source @a; target @b; }\n",
),
(
"views.arcadia",
"// authored view\ndiagram review \"Review\" {\n routes [{ edge @absent; }];\n}\n",
),
],
);
let errors = compile_bundle(&input, ProjectLimits::default()).unwrap_err();
assert!(
errors.iter().any(|error| error.path == "views.arcadia"
&& error.diagnostic.line == 3
&& error.diagnostic.message.contains("absent")),
"{errors:?}"
);
input.files.get_mut("views.arcadia").unwrap().clone_from(&
"// authored view\ndiagram review \"Review\" {\n routes [{ edge @link; label { fraction -0.1; offset { x 0; y 0; }; }; }];\n}\n".into());
let errors = compile_bundle(&input, ProjectLimits::default()).unwrap_err();
assert!(
errors.iter().any(|error| error.path == "views.arcadia"
&& error.diagnostic.line == 3
&& error.diagnostic.field.as_deref() == Some("routes")),
"{errors:?}"
);
}
fn typed() -> SourceBundle {
bundle(
"arcadia 3; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
+274
View File
@@ -140,6 +140,280 @@ fn graph() -> Project {
model
}
#[tokio::test]
async fn edge_route_transactions_source_and_json_share_defaults_etags_and_atomic_rejection() {
let model = oa_core::language::parse(
r#"arcadia 3; project routing "Routing" {
oa_activity a "Report" {}
oa_activity b "Assess" {}
interaction signal "Signal" { source @a; target @b; }
diagram architecture "Architecture" {}
diagram review "Review" {}
}"#,
)
.unwrap();
let find = |alias: &str| {
model
.identities()
.into_iter()
.find(|(_, candidate)| *candidate == alias)
.unwrap()
.0
};
let diagram = find("architecture");
let other = find("review");
let edge = find("signal");
let element = find("a");
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, etag) = server.import(&model).await;
let url = server.url(&format!("/projects/{id}/transactions"));
// The second route intentionally omits both optional fields, exercising
// the public JSON defaults rather than serializing a pre-hydrated struct.
let commands = json!([
{"SetEdgeRoute":{"diagram":diagram,"route":{
"edge":edge,"waypoints":[{"x":420.0,"y":260.0},{"x":690.0,"y":260.0}],
"label":{"fraction":0.55,"offset":{"x":0.0,"y":-16.0}}
}}},
{"SetEdgeRoute":{"diagram":other,"route":{"edge":edge}}}
]);
let typed: Vec<Command> = serde_json::from_value(commands.clone()).unwrap();
let expected = oa_core::execute_batch(&model, &typed).unwrap();
let preview = server
.client
.post(&url)
.header("if-match", &etag)
.json(&json!({"commands":commands,"dry_run":true}))
.send()
.await
.unwrap();
assert_eq!(preview.status(), StatusCode::OK);
let preview: Value = preview.json().await.unwrap();
assert_eq!(
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
expected
);
assert_eq!(server.get(id).await.model, model);
let commit = server
.client
.post(&url)
.header("if-match", &etag)
.json(&json!({"commands":commands}))
.send()
.await
.unwrap();
assert_eq!(commit.status(), StatusCode::OK);
let new_etag = commit.headers()["etag"].to_str().unwrap().to_owned();
let stored = server.get(id).await;
assert_eq!(stored.revision, 2);
assert_eq!(stored.model, expected);
assert!(stored.model.diagrams[&other].routes[0].waypoints.is_empty());
assert!(stored.model.diagrams[&other].routes[0].label.is_none());
let source = server
.client
.get(server.url(&format!("/projects/{id}/source")))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(source.contains("edge @signal;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), expected);
let imported = server
.client
.post(server.url("/projects/import"))
.header("content-type", "text/plain")
.body(source.clone())
.send()
.await
.unwrap();
assert_eq!(imported.status(), StatusCode::CREATED);
let imported: Value = imported.json().await.unwrap();
assert_eq!(
server
.get(imported["resource_id"].as_str().unwrap().parse().unwrap())
.await
.model,
expected
);
let reset = json!({"RemoveEdgeRoute":{"diagram":diagram,"edge":edge}});
for stale_command in [commands[0].clone(), reset.clone()] {
let response = server
.client
.post(&url)
.header("if-match", &etag)
.json(&json!({"commands":[stale_command]}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
}
let no_op = server
.client
.post(&url)
.header("if-match", &new_etag)
.json(&json!({"commands":commands}))
.send()
.await
.unwrap();
assert_eq!(no_op.status(), StatusCode::OK);
assert_eq!(no_op.headers()["etag"], new_etag);
assert_eq!(no_op.json::<Value>().await.unwrap()["changed"], false);
let invalid_routes = [
(json!({"edge":element}), StatusCode::UNPROCESSABLE_ENTITY),
(
json!({"edge":edge,"waypoints":vec![json!({"x":0,"y":0});129]}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"edge":edge,"waypoints":[{"x":1000001,"y":0}]}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"edge":edge,"label":{"fraction":2,"offset":{"x":0,"y":0}}}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"edge":edge,"label":{"fraction":0.5}}),
StatusCode::BAD_REQUEST,
),
(json!({"edge":edge,"unknown":true}), StatusCode::BAD_REQUEST),
];
for (route, status) in invalid_routes {
let response = server
.client
.post(&url)
.header("if-match", &new_etag)
.json(&json!({"commands":[
Command::Rename(element, "Must roll back".into()),
{"SetEdgeRoute":{"diagram":diagram,"route":route}}
]}))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
status,
"{route}: {}",
response.text().await.unwrap()
);
}
let mut duplicated = expected.clone();
let route = duplicated.diagrams[&diagram].routes[0].clone();
duplicated
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(route);
let rejected = server
.client
.put(server.url(&format!("/projects/{id}/model")))
.header("if-match", &new_etag)
.json(&duplicated)
.send()
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(server.get(id).await.model, stored.model);
assert_eq!(server.get(id).await.revision, stored.revision);
// The source write path hydrates the same metadata, and an equal source
// application must not create a revision or erase an explicit empty route.
let source_no_op = server
.client
.put(server.url(&format!("/projects/{id}/source")))
.header("if-match", &new_etag)
.header("content-type", "text/plain")
.body(source)
.send()
.await
.unwrap();
assert_eq!(source_no_op.status(), StatusCode::OK);
assert_eq!(source_no_op.headers()["etag"], new_etag);
let removed = server
.client
.post(&url)
.header("if-match", &new_etag)
.json(&json!({"commands":[reset]}))
.send()
.await
.unwrap();
assert_eq!(removed.status(), StatusCode::OK);
let removed_etag = removed.headers()["etag"].to_str().unwrap().to_owned();
let remaining = server.get(id).await;
assert!(remaining.model.diagrams[&diagram].routes.is_empty());
assert_eq!(remaining.model.diagrams[&other], expected.diagrams[&other]);
let removed_again = server
.client
.post(&url)
.header("if-match", &removed_etag)
.json(&json!({"commands":[reset]}))
.send()
.await
.unwrap();
assert_eq!(removed_again.status(), StatusCode::OK);
assert_eq!(removed_again.headers()["etag"], removed_etag);
assert_eq!(
removed_again.json::<Value>().await.unwrap()["changed"],
false
);
server.stop().await;
let reopened = TestServer::start(directory.path(), None).await;
assert_eq!(reopened.get(id).await.model, remaining.model);
assert_eq!(reopened.get(id).await.revision, remaining.revision);
reopened.stop().await;
}
#[test]
fn route_openapi_describes_the_serde_command_shapes_and_nested_defaults() {
let spec: Value = serde_json::from_str(include_str!("../../../docs/openapi.json")).unwrap();
let schemas = &spec["components"]["schemas"];
assert_eq!(
schemas["Diagram"]["properties"]["routes"]["default"],
json!([])
);
assert!(
!schemas["Diagram"]["required"]
.as_array()
.unwrap()
.contains(&json!("routes"))
);
assert_eq!(schemas["EdgeRoute"]["required"], json!(["edge"]));
assert_eq!(
schemas["EdgeRoute"]["properties"]["waypoints"]["maxItems"],
oa_core::MAX_ROUTE_POINTS
);
assert_eq!(
schemas["EdgeRoute"]["properties"]["waypoints"]["default"],
json!([])
);
assert_eq!(
schemas["EdgeLabel"]["required"],
json!(["fraction", "offset"])
);
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["minimum"], 0);
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["maximum"], 1);
let commands = schemas["Command"]["oneOf"].as_array().unwrap();
for (name, fields) in [
("SetEdgeRoute", json!(["diagram", "route"])),
("RemoveEdgeRoute", json!(["diagram", "edge"])),
] {
let schema = commands
.iter()
.find(|schema| schema["properties"].get(name).is_some())
.unwrap();
assert_eq!(schema["required"], json!([name]));
assert_eq!(schema["properties"][name]["required"], fields);
assert_eq!(schema["properties"][name]["additionalProperties"], false);
}
}
#[tokio::test]
async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() {
let directory = tempfile::tempdir().unwrap();
+53 -1
View File
@@ -622,7 +622,8 @@ Studio profile; no running history state is persisted or synthesized.
----------------------------------------------------
``diagram`` has name, identity, alias, description, ``layer`` (Oa by default),
and ``projection`` (Overview by default). Other projections are Decomposition,
``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.
@@ -653,6 +654,57 @@ Changing a node's diagram preserves the placement identity, alias, coordinates,
size, collapse state, and attached artifacts. The destination must accept the
element's layer and must not already contain another placement of that element.
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
(default ``none``). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
need not currently be visible in their diagram.
.. code-block:: text
routes [
{ edge @report_exchange;
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
];
Waypoints are pinned absolute diagram positions in source-to-target traversal
order; at most 128 are allowed per edge. Computed endpoints and automatic elbows
MUST NOT be persisted as authored waypoints. A label record requires both
``fraction`` (finite binary32 from 0 through 1, measured along the computed
polyline's arc length) and ``offset`` (a relative x/y diagram displacement).
All position/displacement components obey the coordinate bounds in section 2.
An absent label uses automatic placement. Unknown fields, missing required
nested fields, duplicate route edges, non-edge references and invalid numbers
are structural errors. A pinned point that obstructs routing remains stored;
geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
points are bounded by the per-route limit and aggregate input byte limits.
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
removes it; removing an absent entry is a no-op for an existing diagram and edge.
The latter is the Reset routing operation. Either command participates in the
same atomic transactions, validation and undo as other model edits. Deleting a
relation or trace removes its routes in every diagram; reidentifying it updates
route references without changing geometry. Folding, hiding, changing projection
or relocating placements MUST NOT erase routing metadata. Group movement
translates pins when both actual endpoint owners move by the same delta; label
offsets remain relative. Raw ``EditNode`` and source position edits are exact
record edits and do not implicitly translate pins.
This is an additive schema-3 field. Earlier schema-3 JSON/source that omits
``routes`` loads as an empty list. Frozen schema-1 and schema-2 readers do not
accept this new field; migration creates empty route lists. Source projects keep
routes in their owning diagram declaration/file, resolve ``edge @alias`` across
files, and retain route-entry comments by referenced edge identity when entries
are reordered. The existing REST transaction endpoint accepts the same externally
tagged command records with UUID references and conditional ETags; no separate
routing endpoint or UI-only routing model exists.
``artifact`` has name, identity, alias, description, required ``owner`` ref,
``relation`` (References, Satisfies, Verifies, Implements, DerivedFrom;
default References), ``uri`` (default empty), ``revision`` (optional string),
+85
View File
@@ -1735,6 +1735,51 @@
],
"additionalProperties": false
},
"EdgeLabel": {
"type": "object",
"description": "Authored label placement relative to the computed polyline. Both fields are required.",
"properties": {
"fraction": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Fraction of source-to-target polyline arc length."
},
"offset": {
"$ref": "#/components/schemas/Position",
"description": "Relative diagram-space displacement."
}
},
"required": ["fraction", "offset"],
"additionalProperties": false
},
"EdgeRoute": {
"type": "object",
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
"properties": {
"edge": {
"type": "string",
"format": "uuid",
"description": "Existing Relation or Trace identity, never a node placement."
},
"waypoints": {
"type": "array",
"items": { "$ref": "#/components/schemas/Position" },
"maxItems": 128,
"default": [],
"description": "Absolute diagram positions in traversal order."
},
"label": {
"oneOf": [
{ "$ref": "#/components/schemas/EdgeLabel" },
{ "type": "null" }
],
"default": null
}
},
"required": ["edge"],
"additionalProperties": false
},
"Size": {
"type": "object",
"properties": {
@@ -3445,6 +3490,12 @@
"additionalProperties": {
"$ref": "#/components/schemas/NodePlacement"
}
},
"routes": {
"type": "array",
"items": { "$ref": "#/components/schemas/EdgeRoute" },
"default": [],
"description": "Unique by edge identity within this diagram. Stored order is retained. Each entry counts against the total model record admission limit. Missing routes in earlier schema-3 models default to empty."
}
},
"required": [
@@ -4406,6 +4457,40 @@
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"SetEdgeRoute": {
"type": "object",
"description": "Upsert by edge identity; preserves unrelated route entries and order. Uses existing transaction validation and ETag checks.",
"properties": {
"diagram": { "type": "string", "format": "uuid" },
"route": { "$ref": "#/components/schemas/EdgeRoute" }
},
"required": ["diagram", "route"],
"additionalProperties": false
}
},
"required": ["SetEdgeRoute"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"RemoveEdgeRoute": {
"type": "object",
"description": "Reset to automatic routing. No-op if this existing diagram has no route for the existing edge.",
"properties": {
"diagram": { "type": "string", "format": "uuid" },
"edge": { "type": "string", "format": "uuid" }
},
"required": ["diagram", "edge"],
"additionalProperties": false
}
},
"required": ["RemoveEdgeRoute"],
"additionalProperties": false
},
{
"type": "object",
"properties": {