Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ab2b1feb | ||
|
|
8ac9f464ea | ||
|
|
959b83c78f | ||
|
|
0b58107097 | ||
|
|
1230ab8a3e | ||
|
|
c5276b1943 | ||
|
|
9118218ac3 | ||
|
|
2bf4c5451a |
File diff suppressed because it is too large
Load Diff
+24
-11
@@ -34,10 +34,7 @@ fn occurrence(
|
||||
id
|
||||
}
|
||||
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
|
||||
let items = match p.elements[&interface].shared() {
|
||||
Some(SharedBody::Interface { items }) => items.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let items = crate::data::interface_items(p, interface);
|
||||
let contract = if p.elements[&owner].kind() == Kind::Function {
|
||||
if direction == PortDirection::Out {
|
||||
PortContract::FunctionOutput {
|
||||
@@ -168,6 +165,11 @@ pub fn coastal_rescue() -> Project {
|
||||
"Distress report",
|
||||
None,
|
||||
);
|
||||
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
|
||||
p.elements.get_mut(&oa_item).unwrap().shared_mut()
|
||||
{
|
||||
*mechanism = ExchangeMechanism::Event;
|
||||
}
|
||||
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
@@ -308,6 +310,11 @@ pub fn coastal_rescue() -> Project {
|
||||
"Digital distress report",
|
||||
None,
|
||||
);
|
||||
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
|
||||
p.elements.get_mut(&information).unwrap().shared_mut()
|
||||
{
|
||||
*mechanism = ExchangeMechanism::Event;
|
||||
}
|
||||
let material = add(
|
||||
&mut p,
|
||||
Kind::ExchangeItem,
|
||||
@@ -317,6 +324,8 @@ pub fn coastal_rescue() -> Project {
|
||||
);
|
||||
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
let energy = add(
|
||||
&mut p,
|
||||
@@ -327,6 +336,8 @@ pub fn coastal_rescue() -> Project {
|
||||
);
|
||||
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
let field = add(
|
||||
&mut p,
|
||||
@@ -340,6 +351,9 @@ pub fn coastal_rescue() -> Project {
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: "degrees north".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
let interface = add(
|
||||
&mut p,
|
||||
@@ -348,13 +362,12 @@ pub fn coastal_rescue() -> Project {
|
||||
"Distress reporting interface",
|
||||
None,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface {
|
||||
items: vec![information],
|
||||
};
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Distress payload",
|
||||
interface,
|
||||
information,
|
||||
);
|
||||
let vessel_port = port(
|
||||
&mut p,
|
||||
actor,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
use crate::*;
|
||||
@@ -340,6 +340,8 @@ fn reference_field(key: &str) -> bool {
|
||||
| "referenced"
|
||||
| "local"
|
||||
| "field"
|
||||
| "path"
|
||||
| "supertype"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
@@ -378,10 +380,14 @@ fn enum_field(key: &str) -> bool {
|
||||
| "message_kind"
|
||||
| "transition_kind"
|
||||
| "side"
|
||||
| "mechanism"
|
||||
| "storage"
|
||||
| "transmission"
|
||||
| "acquisition"
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
|
||||
/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
|
||||
/// adopts a model and never overwrites an editor draft.
|
||||
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
||||
parse_limited(source, ParseLimits::UNLIMITED)
|
||||
@@ -546,7 +552,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
@@ -1337,12 +1343,12 @@ impl Parser<'_> {
|
||||
.map(|(key, span)| ((d.id, key.clone()), span.start)),
|
||||
);
|
||||
}
|
||||
let project =
|
||||
let decoded =
|
||||
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
|
||||
.map_err(|(_, error)| error)?;
|
||||
Ok(ParsedDocument {
|
||||
project,
|
||||
migration: None,
|
||||
project: decoded.project,
|
||||
migration: decoded.migration,
|
||||
locations,
|
||||
fields,
|
||||
})
|
||||
@@ -1350,9 +1356,11 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if self.text() != "3" {
|
||||
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
|
||||
if !matches!(self.text(), "3" | "4") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
|
||||
}
|
||||
let schema_version = if self.text() == "3" { 3 } else { 4 };
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
let start = self.current().span.start;
|
||||
@@ -1365,7 +1373,7 @@ impl Parser<'_> {
|
||||
self.take("{")?;
|
||||
Some(SourceHeader {
|
||||
project: Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
schema_version,
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
@@ -1540,7 +1548,9 @@ pub(crate) fn resolve_declarations(
|
||||
declarations: &[Declaration],
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
limits: ParseLimits,
|
||||
) -> Result<Project, (usize, Error)> {
|
||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||
let legacy = project.schema_version == 3;
|
||||
let mut legacy_elements = serde_json::Map::new();
|
||||
let mut nodes = Vec::new();
|
||||
for (index, declaration) in declarations.iter().enumerate() {
|
||||
(|| -> Result<(), Error> {
|
||||
@@ -1561,6 +1571,9 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Element constructor was not part of schema3.".into()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let explicit_parent = d.fields.contains_key("parent");
|
||||
@@ -1601,9 +1614,17 @@ pub(crate) fn resolve_declarations(
|
||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||
}
|
||||
let mut body = serde_json::to_value(initial).unwrap();
|
||||
let body_fields =
|
||||
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
let defaults = if legacy {
|
||||
crate::persistence::v3::legacy_defaults(kind)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_else(|| payload(&body))
|
||||
} else { payload(&body) };
|
||||
let body_fields = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, body_fields);
|
||||
if legacy {
|
||||
legacy_elements.insert(d.id.to_string(), serde_json::json!({"id":d.id,"alias":d.alias,"name":d.name,"description":description,"parent":parent,"body":body}));
|
||||
return Ok(());
|
||||
}
|
||||
let body: ElementBody = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
if body.kind() != kind || body.layer() != layer {
|
||||
@@ -1624,6 +1645,9 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
@@ -1725,6 +1749,58 @@ pub(crate) fn resolve_declarations(
|
||||
.map_err(|error| (index, error))?;
|
||||
target.nodes.insert(node.id, node);
|
||||
}
|
||||
if legacy {
|
||||
let mut value = serde_json::to_value(&project).expect("project serialization");
|
||||
value["elements"] = Value::Object(legacy_elements);
|
||||
let migrated = serde_json::from_value(value)
|
||||
.map_err(crate::persistence::FileError::Json)
|
||||
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
||||
.map_err(|error| {
|
||||
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
||||
issues.first()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let index = issue
|
||||
.and_then(|issue| issue.element)
|
||||
.and_then(|id| declarations.iter().position(|d| d.id == id))
|
||||
.unwrap_or(usize::MAX);
|
||||
let offset = declarations.get(index).map_or(0, |d| {
|
||||
issue
|
||||
.and_then(|issue| issue.field.as_ref())
|
||||
.and_then(|field| d.field_spans.get(field))
|
||||
.map_or(d.offset, |span| span.start)
|
||||
});
|
||||
(
|
||||
index,
|
||||
Error {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"migration_error"
|
||||
},
|
||||
offset,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)
|
||||
})?;
|
||||
for (index, d) in declarations.iter().enumerate() {
|
||||
if let DeclKind::Element(kind, layer) = d.kind {
|
||||
let body = &migrated.project.elements[&d.id].body;
|
||||
if body.kind() != kind || body.layer() != layer {
|
||||
return Err((
|
||||
index,
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: d.offset,
|
||||
message: "Declaration kind disagrees with its typed body.".into(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
limits.check_model(&project).map_err(|message| {
|
||||
(
|
||||
usize::MAX,
|
||||
@@ -1735,7 +1811,10 @@ pub(crate) fn resolve_declarations(
|
||||
},
|
||||
)
|
||||
})?;
|
||||
Ok(project)
|
||||
Ok(crate::persistence::DecodeResult {
|
||||
project,
|
||||
migration: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
|
||||
|
||||
fn project_source(body: &str) -> String {
|
||||
format!(
|
||||
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
"arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
"arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"children.arcadia".into(),
|
||||
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
|
||||
|
||||
#[test]
|
||||
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
|
||||
let source = "arcadia 3; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let analysis = analyze(source);
|
||||
let ready = analysis
|
||||
.declarations
|
||||
@@ -406,12 +406,34 @@ fn checked_in_source_project() -> SourceBundle {
|
||||
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
|
||||
let input = checked_in_source_project();
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"every example identity is already explicit"
|
||||
);
|
||||
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
|
||||
for (path, source) in &input.files {
|
||||
let unit = parse_unit(source, path == &input.entry, ParseLimits::UNLIMITED).unwrap();
|
||||
assert!(
|
||||
unit.header
|
||||
.as_ref()
|
||||
.is_none_or(|header| header.id_insertion.is_none())
|
||||
);
|
||||
for declaration in unit.declarations {
|
||||
assert!(
|
||||
declaration.id_insertion.is_none(),
|
||||
"every example identity is already explicit"
|
||||
);
|
||||
assert_eq!(
|
||||
identity(&compiled.decoded.project, &declaration.alias),
|
||||
declaration.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
assert!(reopened.decoded.migration.is_none());
|
||||
assert_eq!(compiled.diagnostics.len(), 2);
|
||||
assert!(
|
||||
compiled.diagnostics.is_empty(),
|
||||
compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
"{:?}",
|
||||
compiled.diagnostics
|
||||
);
|
||||
@@ -473,7 +495,15 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
|
||||
assert_eq!(result.diagnostics.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
"{:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
assert_eq!(
|
||||
result.index.declarations[&assess].path,
|
||||
"sa/assurance.arcadia"
|
||||
@@ -500,6 +530,6 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
|
||||
"sa/participants.arcadia",
|
||||
"views.arcadia",
|
||||
] {
|
||||
assert_eq!(result.bundle.files[path], input.files[path]);
|
||||
assert_eq!(result.bundle.files[path], compiled.bundle.files[path]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod persistence;
|
||||
|
||||
@@ -11,10 +11,12 @@ but constructs and validates the frozen DTO, preserves located old-contract
|
||||
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
|
||||
generic port/optional FE endpoint contracts therefore remain explicit.
|
||||
|
||||
`adapter.rs` budgets generated data, validates V2, transforms only the changed
|
||||
payloads, and validates schema 3. Serialization between the two typed DTOs copies
|
||||
unchanged fields; original input bytes are never deserialized into the new
|
||||
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
|
||||
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
|
||||
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
|
||||
then performs the data-contract upgrade and validates schema 4. Serialization
|
||||
between compatibility DTOs copies unchanged fields; original input bytes are
|
||||
never deserialized into the new `Project`. V1 normalization also constructs this
|
||||
V2 DTO before the shared stages. Combined generation stays within one budget.
|
||||
|
||||
Do not update these frozen shapes when extending the current model. Add another
|
||||
versioned migration when the persistent contract changes again. Golden V2
|
||||
|
||||
@@ -62,16 +62,9 @@ pub(crate) fn migrate(
|
||||
}],
|
||||
});
|
||||
}
|
||||
let mut report = MigrationReport {
|
||||
from_schema: 2,
|
||||
to_schema: 3,
|
||||
preserved_ports: vec![],
|
||||
generated_endpoints: vec![],
|
||||
orientation_defaults: vec![],
|
||||
required_resolutions: vec![],
|
||||
};
|
||||
let mut report = MigrationReport::data_upgrade(2);
|
||||
let mut value = serde_json::to_value(&old)?;
|
||||
value["schema_version"] = Value::from(SCHEMA_VERSION);
|
||||
value["schema_version"] = Value::from(3);
|
||||
for element in old.elements.values() {
|
||||
if let Some(super::model::SharedBody::Port {
|
||||
direction,
|
||||
@@ -204,11 +197,23 @@ pub(crate) fn migrate(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let project: Project = serde_json::from_value(value)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
super::super::validate_project(&project)?;
|
||||
// Carry the endpoint-generation reference charge into the next migration;
|
||||
// independent per-stage checks would undercount the combined generated graph.
|
||||
let data_limits = ParseLimits {
|
||||
max_step_references: limits
|
||||
.max_step_references
|
||||
.saturating_sub(additional_references),
|
||||
..limits
|
||||
};
|
||||
let migrated = super::super::v3::migrate(serde_json::from_value(value)?, data_limits)?;
|
||||
let data = migrated
|
||||
.migration
|
||||
.expect("the schema3 adapter reports its defaults");
|
||||
report.semantic_defaults = data.semantic_defaults;
|
||||
report.generated_interface_allocations = data.generated_interface_allocations;
|
||||
report.unresolved_message_values = data.unresolved_message_values;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
project: migrated.project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Schema 3 to 4 reader
|
||||
|
||||
Schema 3 is a real compatibility input. Its shapes were frozen at `5ce3eef`.
|
||||
The small DTOs in `mod.rs` own the changed data payloads and old message values;
|
||||
closed element/relation constructor lists prevent new vocabulary from entering
|
||||
the old reader. Unchanged ports, diagram placements/routes, artifacts and traces
|
||||
reuse their typed records. The intermediate JSON bridge rejects duplicate keys.
|
||||
|
||||
Nature, bounded types, field defaults/direction/storage, and single supertypes
|
||||
receive explicit documented defaults. Interface lists become identified ordered
|
||||
allocations with UUID v5 namespace equal to the project UUID and name
|
||||
`v2-interface-item:<interface-uuid>:<item-uuid>`. The name remains the same for
|
||||
V2 and V3 inputs so the migration route cannot change the generated identity.
|
||||
Existing IDs never move; deterministic collisions fail with a located diagnostic.
|
||||
Aliases use the normal deterministic collision suffix. Unique legacy message
|
||||
paths are qualified; ambiguous annotations retain their expression and warning.
|
||||
|
||||
V1 first normalizes to the frozen V2 DTO. V2 port/endpoint normalization emits
|
||||
an explicit schema3 candidate and then runs this reader before current decoding.
|
||||
Generated object, record and reference budgets cover the combined stages.
|
||||
|
||||
`language::resolve_declarations` shares lexical/AST parsing, including nesting,
|
||||
but chooses frozen source3 payload defaults and rejects new constructors. Both
|
||||
single-file parsing and `source_project::compile_bundle` take this explicit path.
|
||||
The bundle compiler first stabilizes omitted IDs, then performs targeted token
|
||||
edits in `source_project/source_migration.rs`. Original files, includes, lexical
|
||||
ownership and comments survive. New allocation declarations stay in the source
|
||||
interface's module. The returned source and index describe schema4 bytes; loading
|
||||
does not rewrite original disk files. Canonical output and second-save stability
|
||||
are schema4. No whole-project formatting fallback handles authored source3.
|
||||
|
||||
Frozen source/JSON fixtures live under `tests/fixtures/v3-data-contract.*`; the
|
||||
original V2 golden files remain unchanged.
|
||||
@@ -0,0 +1,630 @@
|
||||
//! Explicit schema-3 compatibility. Only changed payloads are frozen here;
|
||||
//! unchanged typed records are shared after a closed old-constructor check.
|
||||
use crate::language::ParseLimits;
|
||||
use crate::persistence::{DecodeResult, FileError, MigrationReport};
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project3 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element3>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation3>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace>,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Element3 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parent: Option<Id>,
|
||||
#[serde(deserialize_with = "strict_value")]
|
||||
pub body: Value,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Relation3 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "strict_value")]
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
// A Value bridge must retain the strict duplicate-field behavior of typed DTOs.
|
||||
fn strict_value<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
|
||||
struct Strict;
|
||||
impl<'de> serde::de::Visitor<'de> for Strict {
|
||||
type Value = Value;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("a value with unique object fields")
|
||||
}
|
||||
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(Value::Number)
|
||||
.ok_or_else(|| E::custom("Non-finite number"))
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<Value, E> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn visit_none<E: serde::de::Error>(self) -> Result<Value, E> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(v) = a.next_element::<StrictValue>()? {
|
||||
out.push(v.0);
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
|
||||
let mut out = serde_json::Map::new();
|
||||
while let Some((k, v)) = a.next_entry::<String, StrictValue>()? {
|
||||
if out.insert(k.clone(), v.0).is_some() {
|
||||
return Err(serde::de::Error::custom(format!("Duplicate field '{k}'")));
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(Strict)
|
||||
}
|
||||
struct StrictValue(Value);
|
||||
impl<'de> Deserialize<'de> for StrictValue {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
strict_value(d).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum ItemKind3 {
|
||||
Information,
|
||||
Material,
|
||||
Energy,
|
||||
Event,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum Definition3 {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration { literals: Vec<String> },
|
||||
Record,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Item3 {
|
||||
kind: ItemKind3,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Type3 {
|
||||
definition: Definition3,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Field3 {
|
||||
type_ref: TypeRef,
|
||||
minimum: u32,
|
||||
maximum: Option<u32>,
|
||||
unit: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Interface3 {
|
||||
items: Vec<Id>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MessageValue3 {
|
||||
field: Id,
|
||||
expression: String,
|
||||
}
|
||||
|
||||
pub(crate) fn relation_allowed(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Allocation"
|
||||
| "PortAllocation"
|
||||
| "Interaction"
|
||||
| "CapabilityInvolvement"
|
||||
| "ChainInvolvement"
|
||||
| "RoleRealization"
|
||||
| "RoleAssignment"
|
||||
| "FunctionalExchange"
|
||||
| "BoundaryExchange"
|
||||
| "MissionCapability"
|
||||
| "CapabilityInclude"
|
||||
| "CapabilityExtend"
|
||||
| "CapabilityGeneralization"
|
||||
| "MissionInvolvement"
|
||||
| "SequenceLink"
|
||||
| "StateTransition"
|
||||
| "Availability"
|
||||
| "ExchangeAllocation"
|
||||
| "InterfaceProvision"
|
||||
| "InterfaceRequirement"
|
||||
| "StateAction"
|
||||
)
|
||||
}
|
||||
pub(crate) fn element_allowed(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Entity"
|
||||
| "Actor"
|
||||
| "Role"
|
||||
| "Activity"
|
||||
| "Capability"
|
||||
| "OperationalMission"
|
||||
| "Process"
|
||||
| "System"
|
||||
| "SystemActor"
|
||||
| "Function"
|
||||
| "SystemCapability"
|
||||
| "Mission"
|
||||
| "FunctionalChain"
|
||||
| "ExchangeItem"
|
||||
| "DataType"
|
||||
| "DataField"
|
||||
| "Interface"
|
||||
| "Port"
|
||||
| "Scenario"
|
||||
| "Lifeline"
|
||||
| "Occurrence"
|
||||
| "StateMachine"
|
||||
| "Region"
|
||||
| "State"
|
||||
| "Mode"
|
||||
| "Initial"
|
||||
| "Final"
|
||||
| "Choice"
|
||||
| "Junction"
|
||||
| "Fork"
|
||||
| "Join"
|
||||
| "ShallowHistory"
|
||||
| "DeepHistory"
|
||||
| "EntryPoint"
|
||||
| "ExitPoint"
|
||||
| "Terminate"
|
||||
| "Event"
|
||||
| "Constraint"
|
||||
)
|
||||
}
|
||||
pub(crate) fn legacy_defaults(kind: Kind) -> Option<Value> {
|
||||
Some(match kind {
|
||||
Kind::ExchangeItem => json!({"kind":"Information"}),
|
||||
Kind::DataType => json!({"definition":"String"}),
|
||||
Kind::DataField => json!({"type_ref":"String","minimum":1,"maximum":1,"unit":""}),
|
||||
Kind::Interface => json!({"items":[]}),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn at(id: Id, field: &str, code: &str, message: impl Into<String>) -> FileError {
|
||||
let message = message.into();
|
||||
FileError::Model {
|
||||
message: format!("V3 {code} at {id}.{field}: {message}"),
|
||||
issues: vec![validation::Issue {
|
||||
severity: validation::Severity::Error,
|
||||
element: Some(id),
|
||||
code: code.into(),
|
||||
field: Some(field.into()),
|
||||
message,
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn singleton(value: &Value) -> Option<(&str, &Value)> {
|
||||
let object = value.as_object()?;
|
||||
(object.len() == 1)
|
||||
.then(|| object.iter().next().unwrap())
|
||||
.map(|(k, v)| (k.as_str(), v))
|
||||
}
|
||||
fn old_payload<T: serde::de::DeserializeOwned>(
|
||||
id: Id,
|
||||
field: &str,
|
||||
value: Value,
|
||||
) -> Result<T, FileError> {
|
||||
serde_json::from_value(value).map_err(|error| at(id, field, "legacy_shape", error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
if old.schema_version != 3 {
|
||||
return Err(FileError::Invalid("Expected schema_version 3.".into()));
|
||||
}
|
||||
let graph = old.elements.len().saturating_add(old.relations.len());
|
||||
let records = graph
|
||||
.saturating_add(old.diagrams.len())
|
||||
.saturating_add(old.diagrams.values().map(|d| d.nodes.len()).sum::<usize>())
|
||||
.saturating_add(old.traces.len())
|
||||
.saturating_add(old.artifact_links.len())
|
||||
.saturating_add(1);
|
||||
limits
|
||||
.check_counts(graph, records, 0)
|
||||
.map_err(FileError::Limit)?;
|
||||
let mut report = MigrationReport::data_upgrade(3);
|
||||
let mut value = serde_json::to_value(&old)?;
|
||||
let mut allocations = Vec::new();
|
||||
for (key, e) in &old.elements {
|
||||
if *key != e.id {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"id",
|
||||
"identity",
|
||||
"Element map key differs from its identity.",
|
||||
));
|
||||
}
|
||||
let mut body = e.body.clone();
|
||||
let (layer, inner) = singleton(&body)
|
||||
.filter(|(name, _)| matches!(*name, "Oa" | "Sa"))
|
||||
.ok_or_else(|| {
|
||||
at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Expected one OA or SA constructor.",
|
||||
)
|
||||
})?;
|
||||
let layer = layer.to_owned();
|
||||
let outer = inner
|
||||
.as_str()
|
||||
.or_else(|| singleton(inner).map(|(name, _)| name));
|
||||
if !outer.is_some_and(|name| {
|
||||
if layer == "Oa" {
|
||||
matches!(
|
||||
name,
|
||||
"Entity"
|
||||
| "Actor"
|
||||
| "Role"
|
||||
| "Activity"
|
||||
| "Capability"
|
||||
| "Mission"
|
||||
| "Process"
|
||||
| "Shared"
|
||||
)
|
||||
} else {
|
||||
matches!(
|
||||
name,
|
||||
"System"
|
||||
| "Actor"
|
||||
| "Function"
|
||||
| "Capability"
|
||||
| "Mission"
|
||||
| "FunctionalChain"
|
||||
| "Shared"
|
||||
)
|
||||
}
|
||||
}) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Unknown schema3 layer constructor.",
|
||||
));
|
||||
}
|
||||
if let Some(("Shared", shared)) = singleton(inner) {
|
||||
let name = shared
|
||||
.as_str()
|
||||
.or_else(|| singleton(shared).map(|(name, _)| name))
|
||||
.ok_or_else(|| {
|
||||
at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Expected one schema3 shared constructor.",
|
||||
)
|
||||
})?
|
||||
.to_owned();
|
||||
if !matches!(
|
||||
name.as_str(),
|
||||
"ExchangeItem"
|
||||
| "DataType"
|
||||
| "DataField"
|
||||
| "Interface"
|
||||
| "Port"
|
||||
| "Scenario"
|
||||
| "Lifeline"
|
||||
| "Occurrence"
|
||||
| "StateMachine"
|
||||
| "Event"
|
||||
| "Region"
|
||||
| "State"
|
||||
| "Constraint"
|
||||
) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
format!("Unknown schema3 shared constructor '{name}'."),
|
||||
));
|
||||
}
|
||||
let payload = shared.get(&name).cloned().unwrap_or(Value::Null);
|
||||
let changed = match name.as_str() {
|
||||
"ExchangeItem" => {
|
||||
let old: Item3 = old_payload(e.id, "body.kind", payload)?;
|
||||
let (kind, mechanism) = match old.kind {
|
||||
ItemKind3::Information => ("Information", "Unspecified"),
|
||||
ItemKind3::Material => ("Material", "Unspecified"),
|
||||
ItemKind3::Energy => ("Energy", "Unspecified"),
|
||||
ItemKind3::Event => ("Information", "Event"),
|
||||
};
|
||||
Some(json!({"kind":kind,"mechanism":mechanism,"supertype":null}))
|
||||
}
|
||||
"DataType" => {
|
||||
let old: Type3 = old_payload(e.id, "body.definition", payload)?;
|
||||
let definition = match old.definition {
|
||||
Definition3::Boolean => json!("Boolean"),
|
||||
Definition3::Integer => json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
Definition3::Real => json!({"Real":{"minimum":null,"maximum":null}}),
|
||||
Definition3::String => {
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null}})
|
||||
}
|
||||
Definition3::Enumeration { literals } => {
|
||||
json!({"Enumeration":{"literals":literals}})
|
||||
}
|
||||
Definition3::Record => json!({"Record":{"supertype":null}}),
|
||||
};
|
||||
Some(json!({"definition":definition,"default":null,"unit":""}))
|
||||
}
|
||||
"DataField" => {
|
||||
let old: Field3 = old_payload(e.id, "body", payload)?;
|
||||
Some(
|
||||
json!({"type_ref":old.type_ref,"minimum":old.minimum,"maximum":old.maximum,"unit":old.unit,"direction":"Unspecified","storage":"Value","default":null}),
|
||||
)
|
||||
}
|
||||
"Interface" => {
|
||||
let old: Interface3 = old_payload(e.id, "items", payload)?;
|
||||
let mut seen = BTreeSet::new();
|
||||
for (order, item) in old.items.into_iter().enumerate() {
|
||||
if !seen.insert(item) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"items",
|
||||
"duplicate_reference",
|
||||
"Interface items must be unique.",
|
||||
));
|
||||
}
|
||||
allocations.push((e.id, item, order));
|
||||
}
|
||||
Some(json!({"supertype":null}))
|
||||
}
|
||||
"Occurrence" => {
|
||||
let mut payload = payload;
|
||||
if let Some(values) = payload.pointer_mut("/body/Exchange/values") {
|
||||
let old: Vec<MessageValue3> =
|
||||
old_payload(e.id, "body.values", values.clone())?;
|
||||
*values=Value::Array(old.into_iter().map(|v|json!({"field":v.field,"expression":v.expression,"path":[]})).collect());
|
||||
}
|
||||
Some(payload)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(changed) = changed {
|
||||
let description = match name.as_str() {
|
||||
"ExchangeItem" if changed["mechanism"] == "Event" => {
|
||||
"Legacy Event nature becomes Information with Event mechanism; supertype is absent."
|
||||
}
|
||||
"ExchangeItem" => {
|
||||
"Nature is retained; mechanism is Unspecified and supertype is absent."
|
||||
}
|
||||
"DataType" => {
|
||||
"Existing type is retained with unbounded numeric/string limits, no scalar default or unit, and no Record supertype."
|
||||
}
|
||||
"DataField" => {
|
||||
"Type, ownership, multiplicity and unit are retained; direction is Unspecified, storage is Value and default is absent."
|
||||
}
|
||||
"Interface" => {
|
||||
"Items become identified ordered allocations with Unspecified protocols; supertype is absent."
|
||||
}
|
||||
_ => {
|
||||
"Value expressions are retained; unique enclosing field paths are resolved and ambiguous legacy paths remain unresolved."
|
||||
}
|
||||
};
|
||||
body[&layer]["Shared"][&name] = changed;
|
||||
report
|
||||
.semantic_defaults
|
||||
.push(crate::persistence::SemanticDefault {
|
||||
object: e.id,
|
||||
field: "body".into(),
|
||||
description: description.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Current deserialization checks the reused, unchanged outer/typed parts.
|
||||
let _: ElementBody = serde_json::from_value(body.clone())
|
||||
.map_err(|error| at(e.id, "body", "legacy_shape", error.to_string()))?;
|
||||
value["elements"][key.to_string()]["body"] = body;
|
||||
}
|
||||
for (key, r) in &old.relations {
|
||||
if *key != r.id {
|
||||
return Err(at(
|
||||
r.id,
|
||||
"id",
|
||||
"identity",
|
||||
"Relation map key differs from its identity.",
|
||||
));
|
||||
}
|
||||
if !singleton(&r.body).is_some_and(|(name, _)| relation_allowed(name)) {
|
||||
return Err(at(
|
||||
r.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Relation constructor was not part of schema3.",
|
||||
));
|
||||
}
|
||||
}
|
||||
value["schema_version"] = SCHEMA_VERSION.into();
|
||||
let mut project: Project = serde_json::from_value(value)?;
|
||||
let mut references = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum::<usize>()
|
||||
.saturating_add(allocations.len().saturating_mul(2));
|
||||
limits
|
||||
.check_counts(
|
||||
project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len())
|
||||
.saturating_add(allocations.len()),
|
||||
project.identities().len().saturating_add(allocations.len()),
|
||||
references,
|
||||
)
|
||||
.map_err(FileError::Limit)?;
|
||||
let mut ids: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
for (source, target, order) in allocations {
|
||||
let id = Id::new_v5(
|
||||
&project.id,
|
||||
format!("v2-interface-item:{source}:{target}").as_bytes(),
|
||||
);
|
||||
if !ids.insert(id) {
|
||||
return Err(at(
|
||||
source,
|
||||
"items",
|
||||
"identity_collision",
|
||||
format!(
|
||||
"Deterministic interface allocation {id} already belongs to another object."
|
||||
),
|
||||
));
|
||||
}
|
||||
let name = format!("{} · {}", project.named(source), project.named(target));
|
||||
let alias = project.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}_allocation",
|
||||
project
|
||||
.elements
|
||||
.get(&source)
|
||||
.map_or("interface", |e| e.alias.as_str()),
|
||||
project
|
||||
.elements
|
||||
.get(&target)
|
||||
.map_or("item", |e| e.alias.as_str())
|
||||
),
|
||||
"interface_item_allocation",
|
||||
id,
|
||||
);
|
||||
project.relations.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: alias.clone(),
|
||||
name,
|
||||
description: String::new(),
|
||||
body: RelationBody::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order: order.try_into().map_err(|_| {
|
||||
at(source, "items", "model_limit", "Too many interface items.")
|
||||
})?,
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
},
|
||||
},
|
||||
);
|
||||
report.generated_interface_allocations.push(
|
||||
crate::persistence::GeneratedInterfaceAllocation {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
alias,
|
||||
order: order as u32,
|
||||
},
|
||||
);
|
||||
}
|
||||
let annotations: Vec<_> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
values,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) => Some((e.id, *exchange, values.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (owner, exchange, values) in annotations {
|
||||
let Some(relation) = project.relations.get(&exchange) else {
|
||||
continue;
|
||||
};
|
||||
for (index, value) in values.into_iter().enumerate() {
|
||||
let resolution =
|
||||
crate::data::resolve_legacy_field_path(&project, relation, value.field)
|
||||
.map_err(|message| at(owner, "body.values", "model_limit", message))?;
|
||||
if let crate::data::LegacyFieldPathResolution::Unique(path) = resolution {
|
||||
if !path.is_empty() {
|
||||
references = references.saturating_add(path.len());
|
||||
limits
|
||||
.check_counts(
|
||||
project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len()),
|
||||
project.identities().len(),
|
||||
references,
|
||||
)
|
||||
.map_err(FileError::Limit)?;
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.and_then(Element::shared_mut)
|
||||
{
|
||||
values[index].path = path;
|
||||
}
|
||||
}
|
||||
} else if matches!(
|
||||
resolution,
|
||||
crate::data::LegacyFieldPathResolution::Ambiguous
|
||||
) {
|
||||
report.unresolved_message_values.push(crate::persistence::UnresolvedMessageValue { occurrence:owner,field:value.field,reason:"Several carried paths reach this legacy field; choose an explicit argument path.".into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
super::validate_project(&project)?;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
+149
-14
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 3;
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -265,17 +265,79 @@ pub enum ExchangeItemKind {
|
||||
Information,
|
||||
Material,
|
||||
Energy,
|
||||
Event,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum ExchangeMechanism {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Flow,
|
||||
Event,
|
||||
Operation,
|
||||
SharedData,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum ParameterDirection {
|
||||
#[default]
|
||||
Unspecified,
|
||||
In,
|
||||
Out,
|
||||
InOut,
|
||||
Return,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum FieldStorage {
|
||||
#[default]
|
||||
Value,
|
||||
Reference,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DataValue {
|
||||
Boolean(bool),
|
||||
Integer(i64),
|
||||
Real(f64),
|
||||
String(String),
|
||||
Enumeration(String),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum TransmissionProtocol {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Synchronous,
|
||||
Asynchronous,
|
||||
Unicast,
|
||||
Multicast,
|
||||
Broadcast,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum AcquisitionProtocol {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Read,
|
||||
Accept,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DataTypeDefinition {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration { literals: Vec<String> },
|
||||
Record,
|
||||
Integer {
|
||||
minimum: Option<i64>,
|
||||
maximum: Option<i64>,
|
||||
},
|
||||
Real {
|
||||
minimum: Option<f64>,
|
||||
maximum: Option<f64>,
|
||||
},
|
||||
String {
|
||||
minimum_length: u32,
|
||||
maximum_length: Option<u32>,
|
||||
},
|
||||
Enumeration {
|
||||
literals: Vec<String>,
|
||||
},
|
||||
Record {
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -453,6 +515,8 @@ pub struct LifelineBinding {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MessageValue {
|
||||
#[serde(default)]
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
pub expression: String,
|
||||
}
|
||||
@@ -502,18 +566,25 @@ pub enum OccurrenceBody {
|
||||
pub enum SharedBody {
|
||||
ExchangeItem {
|
||||
kind: ExchangeItemKind,
|
||||
mechanism: ExchangeMechanism,
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
DataType {
|
||||
definition: DataTypeDefinition,
|
||||
default: Option<DataValue>,
|
||||
unit: String,
|
||||
},
|
||||
DataField {
|
||||
type_ref: TypeRef,
|
||||
minimum: u32,
|
||||
maximum: Option<u32>,
|
||||
unit: String,
|
||||
direction: ParameterDirection,
|
||||
storage: FieldStorage,
|
||||
default: Option<Vec<DataValue>>,
|
||||
},
|
||||
Interface {
|
||||
items: Vec<Id>,
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
Port {
|
||||
contract: PortContract,
|
||||
@@ -672,17 +743,27 @@ impl ElementBody {
|
||||
match k {
|
||||
Kind::ExchangeItem => SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
supertype: None,
|
||||
},
|
||||
Kind::DataType => SharedBody::DataType {
|
||||
definition: DataTypeDefinition::String,
|
||||
definition: DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
},
|
||||
Kind::DataField => SharedBody::DataField {
|
||||
type_ref: TypeRef::String,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
},
|
||||
Kind::Interface => SharedBody::Interface { items: vec![] },
|
||||
Kind::Interface => SharedBody::Interface { supertype: None },
|
||||
// Port creation requires an explicit contract and owner.
|
||||
Kind::Port => return None,
|
||||
Kind::Scenario => SharedBody::Scenario {
|
||||
@@ -752,6 +833,8 @@ impl SharedBody {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RelationKind {
|
||||
InterfaceItemAllocation,
|
||||
DataAssociation,
|
||||
Allocation,
|
||||
PortAllocation,
|
||||
Interaction,
|
||||
@@ -775,7 +858,9 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 21] = [
|
||||
pub const ALL: [Self; 23] = [
|
||||
Self::InterfaceItemAllocation,
|
||||
Self::DataAssociation,
|
||||
Self::Allocation,
|
||||
Self::PortAllocation,
|
||||
Self::Interaction,
|
||||
@@ -800,6 +885,8 @@ impl RelationKind {
|
||||
];
|
||||
pub fn permits(self, from: Kind, to: Kind) -> bool {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation => from == Kind::Interface && to == Kind::ExchangeItem,
|
||||
Self::DataAssociation => from == Kind::DataType && to == Kind::DataType,
|
||||
Self::Allocation => from.is_performer() && to.is_work(),
|
||||
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
|
||||
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
|
||||
@@ -855,6 +942,8 @@ impl RelationKind {
|
||||
}
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation => "Interface → allocated exchange item",
|
||||
Self::DataAssociation => "Record type → record type",
|
||||
Self::Allocation => "Performer → leaf activity/function",
|
||||
Self::PortAllocation => "Component port → allocated function port",
|
||||
Self::Interaction => "OA activity → OA activity",
|
||||
@@ -886,6 +975,8 @@ impl RelationKind {
|
||||
impl fmt::Display for RelationKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InterfaceItemAllocation => "Interface item allocation",
|
||||
Self::DataAssociation => "Data association",
|
||||
Self::Allocation => "Allocation",
|
||||
Self::PortAllocation => "Port allocation",
|
||||
Self::Interaction => "Interaction",
|
||||
@@ -922,6 +1013,25 @@ pub struct Relation {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum RelationBody {
|
||||
InterfaceItemAllocation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
order: u32,
|
||||
transmission: TransmissionProtocol,
|
||||
acquisition: AcquisitionProtocol,
|
||||
},
|
||||
DataAssociation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
source_role: String,
|
||||
target_role: String,
|
||||
source_minimum: u32,
|
||||
source_maximum: Option<u32>,
|
||||
target_minimum: u32,
|
||||
target_maximum: Option<u32>,
|
||||
source_navigable: bool,
|
||||
target_navigable: bool,
|
||||
},
|
||||
Allocation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1039,6 +1149,8 @@ impl Relation {
|
||||
impl RelationBody {
|
||||
pub fn kind(&self) -> RelationKind {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation,
|
||||
Self::DataAssociation { .. } => RelationKind::DataAssociation,
|
||||
Self::Allocation { .. } => RelationKind::Allocation,
|
||||
Self::PortAllocation { .. } => RelationKind::PortAllocation,
|
||||
Self::Interaction { .. } => RelationKind::Interaction,
|
||||
@@ -1064,7 +1176,9 @@ impl RelationBody {
|
||||
}
|
||||
pub fn endpoints(&self) -> (Id, Id) {
|
||||
match self {
|
||||
Self::Allocation { source, target }
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
@@ -1089,7 +1203,9 @@ impl RelationBody {
|
||||
}
|
||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||
match self {
|
||||
Self::Allocation { source, target }
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
@@ -1125,6 +1241,25 @@ impl RelationBody {
|
||||
}
|
||||
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
|
||||
match kind {
|
||||
RelationKind::InterfaceItemAllocation => Self::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order: 0,
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
},
|
||||
RelationKind::DataAssociation => Self::DataAssociation {
|
||||
source,
|
||||
target,
|
||||
source_role: String::new(),
|
||||
target_role: String::new(),
|
||||
source_minimum: 0,
|
||||
source_maximum: None,
|
||||
target_minimum: 0,
|
||||
target_maximum: None,
|
||||
source_navigable: false,
|
||||
target_navigable: false,
|
||||
},
|
||||
RelationKind::Allocation => Self::Allocation { source, target },
|
||||
RelationKind::PortAllocation => Self::PortAllocation { source, target },
|
||||
RelationKind::Interaction => Self::Interaction {
|
||||
|
||||
@@ -5,6 +5,8 @@ use thiserror::Error;
|
||||
#[path = "migration_v2/mod.rs"]
|
||||
pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
#[path = "migration_v3/mod.rs"]
|
||||
pub(crate) mod v3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -15,6 +17,50 @@ pub struct MigrationReport {
|
||||
pub generated_endpoints: Vec<GeneratedEndpoint>,
|
||||
pub orientation_defaults: Vec<OrientationDefault>,
|
||||
pub required_resolutions: Vec<Id>,
|
||||
#[serde(default)]
|
||||
pub semantic_defaults: Vec<SemanticDefault>,
|
||||
#[serde(default)]
|
||||
pub generated_interface_allocations: Vec<GeneratedInterfaceAllocation>,
|
||||
#[serde(default)]
|
||||
pub unresolved_message_values: Vec<UnresolvedMessageValue>,
|
||||
}
|
||||
impl MigrationReport {
|
||||
pub(crate) fn data_upgrade(from_schema: u32) -> Self {
|
||||
Self {
|
||||
from_schema,
|
||||
to_schema: SCHEMA_VERSION,
|
||||
preserved_ports: vec![],
|
||||
generated_endpoints: vec![],
|
||||
orientation_defaults: vec![],
|
||||
required_resolutions: vec![],
|
||||
semantic_defaults: vec![],
|
||||
generated_interface_allocations: vec![],
|
||||
unresolved_message_values: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SemanticDefault {
|
||||
pub object: Id,
|
||||
pub field: String,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GeneratedInterfaceAllocation {
|
||||
pub id: Id,
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub alias: String,
|
||||
pub order: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UnresolvedMessageValue {
|
||||
pub occurrence: Id,
|
||||
pub field: Id,
|
||||
pub reason: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -101,13 +147,14 @@ pub fn decode_with_report_limited(
|
||||
migrate_v1_with_report_limited(old, limits)?
|
||||
}
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => DecodeResult {
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
|
||||
pub outgoing_items: Vec<Id>,
|
||||
}
|
||||
|
||||
/// R6 specialization has one explicit seam; current models support identity only.
|
||||
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool {
|
||||
carried == declared
|
||||
/// A specialized carried item satisfies a declared base item.
|
||||
pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool {
|
||||
crate::data::item_satisfies(project, carried, declared)
|
||||
}
|
||||
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
|
||||
carried.iter().all(|item| {
|
||||
@@ -130,13 +130,7 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
|
||||
Some(
|
||||
interfaces
|
||||
.into_iter()
|
||||
.filter_map(|id| p.elements.get(&id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.flat_map(|id| crate::data::interface_items(p, id))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -903,14 +897,10 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
|
||||
..
|
||||
}) => {
|
||||
let mut allowed = declared_interface_items(p, port.id);
|
||||
if let Some(SharedBody::Interface { items }) = legacy
|
||||
.as_ref()
|
||||
.and_then(|l| l.interface)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
{
|
||||
if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
|
||||
let items = crate::data::interface_items(p, interface);
|
||||
if r.kind() == RelationKind::BoundaryExchange
|
||||
&& !items_satisfy(p, r.body.items(), items)
|
||||
&& !items_satisfy(p, r.body.items(), &items)
|
||||
{
|
||||
error(
|
||||
out,
|
||||
@@ -1173,7 +1163,7 @@ pub(crate) fn validate_boundary_interface_requirements(
|
||||
if !p.relations.values().any(|role| {
|
||||
role.kind() == RelationKind::InterfaceProvision
|
||||
&& role.source() == peer.id
|
||||
&& role.target() == required.target()
|
||||
&& crate::data::interface_satisfies(p, role.target(), required.target())
|
||||
}) {
|
||||
warning(
|
||||
out,
|
||||
|
||||
@@ -335,6 +335,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
.collect();
|
||||
let mut edges = Vec::new();
|
||||
for r in p.relations.values() {
|
||||
// Interface allocations are selectable nested dictionary rows. Their
|
||||
// independently identified contracts do not add noisy diagram arrows.
|
||||
if r.kind() == RelationKind::InterfaceItemAllocation {
|
||||
continue;
|
||||
}
|
||||
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
|
||||
continue;
|
||||
}
|
||||
@@ -370,7 +375,8 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
|
||||
&& (source != target
|
||||
|| (d.projection.state_machine().is_some()
|
||||
|| ((d.projection.state_machine().is_some()
|
||||
|| r.kind() == RelationKind::DataAssociation)
|
||||
&& r.source() == r.target()
|
||||
&& nodes
|
||||
.iter()
|
||||
@@ -396,7 +402,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
} | RelationBody::DataAssociation { .. }
|
||||
),
|
||||
source_port: source_port.filter(|id| visible_ports.contains(id)),
|
||||
target_port: target_port.filter(|id| visible_ports.contains(id)),
|
||||
|
||||
@@ -33,8 +33,13 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
type_ref: TypeRef::Named(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Interface { items } => {
|
||||
for id in items {
|
||||
SharedBody::Interface { supertype }
|
||||
| SharedBody::ExchangeItem { supertype, .. }
|
||||
| SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
..
|
||||
} => {
|
||||
if let Some(id) = supertype {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +88,9 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(id);
|
||||
}
|
||||
for value in values {
|
||||
for id in &mut value.path {
|
||||
visit(id);
|
||||
}
|
||||
visit(&mut value.field);
|
||||
}
|
||||
}
|
||||
@@ -377,7 +385,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Interface { supertype }
|
||||
| SharedBody::ExchangeItem { supertype, .. }
|
||||
| SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
..
|
||||
} => clear(supertype),
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => items.retain(|id| !removed.contains(id)),
|
||||
@@ -413,7 +426,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
clear(reply_to);
|
||||
values.retain(|value| !removed.contains(&value.field));
|
||||
values.retain(|value| {
|
||||
!removed.contains(&value.field)
|
||||
&& value.path.iter().all(|id| !removed.contains(id))
|
||||
});
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline } => {
|
||||
clear(action);
|
||||
@@ -485,20 +501,37 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
}) => p
|
||||
.relations
|
||||
.get(id)
|
||||
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
|
||||
}) => p.relations.get(id).map(|r| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let retained: Vec<_> = values
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
crate::data::carried_value_path(p, r, &value.path, value.field).is_some()
|
||||
|| (value.path.is_empty()
|
||||
&& crate::data::carried_field_paths(p, r)
|
||||
.iter()
|
||||
.any(|f| f.field == value.field))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
(e.id, retained)
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (id, fields) in allowed {
|
||||
for (id, retained) in allowed {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
|
||||
{
|
||||
values.retain(|value| fields.contains(&value.field));
|
||||
*values = retained;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
|
||||
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
use std::fmt;
|
||||
@@ -55,6 +55,122 @@ pub struct EdgeLabelGeometry {
|
||||
pub bounds: RoutingBounds,
|
||||
}
|
||||
|
||||
/// Derived labels and navigation markers for the two named association ends.
|
||||
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AssociationEndGeometry {
|
||||
pub source_end: bool,
|
||||
pub label: EdgeLabelGeometry,
|
||||
pub tip: Position,
|
||||
pub inward: Position,
|
||||
pub navigable: bool,
|
||||
}
|
||||
|
||||
pub fn association_end_geometry(
|
||||
relation: &Relation,
|
||||
routed: &RoutedEdge,
|
||||
) -> Vec<AssociationEndGeometry> {
|
||||
let RelationBody::DataAssociation {
|
||||
source_navigable,
|
||||
target_navigable,
|
||||
..
|
||||
} = &relation.body
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
|
||||
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
|
||||
let Some(tip) = (if source_end {
|
||||
routed.points.first()
|
||||
} else {
|
||||
routed.points.last()
|
||||
})
|
||||
.copied() else {
|
||||
continue;
|
||||
};
|
||||
let inward = if source_end {
|
||||
routed.points.iter().find(|p| **p != tip)
|
||||
} else {
|
||||
routed.points.iter().rev().find(|p| **p != tip)
|
||||
};
|
||||
let Some(inward) = inward.copied() else {
|
||||
continue;
|
||||
};
|
||||
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
|
||||
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
|
||||
let role = if role.chars().count() > 32 {
|
||||
role.chars()
|
||||
.take(31)
|
||||
.chain(std::iter::once('…'))
|
||||
.collect::<String>()
|
||||
} else {
|
||||
role.to_owned()
|
||||
};
|
||||
format!("{role} [{multiplicity}")
|
||||
} else {
|
||||
full
|
||||
};
|
||||
let size = Size {
|
||||
width: text.chars().count() as f32 * 7.0 + 12.0,
|
||||
height: 22.0,
|
||||
};
|
||||
let dx = inward.x - tip.x;
|
||||
let dy = inward.y - tip.y;
|
||||
let length = dx.hypot(dy);
|
||||
let direction = pos(dx / length, dy / length);
|
||||
// One normal sign puts opposite endpoints of a straight edge on
|
||||
// opposite sides because their inward tangent directions are reversed.
|
||||
let along =
|
||||
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
|
||||
let normal =
|
||||
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
|
||||
let mut center = pos(
|
||||
tip.x + direction.x * along - direction.y * normal,
|
||||
tip.y + direction.y * along + direction.x * normal,
|
||||
);
|
||||
if let Some(other) = ends.first()
|
||||
&& (center.x - other.label.center.x).abs()
|
||||
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
|
||||
&& (center.y - other.label.center.y).abs()
|
||||
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
|
||||
{
|
||||
// Same-side anchors on a self-loop can still be close. Separate
|
||||
// their complete bounds along this end's normal in one step.
|
||||
let perpendicular = pos(-direction.y, direction.x);
|
||||
let distance = (center.x - other.label.center.x) * perpendicular.x
|
||||
+ (center.y - other.label.center.y) * perpendicular.y;
|
||||
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
|
||||
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
|
||||
/ 2.0
|
||||
+ 8.0;
|
||||
let shift = if distance < 0.0 {
|
||||
-clearance - distance
|
||||
} else {
|
||||
clearance - distance
|
||||
};
|
||||
center.x += perpendicular.x * shift;
|
||||
center.y += perpendicular.y * shift;
|
||||
}
|
||||
ends.push(AssociationEndGeometry {
|
||||
source_end,
|
||||
label: EdgeLabelGeometry {
|
||||
text,
|
||||
fraction: if source_end { 0.0 } else { 1.0 },
|
||||
anchor: tip,
|
||||
center,
|
||||
bounds: RoutingBounds {
|
||||
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
|
||||
size,
|
||||
},
|
||||
},
|
||||
tip,
|
||||
inward,
|
||||
navigable,
|
||||
});
|
||||
}
|
||||
ends
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteWarning {
|
||||
PinInsideObstacle { pin: usize, obstacle: Id },
|
||||
@@ -466,6 +582,14 @@ fn route_diagram_impl(
|
||||
// never changes paths or authored metadata. Previews retain other cached
|
||||
// labels and avoid the paths actually shown during the gesture.
|
||||
let mut previous_labels = Vec::new();
|
||||
for routed in edges.values() {
|
||||
if let Some(relation) = project.relations.get(&routed.id) {
|
||||
for end in association_end_geometry(relation, routed) {
|
||||
bounds.rect(end.label.bounds.position, end.label.bounds.size);
|
||||
previous_labels.push(end.label.bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
for edge in ordered {
|
||||
let Some(routed) = edges.get_mut(&edge.id) else {
|
||||
continue;
|
||||
@@ -579,7 +703,7 @@ fn route_edge(
|
||||
let mut candidates = Vec::new();
|
||||
for a in endpoints(source, edge.source_port, view) {
|
||||
for b in endpoints(target, edge.target_port, view) {
|
||||
// A real self-transition must leave and return at distinct anchors.
|
||||
// A real self-relation must leave and return at distinct anchors.
|
||||
// Reusing one anchor makes the cheapest path a degenerate stub.
|
||||
if edge.source == edge.target
|
||||
&& edge.source_port.is_none()
|
||||
@@ -1107,7 +1231,8 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
let text = match relation.kind() {
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange => relation.name.as_str(),
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::DataAssociation => relation.name.as_str(),
|
||||
RelationKind::CapabilityInclude => "«include»",
|
||||
RelationKind::CapabilityExtend => "«extend»",
|
||||
RelationKind::CapabilityGeneralization => "specializes",
|
||||
|
||||
@@ -114,6 +114,7 @@ pub struct PreparedCommand {
|
||||
struct IdentityProvenance {
|
||||
original: BTreeSet<Id>,
|
||||
current_to_original: BTreeMap<Id, Id>,
|
||||
explicit_replacement: bool,
|
||||
}
|
||||
|
||||
impl IdentityProvenance {
|
||||
@@ -123,10 +124,12 @@ impl IdentityProvenance {
|
||||
Self {
|
||||
original,
|
||||
current_to_original,
|
||||
explicit_replacement: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&mut self, project: &Project) {
|
||||
self.explicit_replacement = true;
|
||||
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
self.current_to_original = self
|
||||
.original
|
||||
@@ -180,6 +183,13 @@ pub fn prepare_command(
|
||||
)?;
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
provenance.retain_present(&candidate);
|
||||
if !provenance.explicit_replacement {
|
||||
crate::data::prune_removed_inheritance(
|
||||
project,
|
||||
&mut candidate,
|
||||
&provenance.current_to_original,
|
||||
);
|
||||
}
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(PreparedCommand {
|
||||
project: candidate,
|
||||
@@ -287,6 +297,13 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project,
|
||||
)?;
|
||||
}
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
if !provenance.explicit_replacement {
|
||||
crate::data::prune_removed_inheritance(
|
||||
project,
|
||||
&mut candidate,
|
||||
&provenance.current_to_original,
|
||||
);
|
||||
}
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
@@ -999,7 +999,7 @@ mod transaction_tests {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Pure, bounded source3 projects. All includes resolve against supplied UTF-8
|
||||
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
|
||||
//! documents; this module never reads a filesystem, URL, or process directory.
|
||||
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
|
||||
use language::{Diagnostic, ParseLimits, Span};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
#[path = "source_project/source_migration.rs"]
|
||||
mod source_migration;
|
||||
#[path = "source_project/source_structure.rs"]
|
||||
mod source_structure;
|
||||
|
||||
@@ -278,21 +280,24 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs.
|
||||
/// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
|
||||
/// Missing/unreachable files and every admission or engineering error leave the
|
||||
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
|
||||
pub fn compile_bundle(
|
||||
bundle: &SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let (compiled, insertions) = compile_once(bundle, limits)?;
|
||||
if insertions.is_empty() {
|
||||
return Ok(compiled);
|
||||
let (mut compiled, insertions) = compile_once(bundle, limits)?;
|
||||
if !insertions.is_empty() {
|
||||
let mut stable = bundle.clone();
|
||||
apply_edits(&mut stable, insertions)?;
|
||||
// Re-index actual returned bytes before performing the schema upgrade.
|
||||
compiled = compile_once(&stable, limits)?.0;
|
||||
}
|
||||
let mut stable = bundle.clone();
|
||||
apply_edits(&mut stable, insertions)?;
|
||||
// Re-index the actual returned bytes. Never expose offsets into pre-ID text.
|
||||
compile_once(&stable, limits).map(|(compiled, _)| compiled)
|
||||
if compiled.decoded.migration.is_some() {
|
||||
return source_migration::upgrade(&compiled, limits);
|
||||
}
|
||||
Ok(compiled)
|
||||
}
|
||||
|
||||
fn compile_once(
|
||||
@@ -568,7 +573,7 @@ fn compile_once(
|
||||
span,
|
||||
}),
|
||||
};
|
||||
let project =
|
||||
let decoded =
|
||||
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
|
||||
.map_err(|(index, error)| {
|
||||
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
|
||||
@@ -578,7 +583,7 @@ fn compile_once(
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
let diagnostics = model_diagnostics(bundle, &index, &project);
|
||||
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
|
||||
let errors: Vec<_> = diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.diagnostic.severity == validation::Severity::Error)
|
||||
@@ -590,10 +595,7 @@ fn compile_once(
|
||||
Ok((
|
||||
CompiledSourceProject {
|
||||
bundle: bundle.clone(),
|
||||
decoded: DecodeResult {
|
||||
project,
|
||||
migration: None,
|
||||
},
|
||||
decoded,
|
||||
index,
|
||||
diagnostics,
|
||||
},
|
||||
@@ -718,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical, readable source3 export. Like `language::format`, the caller is
|
||||
/// Canonical, readable source4 export. Like `language::format`, the caller is
|
||||
/// responsible for supplying a valid model; compilation checks the result.
|
||||
/// Existing source layout is preserved by `update_bundle`, not this formatter.
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
@@ -734,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia 3;\n{}{{\n description {};\n",
|
||||
"arcadia 4;\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
@@ -923,6 +925,7 @@ pub fn update_bundle_with_identity_map(
|
||||
after: new_text,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
@@ -951,6 +954,7 @@ pub fn update_bundle_with_identity_map(
|
||||
after: &after,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
};
|
||||
let mut local = Vec::new();
|
||||
update.header(
|
||||
@@ -1119,6 +1123,9 @@ struct SyntaxUpdate<'a> {
|
||||
after: &'a str,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
// must keep its authored argument trivia during that one-time transformation.
|
||||
legacy_value_paths: bool,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
@@ -1338,11 +1345,11 @@ impl SyntaxUpdate<'_> {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
let old_keys: Option<Vec<_>> = previous
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.before, self.old_aliases))
|
||||
.map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths))
|
||||
.collect();
|
||||
let new_keys: Option<Vec<_>> = after
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.after, self.new_aliases))
|
||||
.map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
|
||||
.collect();
|
||||
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
|
||||
old.iter().collect::<BTreeSet<_>>().len() == old.len()
|
||||
@@ -1449,6 +1456,7 @@ fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
match &value.kind {
|
||||
@@ -1462,7 +1470,31 @@ fn item_key(
|
||||
.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}"))
|
||||
aliases.get(alias).map(|id| {
|
||||
if key == "field" && !legacy_value_paths {
|
||||
let path = fields
|
||||
.get("path")
|
||||
.and_then(|field| {
|
||||
if let EditKind::List(path) = &field.value.kind {
|
||||
path.iter()
|
||||
.map(|hop| {
|
||||
if let EditKind::Reference(alias) = &hop.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("field:{}:{id}", path.join("/"))
|
||||
} else {
|
||||
format!("{key}:{id}")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn upgrade(
|
||||
current: &CompiledSourceProject,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let report = current
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.expect("an explicit source migration report");
|
||||
let project = ¤t.decoded.project;
|
||||
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
.map(|default| default.object)
|
||||
.collect();
|
||||
let aliases: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id.to_string()))
|
||||
.collect();
|
||||
let mut edits: Edits = BTreeMap::new();
|
||||
for (path, source) in ¤t.bundle.files {
|
||||
let declarations =
|
||||
language::edit_declarations(source, path == ¤t.bundle.entry, limits.model)
|
||||
.map_err(|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: *diagnostic,
|
||||
related: vec![],
|
||||
}]
|
||||
})?;
|
||||
for (id, actual) in declarations {
|
||||
if !changed.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(after) = canonical.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let mut next = language::edit_declaration(after).map_err(|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: *diagnostic,
|
||||
related: vec![],
|
||||
}]
|
||||
})?;
|
||||
if actual.lexical_owner.is_some()
|
||||
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
||||
&& !fields.contains_key("parent")
|
||||
&& let language::EditKind::Record(fields) = &mut next.fields.kind
|
||||
{
|
||||
fields.remove("parent");
|
||||
}
|
||||
let update = SyntaxUpdate {
|
||||
source,
|
||||
before: source,
|
||||
after,
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let source = ¤t.bundle.files[¤t.bundle.entry];
|
||||
let version = significant(source)
|
||||
.get(1)
|
||||
.map(|token| token.span)
|
||||
.expect("a parsed version token");
|
||||
edits
|
||||
.entry(current.bundle.entry.clone())
|
||||
.or_default()
|
||||
.push((version, "4".into()));
|
||||
// New allocation relations are emitted into the allocating interface's
|
||||
// existing module, outside its lexical element body. Existing source never moves.
|
||||
let mut added: BTreeMap<String, String> = BTreeMap::new();
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
let path = current
|
||||
.index
|
||||
.declarations
|
||||
.get(&allocation.source)
|
||||
.map(|location| location.path.clone())
|
||||
.unwrap_or_else(|| current.bundle.entry.clone());
|
||||
let ending = newline(¤t.bundle.files[&path]);
|
||||
let text = added.entry(path).or_default();
|
||||
text.push_str(ending);
|
||||
text.push_str(&canonical[&allocation.id].replace('\n', ending));
|
||||
}
|
||||
for (path, text) in added {
|
||||
let offset = if path == current.bundle.entry {
|
||||
current.index.project_body.span.end
|
||||
} else {
|
||||
current.bundle.files[&path].len()
|
||||
};
|
||||
edits.entry(path).or_default().push((
|
||||
Span {
|
||||
start: offset,
|
||||
end: offset,
|
||||
},
|
||||
text,
|
||||
));
|
||||
}
|
||||
let mut bundle = current.bundle.clone();
|
||||
apply_edits(&mut bundle, edits)?;
|
||||
let (mut result, insertions) = compile_once(&bundle, limits)?;
|
||||
if !insertions.is_empty() || result.decoded.project != *project {
|
||||
return Err(vec![problem(
|
||||
&bundle,
|
||||
&bundle.entry,
|
||||
Span::default(),
|
||||
"migration_source_mismatch",
|
||||
"Targeted source3 upgrade did not reproduce the migrated project.",
|
||||
)]);
|
||||
}
|
||||
result.decoded.migration = Some(report.clone());
|
||||
Ok(result)
|
||||
}
|
||||
@@ -251,6 +251,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
}
|
||||
(
|
||||
RelationBody::Allocation { .. }
|
||||
| RelationBody::InterfaceItemAllocation { .. }
|
||||
| RelationBody::DataAssociation { .. }
|
||||
| RelationBody::ExchangeAllocation { .. }
|
||||
| RelationBody::CapabilityInvolvement { .. }
|
||||
| RelationBody::CapabilityInclude { .. }
|
||||
@@ -421,6 +423,24 @@ pub fn plan_transition(
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
// Allocations are owned by interfaces. Independent data
|
||||
// associations travel only when both endpoint types are
|
||||
// already required; they do not expand the dictionary.
|
||||
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
|
||||
let contracts: Vec<_> = project
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|r| {
|
||||
((r.kind() == RelationKind::InterfaceItemAllocation
|
||||
&& r.source() == id)
|
||||
|| (r.kind() == RelationKind::DataAssociation
|
||||
&& selected.contains(&r.source())
|
||||
&& selected.contains(&r.target())))
|
||||
.then_some(r.id)
|
||||
})
|
||||
.collect();
|
||||
selected.extend(contracts);
|
||||
}
|
||||
// Contextual links are owned by the process/chain shared by
|
||||
// their endpoint involvements, independently of list order.
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
|
||||
@@ -388,68 +388,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
if let Some(shared) = e.shared() {
|
||||
match shared {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
} => {
|
||||
let mut seen = BTreeSet::new();
|
||||
for literal in literals {
|
||||
text(&mut out, e.id, "literals", literal);
|
||||
if !seen.insert(literal) {
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"duplicate_literal",
|
||||
"literals",
|
||||
"Enumeration literals must be unique.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SharedBody::DataField {
|
||||
type_ref,
|
||||
minimum,
|
||||
maximum,
|
||||
unit,
|
||||
} => {
|
||||
if maximum.is_some_and(|max| max < *minimum) {
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"invalid_multiplicity",
|
||||
"maximum",
|
||||
"Maximum multiplicity must be at least minimum, or unbounded.",
|
||||
);
|
||||
}
|
||||
text(&mut out, e.id, "unit", unit);
|
||||
if let TypeRef::Named(id) = type_ref {
|
||||
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
|
||||
}
|
||||
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
|
||||
&& parent.kind() == Kind::DataType
|
||||
&& !matches!(
|
||||
parent.shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record
|
||||
})
|
||||
)
|
||||
{
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"field_owner",
|
||||
"parent",
|
||||
"Fields under a data type require a Record type.",
|
||||
);
|
||||
}
|
||||
}
|
||||
SharedBody::Interface { items } => {
|
||||
check_ids(&mut out, e.id, "items", items);
|
||||
for id in items {
|
||||
expect_element(p, &mut out, e.id, *id, "items", |k| {
|
||||
k == Kind::ExchangeItem
|
||||
});
|
||||
}
|
||||
}
|
||||
SharedBody::DataType { .. }
|
||||
| SharedBody::DataField { .. }
|
||||
| SharedBody::Interface { .. }
|
||||
| SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
|
||||
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
|
||||
SharedBody::Scenario {
|
||||
viewpoint,
|
||||
@@ -643,9 +585,11 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, r.id, "name", &r.name);
|
||||
text(&mut out, r.id, "description", &r.description);
|
||||
validate_relation(p, r, &mut out);
|
||||
crate::data::validate_relation(p, r, &mut out);
|
||||
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
|
||||
validate_relation_context(p, r, &mut out);
|
||||
}
|
||||
crate::data::validate_record_cycles(p, &mut out);
|
||||
for (key, d) in &p.diagrams {
|
||||
if *key != d.id {
|
||||
error(
|
||||
@@ -1436,23 +1380,10 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
|
||||
}
|
||||
/// Fields carried by an exchange, including fields of nested named records.
|
||||
pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> {
|
||||
let mut pending = relation.body.items().to_vec();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut fields = BTreeSet::new();
|
||||
while let Some(owner) = pending.pop() {
|
||||
if !seen.insert(owner) {
|
||||
continue;
|
||||
}
|
||||
for field in p.children(owner) {
|
||||
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
|
||||
fields.insert(field.id);
|
||||
if let TypeRef::Named(id) = type_ref {
|
||||
pending.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fields
|
||||
crate::data::carried_field_paths(p, relation)
|
||||
.into_iter()
|
||||
.map(|f| f.field)
|
||||
.collect()
|
||||
}
|
||||
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
|
||||
match body {
|
||||
@@ -1683,36 +1614,125 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
}
|
||||
}
|
||||
}
|
||||
let allowed = exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.map(|r| carried_fields(p, r));
|
||||
let relation = exchange.and_then(|id| p.relations.get(&id));
|
||||
if message_kind == &MessageKind::Call
|
||||
&& relation.is_some_and(|r| {
|
||||
!r.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation))
|
||||
})
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"call_without_operation",
|
||||
"exchange",
|
||||
"This generic call carries no Operation item; define an operation contract when appropriate.",
|
||||
);
|
||||
}
|
||||
let mut fields = BTreeSet::new();
|
||||
for value in values {
|
||||
expect_element(p, out, e.id, value.field, "values.field", |kind| {
|
||||
kind == Kind::DataField
|
||||
});
|
||||
for hop in &value.path {
|
||||
expect_element(p, out, e.id, *hop, "values.path", |kind| {
|
||||
kind == Kind::DataField
|
||||
});
|
||||
}
|
||||
text(out, e.id, "values.expression", &value.expression);
|
||||
if !fields.insert(value.field) {
|
||||
if !fields.insert((&value.path, value.field)) {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"duplicate_message_value",
|
||||
"values",
|
||||
"Each carried field has at most one contextual value annotation in a message.",
|
||||
"Each qualified carried field has at most one contextual value annotation.",
|
||||
);
|
||||
}
|
||||
if allowed
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(&value.field))
|
||||
{
|
||||
if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_field",
|
||||
"values.field",
|
||||
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
|
||||
"message_value_path_limit",
|
||||
"values.path",
|
||||
"The value path exceeds the bounded authoring depth.",
|
||||
);
|
||||
}
|
||||
if let Some(r) = relation {
|
||||
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
|
||||
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
|
||||
let permitted = match message_kind {
|
||||
MessageKind::Call => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Reply => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::Out
|
||||
| ParameterDirection::InOut
|
||||
| ParameterDirection::Return
|
||||
),
|
||||
_ => true,
|
||||
};
|
||||
if !permitted {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_parameter_phase",
|
||||
"values.path",
|
||||
"Call values address In/InOut parameters; replies address Out/InOut/Return parameters.",
|
||||
);
|
||||
}
|
||||
if path.direction == ParameterDirection::Unspecified {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"message_parameter_direction",
|
||||
"values.path",
|
||||
"The containing operation parameter has no direction yet.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let legacy = value
|
||||
.path
|
||||
.is_empty()
|
||||
.then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok())
|
||||
.flatten();
|
||||
if matches!(
|
||||
legacy,
|
||||
Some(
|
||||
crate::data::LegacyFieldPathResolution::Unique(_)
|
||||
| crate::data::LegacyFieldPathResolution::Ambiguous
|
||||
)
|
||||
) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) {
|
||||
"message_value_ambiguous_path"
|
||||
} else {
|
||||
"message_value_missing_path"
|
||||
},
|
||||
"values.path",
|
||||
"This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.",
|
||||
);
|
||||
} else {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_field",
|
||||
"values.path",
|
||||
"The qualified field must be carried by the exchange, following Value record fields only.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if value.expression.trim().is_empty() {
|
||||
warning(
|
||||
out,
|
||||
@@ -2130,7 +2150,11 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
};
|
||||
if a.layer() != b.layer()
|
||||
|| !r.kind().permits(a.kind(), b.kind())
|
||||
|| (a.id == b.id && r.kind() != RelationKind::StateTransition)
|
||||
|| (a.id == b.id
|
||||
&& !matches!(
|
||||
r.kind(),
|
||||
RelationKind::StateTransition | RelationKind::DataAssociation
|
||||
))
|
||||
{
|
||||
error(
|
||||
out,
|
||||
@@ -2381,8 +2405,10 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
|
||||
owner.layer() == layer
|
||||
&& parent_permits(owner.kind(), child_kind)
|
||||
&& match owner.shared() {
|
||||
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => {
|
||||
*definition == DataTypeDefinition::Record
|
||||
Some(SharedBody::DataType { definition, .. })
|
||||
if child_kind == Kind::DataField =>
|
||||
{
|
||||
matches!(definition, DataTypeDefinition::Record { .. })
|
||||
}
|
||||
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
|
||||
matches!(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Frozen authored schema3 compatibility input. Do not rewrite as current syntax.
|
||||
arcadia 3;
|
||||
project legacy_data "Legacy data contract" id "00000000-0000-0000-0000-000000000001" {
|
||||
sa_function produce "Produce" id "00000000-0000-0000-0000-000000000002" {}
|
||||
sa_function consume "Consume" id "00000000-0000-0000-0000-000000000003" {}
|
||||
sa_exchange_item packet "Position event" id "00000000-0000-0000-0000-000000000004" {
|
||||
kind Event;
|
||||
// Reused record parameters retain their own identities.
|
||||
sa_data_field origin "Origin" id "00000000-0000-0000-0000-000000000007" { type_ref { Named @coordinate; }; }
|
||||
sa_data_field destination "Destination" id "00000000-0000-0000-0000-000000000008" { type_ref { Named @coordinate; }; }
|
||||
}
|
||||
sa_data_type coordinate "Coordinate" id "00000000-0000-0000-0000-000000000005" {
|
||||
definition Record;
|
||||
sa_data_field latitude "Latitude" id "00000000-0000-0000-0000-000000000006" { type_ref Real; unit "deg"; }
|
||||
}
|
||||
sa_interface sender_contract "Sender contract" id "00000000-0000-0000-0000-000000000009" { items [@packet]; }
|
||||
sa_interface receiver_contract "Receiver contract" id "00000000-0000-0000-0000-00000000000a" { items [@packet]; }
|
||||
sa_port output "Output" id "00000000-0000-0000-0000-00000000000b" {
|
||||
parent @produce; contract { FunctionOutput { outgoing_items [@packet]; }; }; legacy none;
|
||||
}
|
||||
sa_port input "Input" id "00000000-0000-0000-0000-00000000000c" {
|
||||
parent @consume; contract { FunctionInput { incoming_items [@packet]; }; }; legacy none;
|
||||
}
|
||||
functional_exchange position_exchange "Position exchange" id "00000000-0000-0000-0000-00000000000d" {
|
||||
source @produce; target @consume; source_port @output; target_port @input; items [@packet];
|
||||
}
|
||||
sa_scenario scenario "Position scenario" id "00000000-0000-0000-0000-00000000000e" {
|
||||
viewpoint Functional; steps [@message];
|
||||
sa_lifeline sender "Sender" id "00000000-0000-0000-0000-00000000000f" { represented @produce; }
|
||||
sa_lifeline receiver "Receiver" id "00000000-0000-0000-0000-000000000010" { represented @consume; }
|
||||
sa_occurrence message "Position" id "00000000-0000-0000-0000-000000000011" {
|
||||
body { Exchange {
|
||||
exchange @position_exchange; sender @sender; receiver @receiver;
|
||||
values [{ field @latitude; expression "navigation.latitude"; }];
|
||||
}; };
|
||||
}
|
||||
}
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"alias": "legacy_data",
|
||||
"name": "Legacy data contract",
|
||||
"description": "",
|
||||
"elements": {
|
||||
"00000000-0000-0000-0000-000000000002": {
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"alias": "produce",
|
||||
"name": "Produce",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000003": {
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"alias": "consume",
|
||||
"name": "Consume",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000004": {
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"alias": "packet",
|
||||
"name": "Position event",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"ExchangeItem": {
|
||||
"kind": "Event"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000005": {
|
||||
"id": "00000000-0000-0000-0000-000000000005",
|
||||
"alias": "coordinate",
|
||||
"name": "Coordinate",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataType": {
|
||||
"definition": "Record"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000006": {
|
||||
"id": "00000000-0000-0000-0000-000000000006",
|
||||
"alias": "latitude",
|
||||
"name": "Latitude",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000005",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": "Real",
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": "deg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000007": {
|
||||
"id": "00000000-0000-0000-0000-000000000007",
|
||||
"alias": "origin",
|
||||
"name": "Origin",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000004",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": {
|
||||
"Named": "00000000-0000-0000-0000-000000000005"
|
||||
},
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000008": {
|
||||
"id": "00000000-0000-0000-0000-000000000008",
|
||||
"alias": "destination",
|
||||
"name": "Destination",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000004",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": {
|
||||
"Named": "00000000-0000-0000-0000-000000000005"
|
||||
},
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000009": {
|
||||
"id": "00000000-0000-0000-0000-000000000009",
|
||||
"alias": "sender_contract",
|
||||
"name": "Sender contract",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Interface": {
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000a": {
|
||||
"id": "00000000-0000-0000-0000-00000000000a",
|
||||
"alias": "receiver_contract",
|
||||
"name": "Receiver contract",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Interface": {
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000b": {
|
||||
"id": "00000000-0000-0000-0000-00000000000b",
|
||||
"alias": "output",
|
||||
"name": "Output",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000002",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionOutput": {
|
||||
"outgoing_items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000c": {
|
||||
"id": "00000000-0000-0000-0000-00000000000c",
|
||||
"alias": "input",
|
||||
"name": "Input",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000003",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionInput": {
|
||||
"incoming_items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000e": {
|
||||
"id": "00000000-0000-0000-0000-00000000000e",
|
||||
"alias": "scenario",
|
||||
"name": "Position scenario",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Scenario": {
|
||||
"viewpoint": "Functional",
|
||||
"context": null,
|
||||
"precondition": "",
|
||||
"postcondition": "",
|
||||
"steps": [
|
||||
"00000000-0000-0000-0000-000000000011"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000f": {
|
||||
"id": "00000000-0000-0000-0000-00000000000f",
|
||||
"alias": "sender",
|
||||
"name": "Sender",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Lifeline": {
|
||||
"represented": "00000000-0000-0000-0000-000000000002"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000010": {
|
||||
"id": "00000000-0000-0000-0000-000000000010",
|
||||
"alias": "receiver",
|
||||
"name": "Receiver",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Lifeline": {
|
||||
"represented": "00000000-0000-0000-0000-000000000003"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000011": {
|
||||
"id": "00000000-0000-0000-0000-000000000011",
|
||||
"alias": "message",
|
||||
"name": "Position",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Occurrence": {
|
||||
"body": {
|
||||
"Exchange": {
|
||||
"exchange": "00000000-0000-0000-0000-00000000000d",
|
||||
"sender": "00000000-0000-0000-0000-00000000000f",
|
||||
"receiver": "00000000-0000-0000-0000-000000000010",
|
||||
"message_kind": "Asynchronous",
|
||||
"reply_to": null,
|
||||
"values": [
|
||||
{
|
||||
"field": "00000000-0000-0000-0000-000000000006",
|
||||
"expression": "navigation.latitude"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"precondition": "",
|
||||
"postcondition": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"00000000-0000-0000-0000-00000000000d": {
|
||||
"id": "00000000-0000-0000-0000-00000000000d",
|
||||
"alias": "position_exchange",
|
||||
"name": "Position exchange",
|
||||
"description": "",
|
||||
"body": {
|
||||
"FunctionalExchange": {
|
||||
"source": "00000000-0000-0000-0000-000000000002",
|
||||
"target": "00000000-0000-0000-0000-000000000003",
|
||||
"source_port": "00000000-0000-0000-0000-00000000000b",
|
||||
"target_port": "00000000-0000-0000-0000-00000000000c",
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagrams": {},
|
||||
"artifact_links": {},
|
||||
"traces": {}
|
||||
}
|
||||
@@ -168,6 +168,20 @@ project mission "Harbor safety — 港 🌊" {
|
||||
.find(|e| e.alias == "event_record")
|
||||
.unwrap()
|
||||
.id;
|
||||
let associated = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Associated record", None)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
p.elements.get_mut(&associated).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::DataAssociation,
|
||||
"Related record",
|
||||
record,
|
||||
associated,
|
||||
);
|
||||
for (index, type_ref) in [
|
||||
TypeRef::Boolean,
|
||||
TypeRef::Integer,
|
||||
@@ -190,18 +204,30 @@ project mission "Harbor safety — 港 🌊" {
|
||||
minimum: 1,
|
||||
maximum: Some(5),
|
||||
unit: "unit".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
for definition in [
|
||||
DataTypeDefinition::Boolean,
|
||||
DataTypeDefinition::Integer,
|
||||
DataTypeDefinition::String,
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: None,
|
||||
maximum: None,
|
||||
},
|
||||
DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
] {
|
||||
let id = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Primitive type", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::DataType { definition };
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition,
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
for kind in [
|
||||
ExchangeItemKind::Material,
|
||||
@@ -216,7 +242,11 @@ project mission "Harbor safety — 港 🌊" {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind };
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
supertype: None,
|
||||
};
|
||||
}
|
||||
let scenario = p
|
||||
.elements
|
||||
@@ -461,7 +491,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 4; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 5; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -765,9 +795,27 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(from_source, from_json);
|
||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||
assert_eq!(retained["schema_version"], 3);
|
||||
assert_eq!(retained["schema_version"], 4);
|
||||
retained["schema_version"] = 2.into();
|
||||
let allocations: Vec<_> = from_json
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|relation| {
|
||||
if let RelationBody::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order,
|
||||
..
|
||||
} = relation.body
|
||||
{
|
||||
Some((source, target, order))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
|
||||
let element_id: Id = element["id"].as_str().unwrap().parse().unwrap();
|
||||
for layer in ["Oa", "Sa"] {
|
||||
let Some(shared) = element["body"]
|
||||
.get_mut(layer)
|
||||
@@ -779,6 +827,46 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(port["contract"], "Legacy");
|
||||
*port = port["legacy"].clone();
|
||||
}
|
||||
if let Some(item) = shared
|
||||
.get_mut("ExchangeItem")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
if item["mechanism"] == "Event" {
|
||||
item.insert("kind".into(), "Event".into());
|
||||
}
|
||||
item.remove("mechanism");
|
||||
item.remove("supertype");
|
||||
}
|
||||
if let Some(kind) = shared
|
||||
.get_mut("DataType")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
kind.remove("default");
|
||||
kind.remove("unit");
|
||||
let definition = &kind["definition"];
|
||||
if let Some(name) = ["Integer", "Real", "String", "Record"]
|
||||
.into_iter()
|
||||
.find(|name| definition.get(name).is_some())
|
||||
{
|
||||
kind.insert("definition".into(), name.into());
|
||||
}
|
||||
}
|
||||
if let Some(field) = shared
|
||||
.get_mut("DataField")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
for key in ["direction", "storage", "default"] {
|
||||
field.remove(key);
|
||||
}
|
||||
}
|
||||
if let Some(interface) = shared.get_mut("Interface") {
|
||||
let mut entries: Vec<_> = allocations
|
||||
.iter()
|
||||
.filter(|(source, _, _)| *source == element_id)
|
||||
.collect();
|
||||
entries.sort_by_key(|(_, _, order)| *order);
|
||||
*interface = serde_json::json!({"items":entries.into_iter().map(|(_,target,_)|target).collect::<Vec<_>>()});
|
||||
}
|
||||
if let Some(scenario) = shared
|
||||
.get_mut("Scenario")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
@@ -797,6 +885,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
}
|
||||
}
|
||||
}
|
||||
retained["relations"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|_, relation| relation["body"].get("InterfaceItemAllocation").is_none());
|
||||
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
|
||||
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
|
||||
assert_eq!(boundary["oriented"], true);
|
||||
@@ -826,7 +918,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
);
|
||||
assert_eq!(
|
||||
from_json.relations.len(),
|
||||
original["relations"].as_object().unwrap().len()
|
||||
original["relations"].as_object().unwrap().len() + allocations.len()
|
||||
);
|
||||
assert_eq!(
|
||||
from_json.diagrams.len(),
|
||||
@@ -899,7 +991,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
||||
assert_eq!(from_text, from_json);
|
||||
assert_eq!(read(text), from_text);
|
||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||
assert_eq!(from_text.schema_version, 3);
|
||||
assert_eq!(from_text.schema_version, 4);
|
||||
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
for (id, record) in raw["elements"].as_object().unwrap() {
|
||||
@@ -1100,6 +1192,7 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
}
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
|| code.trim_start().starts_with("arcadia 4;")
|
||||
{
|
||||
read(&code);
|
||||
examples += 1;
|
||||
|
||||
@@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
assert_eq!(decoded.project, from_source.project);
|
||||
assert_eq!(decoded.migration, from_source.migration);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 3));
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 4));
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
@@ -56,6 +56,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
+ old["traces"].as_object().unwrap().len()
|
||||
+ old["artifact_links"].as_object().unwrap().len()
|
||||
+ 1
|
||||
+ report.generated_interface_allocations.len()
|
||||
);
|
||||
let mut current = serde_json::to_value(&decoded.project).unwrap();
|
||||
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
@@ -143,7 +144,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 3;"));
|
||||
assert!(canonical.starts_with("arcadia 4;"));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
@@ -223,7 +224,13 @@ fn migration_admission_counts_generated_objects_records_and_references() {
|
||||
_ => 0,
|
||||
})
|
||||
})
|
||||
.sum::<usize>();
|
||||
.sum::<usize>()
|
||||
+ decode(&old)
|
||||
.migration
|
||||
.unwrap()
|
||||
.generated_interface_allocations
|
||||
.len()
|
||||
* 2;
|
||||
for limits in [
|
||||
ParseLimits {
|
||||
max_graph_objects: graph + 1,
|
||||
@@ -244,7 +251,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
|
||||
));
|
||||
}
|
||||
let accepted = ParseLimits {
|
||||
max_graph_objects: graph + 2,
|
||||
max_graph_objects: migrated.elements.len() + migrated.relations.len(),
|
||||
max_records: records,
|
||||
max_step_references: reference_count,
|
||||
..ParseLimits::UNLIMITED
|
||||
@@ -267,7 +274,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
|
||||
assert!(error.contains("V2") && error.contains(&fe), "{error}");
|
||||
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||
let result = persistence::decode_with_report(v1).unwrap();
|
||||
assert_eq!(result.project.schema_version, 3);
|
||||
assert_eq!(result.project.schema_version, 4);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use oa_core::{language, persistence, *};
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
const SOURCE: &str = r#"arcadia 4;
|
||||
project typed_ports "Typed ports" {
|
||||
sa_system producer "Producer" {}
|
||||
sa_actor consumer "Consumer" {}
|
||||
@@ -9,7 +9,8 @@ project typed_ports "Typed ports" {
|
||||
allocation sends "Produces" { source @producer; target @send; }
|
||||
allocation receives "Consumes" { source @consumer; target @receive; }
|
||||
sa_exchange_item data "Data" {}
|
||||
sa_interface service "Service" { items [@data]; }
|
||||
sa_interface service "Service" {}
|
||||
interface_item_allocation service_data "Service data" { source @service; target @data; }
|
||||
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }
|
||||
sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
|
||||
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
|
||||
@@ -26,7 +27,7 @@ project typed_ports "Typed ports" {
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
assert!(decoded.migration.is_none());
|
||||
let canonical = language::format(&decoded.project);
|
||||
@@ -69,7 +70,7 @@ fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||
fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||
for (remove, expected) in [
|
||||
("source_port @output; ", "source_port"),
|
||||
("target_port @input; ", "target_port"),
|
||||
@@ -90,7 +91,7 @@ fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||
fn source4_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||
let mixed = SOURCE.replace(
|
||||
"parent @consumer; contract { Component { kind Standard; orientation Unset;",
|
||||
"parent @consumer; contract { Component { kind Flow; orientation Unset;",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
use oa_core::{language, persistence, *};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const SOURCE3: &str = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(n)
|
||||
}
|
||||
|
||||
// Frozen JSON uses the same authored schema3 contract as the source fixture;
|
||||
// this helper never serializes current defaults under version3.
|
||||
fn original3() -> Value {
|
||||
serde_json::from_str(include_str!("fixtures/v3-data-contract.json")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() {
|
||||
let old = original3();
|
||||
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
let source = language::parse_with_report(SOURCE3).unwrap();
|
||||
assert_eq!(decoded.project, source.project);
|
||||
assert_eq!(decoded.migration, source.migration);
|
||||
let project = &decoded.project;
|
||||
let report = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 4));
|
||||
assert_eq!(project.identities().len(), 19);
|
||||
assert!(report.preserved_ports.is_empty());
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.generated_interface_allocations.len(), 2);
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
assert_eq!(
|
||||
allocation.id,
|
||||
Id::new_v5(
|
||||
&id(1),
|
||||
format!(
|
||||
"v2-interface-item:{}:{}",
|
||||
allocation.source, allocation.target
|
||||
)
|
||||
.as_bytes()
|
||||
)
|
||||
);
|
||||
assert_eq!(allocation.target, id(4));
|
||||
assert_eq!(allocation.order, 0);
|
||||
assert!(matches!(
|
||||
project.relations[&allocation.id].body,
|
||||
RelationBody::InterfaceItemAllocation {
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
let now = serde_json::to_value(project).unwrap();
|
||||
for port in [id(11), id(12)] {
|
||||
assert_eq!(
|
||||
now["elements"][port.to_string()],
|
||||
old["elements"][port.to_string()]
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
project.elements[&id(4)].shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Event,
|
||||
supertype: None
|
||||
})
|
||||
));
|
||||
assert_eq!(report.unresolved_message_values.len(), 1);
|
||||
assert!(
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let canonical = language::format(project);
|
||||
assert!(canonical.starts_with("arcadia 4;"));
|
||||
let reopened = language::parse_with_report(&canonical).unwrap();
|
||||
assert_eq!(&reopened.project, project);
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(canonical, language::format(&reopened.project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_changed_payloads_reject_schema4_fields_and_new_constructor_names() {
|
||||
let mut old = original3();
|
||||
old["elements"][id(4).to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["mechanism"] =
|
||||
json!("Flow");
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let mut old = original3();
|
||||
old["elements"][id(5).to_string()]["body"]["Sa"]["Shared"]["DataType"]["definition"] =
|
||||
json!({"Record":{"supertype":null}});
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let mut old = original3();
|
||||
old["elements"][id(17).to_string()]["body"]["Sa"]["Shared"]["Occurrence"]["body"]["Exchange"]
|
||||
["values"][0]["path"] = json!([]);
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let source = SOURCE3.replace("kind Event;", "kind Information; mechanism Flow;");
|
||||
assert!(language::parse(&source).is_err());
|
||||
let source = SOURCE3.replace(
|
||||
"functional_exchange position_exchange",
|
||||
"interface_item_allocation position_exchange",
|
||||
);
|
||||
assert!(language::parse(&source).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_allocation_id_collision_is_located_without_overwriting_any_object() {
|
||||
let mut old = original3();
|
||||
let collision = Id::new_v5(
|
||||
&id(1),
|
||||
format!("v2-interface-item:{}:{}", id(9), id(4)).as_bytes(),
|
||||
);
|
||||
old["artifact_links"][collision.to_string()] = json!({"id":collision,"alias":"retained_artifact","name":"Retain","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
|
||||
let error = persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap_err();
|
||||
match error {
|
||||
persistence::FileError::Model { issues, .. } => {
|
||||
assert_eq!(issues[0].code, "identity_collision");
|
||||
assert_eq!(issues[0].element, Some(id(9)));
|
||||
assert_eq!(issues[0].field.as_deref(), Some("items"));
|
||||
}
|
||||
other => panic!("unexpected error {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_legacy_nested_value_is_qualified_and_physical_nature_is_not_guessed() {
|
||||
let source = SOURCE3.replace("kind Event;", "kind Material;").replace(
|
||||
"type_ref { Named @coordinate; }; }\n }",
|
||||
"type_ref String; }\n }",
|
||||
);
|
||||
let decoded = language::parse_with_report(&source).unwrap();
|
||||
assert!(matches!(
|
||||
decoded.project.elements[&id(4)].shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
..
|
||||
})
|
||||
));
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = decoded.project.elements[&id(17)].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(values[0].path, vec![id(7)]);
|
||||
assert_eq!(values[0].expression, "navigation.latitude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
|
||||
let v2 =
|
||||
persistence::decode_with_report(include_bytes!("fixtures/v2-coastal-rescue.json")).unwrap();
|
||||
let text =
|
||||
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
|
||||
assert_eq!(v2.project, text.project);
|
||||
assert_eq!(v2.migration, text.migration);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, 4);
|
||||
let v1 = language::parse_with_report(
|
||||
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v1.project.schema_version, 4);
|
||||
assert_eq!(v1.migration.unwrap().from_schema, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_alias_collisions_are_stable_and_duplicate_legacy_payload_fields_reject() {
|
||||
let mut old = original3();
|
||||
let first = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
let alias = first.migration.unwrap().generated_interface_allocations[0]
|
||||
.alias
|
||||
.clone();
|
||||
old["artifact_links"][id(100).to_string()] = json!({"id":id(100),"alias":alias,"name":"Existing alias","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let migrated = persistence::decode_with_report(&bytes).unwrap();
|
||||
let generated = &migrated
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.generated_interface_allocations[0];
|
||||
assert_ne!(generated.alias, alias);
|
||||
assert!(generated.alias.starts_with(&alias));
|
||||
assert_eq!(
|
||||
migrated.project,
|
||||
persistence::decode_with_report(&bytes).unwrap().project
|
||||
);
|
||||
let duplicate = include_str!("fixtures/v3-data-contract.json").replace(
|
||||
"\"kind\": \"Event\"",
|
||||
"\"kind\": \"Event\", \"kind\": \"Material\"",
|
||||
);
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Duplicate field 'kind'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_legacy_record_paths_remain_ambiguous_instead_of_becoming_one_argument() {
|
||||
let mut old = original3();
|
||||
old["elements"][id(8).to_string()]["body"]["Sa"]["Shared"]["DataField"]["type_ref"] =
|
||||
json!("String");
|
||||
old["elements"][id(100).to_string()] = json!({"id":id(100),"alias":"next_coordinate","name":"Next coordinate","description":"","parent":id(5),"body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":id(5)},"minimum":0,"maximum":1,"unit":""}}}}});
|
||||
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.unresolved_message_values
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = decoded.project.elements[&id(17)].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(values[0].path.is_empty());
|
||||
assert!(
|
||||
validation::validate(&decoded.project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_identity() {
|
||||
let original = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
let header = original.find(" sa_function").unwrap();
|
||||
let close = original.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} // Contracts stay in their original module.\n include \"contracts.arcadia\";\n}}\n",
|
||||
&original[..header]
|
||||
);
|
||||
let fragment = original[header..close].replace(
|
||||
"items [@packet];",
|
||||
"items [@ // interface contract decision\npacket];",
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), entry),
|
||||
("contracts.arcadia".into(), fragment),
|
||||
]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files.len(), 2);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 4;"));
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
|
||||
for text in [
|
||||
"// Contracts stay in their original module.",
|
||||
"// Reused record parameters retain their own identities.",
|
||||
"// interface contract decision",
|
||||
] {
|
||||
assert!(
|
||||
compiled
|
||||
.bundle
|
||||
.files
|
||||
.values()
|
||||
.any(|source| source.contains(text)),
|
||||
"Lost {text}"
|
||||
);
|
||||
}
|
||||
assert_eq!(compiled.index.lexical_owners[&id(6)], id(5));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(7)], id(4));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(8)], id(4));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
|
||||
let report = compiled.decoded.migration.as_ref().unwrap();
|
||||
assert_eq!(report.from_schema, 3);
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
assert_eq!(
|
||||
compiled.index.declarations[&allocation.id].path,
|
||||
"contracts.arcadia"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let second = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(second.bundle, compiled.bundle);
|
||||
assert_eq!(second.decoded.project, compiled.decoded.project);
|
||||
assert!(second.decoded.migration.is_none());
|
||||
let mut renamed = compiled.decoded.project.clone();
|
||||
renamed.elements.get_mut(&id(6)).unwrap().name = "Latitude degrees".into();
|
||||
let updated = update_bundle(&compiled, &renamed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.index.lexical_owners, compiled.index.lexical_owners);
|
||||
assert!(updated.bundle.files["contracts.arcadia"].contains("Latitude degrees"));
|
||||
assert!(
|
||||
bundle.files["project.arcadia"].contains("arcadia 3;"),
|
||||
"Migration does not mutate the caller's draft"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema4_typed_values_supertype_paths_and_protocols_roundtrip() {
|
||||
let source = r#"arcadia 4; project data_contract "Data contract" {
|
||||
sa_data_type bounded "Bounded" { definition { Integer { minimum -9223372036854775808; maximum 9223372036854775807; }; }; default { Integer -9223372036854775808; }; unit "m"; }
|
||||
sa_data_type label_type "Label" { definition { String { minimum_length 1; maximum_length 4; }; }; default { String "航海"; }; }
|
||||
sa_data_type base "Base" { definition { Record { supertype none; }; }; }
|
||||
sa_data_type derived "Derived" { definition { Record { supertype @base; }; }; }
|
||||
sa_exchange_item operation "Operation" { mechanism Operation;
|
||||
sa_data_field value "Value" { type_ref { Named @bounded; }; direction In; storage Value; default [{ Integer 42; }]; }
|
||||
}
|
||||
sa_interface contract "Contract" { supertype none; }
|
||||
interface_item_allocation member "Operation member" { source @contract; target @operation; transmission Synchronous; acquisition Accept; order 3; }
|
||||
data_association association "Related records" { source @base; target @derived; source_role "parent"; target_role "children"; source_minimum 0; source_maximum 1; target_minimum 0; target_maximum none; source_navigable true; target_navigable true; }
|
||||
}"#;
|
||||
let parsed = language::parse(source).unwrap();
|
||||
let formatted = language::format(&parsed);
|
||||
assert!(formatted.contains("supertype @base"));
|
||||
assert!(formatted.contains("transmission Synchronous"));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), parsed);
|
||||
let compiled = compile_bundle(&format_bundle(&parsed), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, parsed);
|
||||
assert!(compiled.decoded.migration.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_ids_are_stabilized_before_allocation_identity_generation() {
|
||||
let bundle=SourceBundle{entry:"project.arcadia".into(),files:BTreeMap::from([("project.arcadia".into(),"arcadia 3; project old_model \"Old\" { sa_exchange_item event_item \"Event\" { kind Event; } sa_interface contract \"Contract\" { items [@event_item]; } }".into())])};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let report = compiled.decoded.migration.as_ref().unwrap();
|
||||
let allocation = &report.generated_interface_allocations[0];
|
||||
assert_eq!(
|
||||
allocation.id,
|
||||
Id::new_v5(
|
||||
&compiled.decoded.project.id,
|
||||
format!(
|
||||
"v2-interface-item:{}:{}",
|
||||
allocation.source, allocation.target
|
||||
)
|
||||
.as_bytes()
|
||||
)
|
||||
);
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.decoded.project, compiled.decoded.project);
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferring_a_legacy_path_keeps_rationale_inside_its_argument() {
|
||||
let source = include_str!("fixtures/v3-data-contract.arcadia")
|
||||
.replace(
|
||||
"type_ref { Named @coordinate; }; }\n }",
|
||||
"type_ref String; }\n }",
|
||||
)
|
||||
.replace(
|
||||
"field @latitude; expression",
|
||||
"field @latitude; // original argument rationale\nexpression",
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let source = &compiled.bundle.files["project.arcadia"];
|
||||
let argument = &source[source.find("values [{").unwrap()..];
|
||||
let argument = &argument[..argument.find('}').unwrap()];
|
||||
assert!(
|
||||
argument.contains("// original argument rationale"),
|
||||
"{source}"
|
||||
);
|
||||
assert!(argument.contains("path [@origin]"), "{source}");
|
||||
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_value_reordering_and_path_reidentification_keep_each_arguments_comments() {
|
||||
let mut project = language::parse(include_str!("fixtures/v3-data-contract.arcadia")).unwrap();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*values = vec![
|
||||
MessageValue {
|
||||
path: vec![id(7)],
|
||||
field: id(6),
|
||||
expression: "origin value".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![id(8)],
|
||||
field: id(6),
|
||||
expression: "destination value".into(),
|
||||
},
|
||||
];
|
||||
let mut bundle = format_bundle(&project);
|
||||
for source in bundle.files.values_mut() {
|
||||
*source = source
|
||||
.replace(
|
||||
"expression \"origin value\";",
|
||||
"expression \"origin value\"; // origin argument rationale",
|
||||
)
|
||||
.replace(
|
||||
"expression \"destination value\";",
|
||||
"expression \"destination value\"; // destination argument rationale",
|
||||
);
|
||||
}
|
||||
let current = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
values.reverse();
|
||||
let renamed = Id::from_u128(100);
|
||||
let mut identities: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
identities.insert(id(7), renamed);
|
||||
reidentify(&mut project, id(7), renamed).unwrap();
|
||||
project.elements.get_mut(&renamed).unwrap().alias = "revised_origin".into();
|
||||
let result =
|
||||
update_bundle_with_identity_map(¤t, &project, &identities, ProjectLimits::default())
|
||||
.unwrap();
|
||||
let source = &result.bundle.files[&result.index.declarations[&id(17)].path];
|
||||
let destination = source.find("// destination argument rationale").unwrap();
|
||||
let origin = source.find("// origin argument rationale").unwrap();
|
||||
assert!(
|
||||
destination < origin,
|
||||
"Distinct uses of the same terminal field must reorder as complete argument annotations: {source}"
|
||||
);
|
||||
assert!(source[origin..].contains("path [@revised_origin]"));
|
||||
assert_eq!(result.decoded.project, project);
|
||||
}
|
||||
@@ -447,11 +447,12 @@ fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_r
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||
let (source, target) = p.relations[&boundary].body.endpoints();
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Caller requires service",
|
||||
@@ -499,11 +500,12 @@ fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_
|
||||
let (mut p, a, b, item, forward) = fixture();
|
||||
let (old, _) = endpoints(&p, forward);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
|
||||
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
@@ -659,11 +661,12 @@ fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity()
|
||||
let (mut p, _, _, item, fe) = fixture();
|
||||
let output = endpoints(&p, fe).0;
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
|
||||
@@ -94,7 +94,9 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
.add_element(Kind::DataType, layer, "Request record", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record,
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
let field = p
|
||||
.add_element(Kind::DataField, layer, "Requested level", Some(record))
|
||||
@@ -160,6 +162,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}) = p.elements.get_mut(&call).unwrap().shared_mut()
|
||||
{
|
||||
values.push(MessageValue {
|
||||
path: vec![payload],
|
||||
field,
|
||||
expression: "requested_level".into(),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(directory.path().join("parts")).unwrap();
|
||||
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
|
||||
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
|
||||
let directory = setup();
|
||||
let entry = directory.path().join("project.arcadia");
|
||||
fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(
|
||||
directory.path().join("parts/a.arcadia"),
|
||||
"oa_activity a \"A\" { invalid",
|
||||
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
|
||||
let overlays = BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
"arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"parts/a.arcadia".into(),
|
||||
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
),
|
||||
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
|
||||
]),
|
||||
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
|
||||
.unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
|
||||
std::os::unix::fs::symlink(&destination, &link).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 3; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = load_project(
|
||||
|
||||
@@ -6,10 +6,10 @@ fn source() -> CompiledSourceProject {
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 3; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
|
||||
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
|
||||
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()),
|
||||
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
|
||||
]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
@@ -91,8 +91,9 @@ fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origi
|
||||
reidentify(&mut candidate, second, first).unwrap();
|
||||
reidentify(&mut candidate, temporary, second).unwrap();
|
||||
let contract = find(&candidate, "contract");
|
||||
let Some(SharedBody::Interface { items }) =
|
||||
candidate.elements.get_mut(&contract).unwrap().shared_mut()
|
||||
let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = candidate.elements.get_mut(&contract).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("interface")
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
|
||||
compile_bundle(&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 3; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap()
|
||||
|
||||
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
|
||||
#[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\"; }",
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -95,7 +95,7 @@ diagram review "Review" {
|
||||
#[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\"; }",
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
|
||||
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}",
|
||||
"arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
&[
|
||||
(
|
||||
"exchanges.arcadia",
|
||||
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
#[test]
|
||||
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
|
||||
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
#[test]
|
||||
fn source_errors_keep_exact_fragment_unicode_positions() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"// Пример\nsa_function child \"航海\" { parent @missing; }",
|
||||
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
),
|
||||
] {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[("a.arcadia", left), ("b.arcadia", right)],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
assert_eq!(error.related[0].path, "a.arcadia");
|
||||
}
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
#[test]
|
||||
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
|
||||
let mut input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
&[
|
||||
("sub/a.arcadia", "include \"../shared.arcadia\";"),
|
||||
("sub/b.arcadia", "include \"../shared.arcadia\";"),
|
||||
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
);
|
||||
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" {}",
|
||||
"arcadia 4; project p \"P\" {}",
|
||||
&[("A.arcadia", ""), ("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
#[test]
|
||||
fn project_admission_is_aggregate_and_precedes_global_resolution() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
|
||||
("b.arcadia", "oa_activity two \"Two\" {}"),
|
||||
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
|
||||
|
||||
#[test]
|
||||
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
|
||||
let source = "arcadia 3; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
assert!(
|
||||
language::parse(source).unwrap_err()[0]
|
||||
.message
|
||||
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
|
||||
#[test]
|
||||
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
|
||||
let input = bundle(
|
||||
"arcadia 3;project p\"P\"{include \"child.arcadia\";}",
|
||||
"arcadia 4;project p\"P\"{include \"child.arcadia\";}",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
|
||||
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
|
||||
#[test]
|
||||
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
&[("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
|
||||
"project_limit"
|
||||
);
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"a.arcadia",
|
||||
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
|
||||
&source[span.start..span.end],
|
||||
"include \"../shared.arcadia\";"
|
||||
);
|
||||
let entry = "arcadia 3; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
assert_eq!(
|
||||
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
|
||||
.target,
|
||||
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
|
||||
let entry = "arcadia 3;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
|
||||
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
|
||||
#[test]
|
||||
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
|
||||
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
|
||||
#[test]
|
||||
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
|
||||
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
|
||||
#[test]
|
||||
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"item.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"item.arcadia\"; }",
|
||||
&[(
|
||||
"item.arcadia",
|
||||
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
|
||||
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
|
||||
#[test]
|
||||
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"event.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"event.arcadia\"; }",
|
||||
&[(
|
||||
"event.arcadia",
|
||||
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
|
||||
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
|
||||
#[test]
|
||||
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
&[(
|
||||
"ports.arcadia",
|
||||
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
|
||||
@@ -1017,10 +1017,10 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
#[test]
|
||||
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }\n",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
@@ -1029,8 +1029,9 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.get_mut(&a).unwrap().alias = "renamed_a".into();
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
if let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b, a];
|
||||
} else {
|
||||
@@ -1051,10 +1052,10 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
#[test]
|
||||
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }\n",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
@@ -1063,8 +1064,9 @@ fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations()
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.remove(&a);
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
if let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b];
|
||||
} else {
|
||||
@@ -1102,7 +1104,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
}; }; }
|
||||
"#;
|
||||
let current = compile(&bundle(
|
||||
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
&[("conversation.arcadia", source)],
|
||||
));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -1151,7 +1153,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
#[test]
|
||||
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
|
||||
@@ -1160,7 +1162,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
|
||||
let empty = compile(&input);
|
||||
let mut project = empty.decoded.project.clone();
|
||||
let values = id(&project, "values");
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
@@ -1185,7 +1187,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
|
||||
.replace("\"Beta\"", "\"Beta\" // beta explanation\n"),
|
||||
);
|
||||
let annotated = compile(&annotated);
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
|
||||
@@ -328,11 +328,12 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
let port = |p: &mut Project, name: &str, parent, direction| {
|
||||
let id = Id::new_v4();
|
||||
let contract = if p.elements[&parent].kind() == Kind::Function {
|
||||
@@ -740,6 +741,9 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
||||
minimum: 0,
|
||||
maximum: None,
|
||||
unit: "items".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
||||
@@ -804,8 +808,40 @@ fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references()
|
||||
"Reidentification must preserve all values and order"
|
||||
);
|
||||
if id != p.id {
|
||||
let deleted = execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
|
||||
let deleted = if p
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
{
|
||||
// An explicitly empty interface cannot silently accept the
|
||||
// still-carried payload. Removing its definition needs a
|
||||
// reviewed edit of that dependent contract in the same batch.
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Delete(id))
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("port_interface_items")
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
let item = p.relations[&id].target();
|
||||
let mut commands = vec![Command::Delete(id)];
|
||||
commands.extend(
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::BoundaryExchange
|
||||
&& r.body.items().contains(&item)
|
||||
})
|
||||
.map(|r| Command::Delete(r.id)),
|
||||
);
|
||||
execute_batch(&p, &commands).unwrap()
|
||||
} else {
|
||||
execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"))
|
||||
};
|
||||
accepted(&deleted);
|
||||
}
|
||||
}
|
||||
@@ -890,13 +926,15 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() {
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material
|
||||
kind: ExchangeItemKind::Material,
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy
|
||||
kind: ExchangeItemKind::Energy,
|
||||
..
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -642,7 +642,7 @@ impl App {
|
||||
if !self.project().contains(id) {
|
||||
return;
|
||||
}
|
||||
let previous_owner = crate::behavior::selected_owner(self);
|
||||
let previous_state_view = self.state_view_identity();
|
||||
self.selected = id;
|
||||
self.selected_node = None;
|
||||
if let Some(layer) = self.project().layer_of(id)
|
||||
@@ -656,10 +656,11 @@ impl App {
|
||||
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
|
||||
{
|
||||
self.behavior.active_owner = Some(owner);
|
||||
if previous_owner != Some(owner) {
|
||||
self.behavior.diagram = Default::default();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
if self.reset_state_view_after_navigation(previous_state_view)
|
||||
&& self.page == Page::Behavior
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
@@ -1200,13 +1201,14 @@ impl App {
|
||||
Message::Page(page) => {
|
||||
if page != Page::Text || !self.draft_dirty() || self.apply() {
|
||||
if page == Page::Behavior && self.page != Page::Behavior {
|
||||
let previous_state_view = self.state_view_identity();
|
||||
if let Some(owner) =
|
||||
crate::behavior::selected_behavior(self.project(), self.selected)
|
||||
{
|
||||
self.behavior.active_owner = Some(owner);
|
||||
}
|
||||
self.behavior.active_owner = crate::behavior::selected_owner(self);
|
||||
self.behavior.diagram.fit = true;
|
||||
self.reset_state_view_after_navigation(previous_state_view);
|
||||
}
|
||||
self.page = page;
|
||||
self.selected_node = if page == Page::Behavior {
|
||||
@@ -1360,6 +1362,7 @@ impl App {
|
||||
}
|
||||
Message::Layer(layer) => {
|
||||
if self.apply() {
|
||||
let previous_state_view = self.state_view_identity();
|
||||
self.layer = layer;
|
||||
self.create_kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
@@ -1375,6 +1378,8 @@ impl App {
|
||||
.map(|d| d.id);
|
||||
self.selected = self.project().id;
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = crate::behavior::selected_owner(self);
|
||||
self.reset_state_view_after_navigation(previous_state_view);
|
||||
self.refresh_draft();
|
||||
self.fit();
|
||||
}
|
||||
|
||||
@@ -6,12 +6,35 @@ use crate::behavior::{
|
||||
};
|
||||
|
||||
impl App {
|
||||
/// The behavior view resolved independently of the current page. Capture
|
||||
/// this before selection/layer changes, while it still identifies the view
|
||||
/// whose camera and tool are retained during navigation.
|
||||
pub(super) fn state_view_identity(&self) -> (Option<Id>, Option<Id>) {
|
||||
let owner = behavior::selected_owner(self);
|
||||
let diagram = owner.and_then(|owner| behavior::state_view::diagram(self, owner));
|
||||
(owner, diagram)
|
||||
}
|
||||
|
||||
pub(super) fn reset_state_view_after_navigation(
|
||||
&mut self,
|
||||
previous: (Option<Id>, Option<Id>),
|
||||
) -> bool {
|
||||
let current = self.state_view_identity();
|
||||
if current == previous {
|
||||
return false;
|
||||
}
|
||||
self.behavior.diagram = behavior::state_view::State {
|
||||
diagram: current.1,
|
||||
..Default::default()
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
pub fn state_context(&self) -> Option<Context> {
|
||||
if self.page != Page::Behavior {
|
||||
return None;
|
||||
}
|
||||
let owner = behavior::selected_owner(self)?;
|
||||
let diagram = behavior::state_view::diagram(self, owner)?;
|
||||
let diagram = self.state_view_identity().1?;
|
||||
Some(Context {
|
||||
document: self.canvas_document,
|
||||
diagram,
|
||||
|
||||
@@ -89,6 +89,232 @@ fn assert_source_model_agree(app: &App) {
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
|
||||
struct NavigationFixture {
|
||||
app: App,
|
||||
machine_a: Id,
|
||||
diagram_a: Id,
|
||||
state_a: Id,
|
||||
transition_a: Id,
|
||||
root_event: Id,
|
||||
machine_b: Id,
|
||||
diagram_b: Id,
|
||||
state_b: Id,
|
||||
}
|
||||
|
||||
fn navigation_fixture(layer_b: Layer) -> NavigationFixture {
|
||||
let mut project = Project::empty();
|
||||
let mut machine = |layer, name| {
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, layer, name, None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, layer, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, layer, "Ready", Some(region))
|
||||
.unwrap();
|
||||
(machine, state)
|
||||
};
|
||||
let (machine_a, state_a) = machine(Layer::Sa, "Machine A");
|
||||
let (machine_b, state_b) = machine(layer_b, "Machine B");
|
||||
let root_event = project
|
||||
.add_element(Kind::Event, Layer::Sa, "External event", None)
|
||||
.unwrap();
|
||||
let transition_a = project.relate(RelationKind::StateTransition, "Repeat", state_a, state_a);
|
||||
let plan_a = state_edit::create_view(&project, machine_a).unwrap();
|
||||
project = prepare_command(&project, &plan_a.command).unwrap().project;
|
||||
let plan_b = state_edit::create_view(&project, machine_b).unwrap();
|
||||
project = prepare_command(&project, &plan_b.command).unwrap().project;
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(state_a));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
send(
|
||||
&mut app,
|
||||
Message::BehaviorDiagram(Choice {
|
||||
id: plan_a.diagram,
|
||||
label: "A view".into(),
|
||||
}),
|
||||
);
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
event(&mut app, Event::Zoom(context, 0.65));
|
||||
event(
|
||||
&mut app,
|
||||
Event::Pan(context, iced::Vector::new(-1_400., -800.)),
|
||||
);
|
||||
event(
|
||||
&mut app,
|
||||
Event::Choose(Tool::Reconnect {
|
||||
relation: transition_a,
|
||||
endpoint: Endpoint::Target,
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.state_context().unwrap().diagram, plan_a.diagram);
|
||||
assert!(!app.behavior.diagram.fit);
|
||||
NavigationFixture {
|
||||
app,
|
||||
machine_a,
|
||||
diagram_a: plan_a.diagram,
|
||||
state_a,
|
||||
transition_a,
|
||||
root_event,
|
||||
machine_b,
|
||||
diagram_b: plan_b.diagram,
|
||||
state_b,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_switched_state_view(f: &mut NavigationFixture) {
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_b));
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_b);
|
||||
let defaults = behavior::state_view::State::default();
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(state.diagram, Some(f.diagram_b));
|
||||
assert_eq!(
|
||||
state.tool,
|
||||
Tool::Select,
|
||||
"Reconnect from A must not be armed in B"
|
||||
);
|
||||
assert_eq!(state.pan, defaults.pan);
|
||||
assert_eq!(state.zoom, defaults.zoom);
|
||||
assert!(state.fit);
|
||||
assert!(
|
||||
state.viewport.is_none(),
|
||||
"B needs its own first viewport measurement"
|
||||
);
|
||||
assert!(state.placement.is_none());
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
assert!(
|
||||
f.app.behavior.diagram.pan != defaults.pan || f.app.behavior.diagram.zoom != defaults.zoom,
|
||||
"The unchanged widget size still fits a newly selected view"
|
||||
);
|
||||
}
|
||||
|
||||
fn catalog_selects_another_machine(layer_b: Layer) {
|
||||
let mut f = navigation_fixture(layer_b);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Select(f.state_b));
|
||||
assert_eq!(f.app.layer, layer_b);
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
assert_switched_state_view(&mut f);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_source_model_agree(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_catalog_selection_of_another_machine_resets_camera_and_tool() {
|
||||
catalog_selects_another_machine(Layer::Sa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_catalog_selection_across_layers_resets_camera_and_tool() {
|
||||
catalog_selects_another_machine(Layer::Oa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_layer_change_resets_the_new_machine_at_the_same_viewport_size() {
|
||||
let mut f = navigation_fixture(Layer::Oa);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Layer(Layer::Oa));
|
||||
assert_switched_state_view(&mut f);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_source_model_agree(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_return_to_the_same_view_preserves_camera_tool_and_fit_mode() {
|
||||
for fit in [false, true] {
|
||||
let mut f = navigation_fixture(Layer::Sa);
|
||||
if fit {
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(&mut f.app, Event::Fit(context));
|
||||
}
|
||||
let state = &f.app.behavior.diagram;
|
||||
let retained = (
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Select(f.state_a));
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
|
||||
assert_eq!(context.diagram, f.diagram_a);
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(
|
||||
(
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport
|
||||
),
|
||||
retained
|
||||
);
|
||||
assert_eq!(
|
||||
state.tool,
|
||||
Tool::Reconnect {
|
||||
relation: f.transition_a,
|
||||
endpoint: Endpoint::Target
|
||||
}
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_root_event_and_same_layer_keep_the_current_camera_and_tool() {
|
||||
let mut f = navigation_fixture(Layer::Sa);
|
||||
let state = &f.app.behavior.diagram;
|
||||
let retained = (
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Select(f.root_event));
|
||||
assert_eq!(f.app.selected, f.root_event);
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
send(&mut f.app, Message::Layer(Layer::Sa));
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_a);
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(
|
||||
(
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport
|
||||
),
|
||||
retained
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_view_places_only_its_machine_and_restores_exact_source_in_one_undo() {
|
||||
let mut f = fixture();
|
||||
|
||||
@@ -231,7 +231,7 @@ fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
|
||||
}
|
||||
|
||||
fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
|
||||
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 migration.
|
||||
// Format is chosen by HTTP. The shared decoder owns supported older-schema migration.
|
||||
let _: Value = json_body(body)?;
|
||||
oa_core::persistence::decode_with_report_limited(body, MODEL_LIMITS).map_err(
|
||||
|error| match error {
|
||||
@@ -525,7 +525,7 @@ async fn put_source_project(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"revision": stored.revision, "changed": changed, "issues": diagnostics,
|
||||
"source_project": stored.source_project
|
||||
"source_project": stored.source_project, "migration": stored.migration
|
||||
}),
|
||||
))
|
||||
})
|
||||
@@ -947,14 +947,16 @@ async fn validate_candidate(
|
||||
// Advisory locations refer to submitted bytes, before Apply
|
||||
// would insert missing persistent identities.
|
||||
let diagnostics: Vec<_> = source_project::lint_bundle(&source_project, SOURCE_LIMITS).into_iter().map(source_issue).collect();
|
||||
if !diagnostics.iter().any(|issue| issue.severity == "error") {
|
||||
PreparedProject::from_bundle(source_project)?;
|
||||
}
|
||||
Ok(diagnostics)
|
||||
let migration = if !diagnostics.iter().any(|issue| issue.severity == "error") {
|
||||
PreparedProject::from_bundle(source_project)?.migration().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((diagnostics, migration))
|
||||
});
|
||||
let diagnostics = result.unwrap_or_else(candidate_issues);
|
||||
let (diagnostics, migration) = result.unwrap_or_else(|error| (candidate_issues(error), None));
|
||||
let valid = !diagnostics.iter().any(|issue| issue.severity == "error");
|
||||
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":null})));
|
||||
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":migration})));
|
||||
}
|
||||
_ => return Err(ApiError::bad_request("Validation requires one matching format/payload: json with model, arcadia with source, or source-project with source_project.")),
|
||||
};
|
||||
|
||||
@@ -128,9 +128,10 @@ impl PreparedProject {
|
||||
fn from_compiled(compiled: CompiledSourceProject) -> ApiResult<Self> {
|
||||
check_model_size(&compiled.decoded.project)?;
|
||||
check_source_size(&compiled.bundle)?;
|
||||
let migration = compiled.decoded.migration.clone();
|
||||
Ok(Self {
|
||||
compiled,
|
||||
migration: None,
|
||||
migration,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -173,6 +174,10 @@ impl PreparedProject {
|
||||
&self.compiled.bundle
|
||||
}
|
||||
|
||||
pub(crate) fn migration(&self) -> Option<&MigrationReport> {
|
||||
self.migration.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn source_issues(&self) -> Vec<crate::error::IssueDto> {
|
||||
self.compiled
|
||||
.diagnostics
|
||||
@@ -329,7 +334,12 @@ impl Store {
|
||||
let prepared = if let Some(bundle) = envelope.source_project {
|
||||
let prepared = PreparedProject::from_bundle(bundle.clone())
|
||||
.map_err(|error| corrupt(error.detail))?;
|
||||
if prepared.bundle() != &bundle || prepared.model() != &decoded.project {
|
||||
// Explicit source upgrades may change tokens and generate new
|
||||
// records. They must still agree with independently migrated
|
||||
// JSON. Current-format unstabilized/inconsistent pairs reject.
|
||||
if (prepared.bundle() != &bundle && prepared.migration.is_none())
|
||||
|| prepared.model() != &decoded.project
|
||||
{
|
||||
return Err(corrupt(
|
||||
"Stored source and model disagree, or source lacks stable identities."
|
||||
.into(),
|
||||
@@ -347,7 +357,7 @@ impl Store {
|
||||
revision: envelope.revision,
|
||||
model: decoded.project,
|
||||
source_project: prepared.compiled.bundle,
|
||||
migration: decoded.migration,
|
||||
migration: decoded.migration.or(prepared.migration),
|
||||
};
|
||||
if stored.resource_id != filename_id || stored.revision == 0 {
|
||||
return Err(corrupt(
|
||||
|
||||
@@ -8,6 +8,8 @@ use serde_json::{Value, json};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use tokio::{sync::oneshot, task::JoinHandle};
|
||||
|
||||
#[path = "http/data_contracts.rs"]
|
||||
mod data_contracts;
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/source_projects.rs"]
|
||||
@@ -1929,7 +1931,7 @@ async fn legacy_json_import_and_model_reidentification_keep_the_service_address(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
|
||||
async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let old = v2_missing_ports();
|
||||
@@ -1945,7 +1947,7 @@ async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
assert_eq!(imported.status(), StatusCode::CREATED);
|
||||
let etag = imported.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let summary: Value = imported.json().await.unwrap();
|
||||
assert_eq!(summary["schema_version"], 3);
|
||||
assert_eq!(summary["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
summary["migration"],
|
||||
serde_json::to_value(&expected.migration).unwrap()
|
||||
@@ -1958,7 +1960,7 @@ async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
let before = std::fs::read(&filename).unwrap();
|
||||
let envelope: Value = serde_json::from_slice(&before).unwrap();
|
||||
assert!(envelope.get("migration").is_none());
|
||||
assert_eq!(envelope["model"]["schema_version"], 3);
|
||||
assert_eq!(envelope["model"]["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
let validation = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{id}/validation")))
|
||||
@@ -2000,7 +2002,7 @@ async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 3;"));
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
|
||||
let no_op = server
|
||||
.client
|
||||
@@ -2049,7 +2051,7 @@ async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_rev
|
||||
let migrated = server.get(id).await;
|
||||
assert_eq!(migrated.resource_id, id);
|
||||
assert_eq!(migrated.revision, 7);
|
||||
assert_eq!(migrated.model.schema_version, 3);
|
||||
assert_eq!(migrated.model.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.migration
|
||||
@@ -2074,7 +2076,7 @@ async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_rev
|
||||
assert_eq!(current.model.name, "Reviewed migrated model");
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap();
|
||||
assert_eq!(saved["revision"], 8);
|
||||
assert_eq!(saved["model"]["schema_version"], 3);
|
||||
assert_eq!(saved["model"]["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
assert!(saved.get("migration").is_none());
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Transport evidence for the published R6 contract, not a second model fixture.
|
||||
use super::*;
|
||||
use oa_core::source_project::{ProjectLimits, SourceBundle, compile_bundle};
|
||||
use oa_core::{DataTypeDefinition, DataValue, SharedBody, TransmissionProtocol};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn documented_data() -> Project {
|
||||
let contract = include_str!("../../../../docs/language.rst");
|
||||
let example = contract
|
||||
.split("This complete data contract example is also exercised through the REST API:")
|
||||
.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 = oa_core::language::parse(&source).unwrap();
|
||||
oa_core::validate_candidate(&model).unwrap();
|
||||
model
|
||||
}
|
||||
fn id(model: &Project, alias: &str) -> Id {
|
||||
model
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn assert_pair(stored: &StoredProject) {
|
||||
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, stored.model);
|
||||
assert_eq!(compiled.bundle, stored.source_project);
|
||||
}
|
||||
async fn commands(
|
||||
server: &TestServer,
|
||||
current: &StoredProject,
|
||||
commands: Vec<Command>,
|
||||
) -> reqwest::Response {
|
||||
server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&json!({"commands":commands}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn published_data_contract_queries_atomic_edits_identity_and_restart() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let model = documented_data();
|
||||
let (resource, _) = server.import(&model).await;
|
||||
let before = server.get(resource).await;
|
||||
assert_eq!(before.model, model);
|
||||
assert_pair(&before);
|
||||
for (kind, count) in [("InterfaceItemAllocation", 2), ("DataAssociation", 1)] {
|
||||
let result: Value = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/relations?kind={kind}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["items"].as_array().unwrap().len(), count);
|
||||
}
|
||||
let voltage = id(&model, "voltage");
|
||||
let mut invalid = model.elements[&voltage].clone();
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real { maximum, .. },
|
||||
..
|
||||
}) = invalid.shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*maximum = Some(12.0); // Existing default24V is outside this bound.
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let disk_before = std::fs::read(&filename).unwrap();
|
||||
assert_eq!(
|
||||
commands(
|
||||
&server,
|
||||
&before,
|
||||
vec![Command::EditElement(voltage, invalid.clone())]
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(server.get(resource).await.etag(), before.etag());
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), disk_before);
|
||||
|
||||
let Some(SharedBody::DataType { default, .. }) = invalid.shared_mut() else {
|
||||
panic!()
|
||||
};
|
||||
*default = Some(DataValue::Real(12.0));
|
||||
let nav = id(&model, "nav_operation");
|
||||
let other = id(&model, "monitor_operation");
|
||||
let mut allocation = model.relations[&nav].clone();
|
||||
let oa_core::RelationBody::InterfaceItemAllocation { transmission, .. } = &mut allocation.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*transmission = TransmissionProtocol::Unicast;
|
||||
let artifact = oa_core::ArtifactLink {
|
||||
id: Id::new_v4(),
|
||||
alias: "navigation_evidence".into(),
|
||||
name: "Navigation CAD evidence".into(),
|
||||
description: "Independent allocation evidence".into(),
|
||||
owner: nav,
|
||||
relation: oa_core::ArtifactRelation::Verifies,
|
||||
uri: "cad://navigation/connector".into(),
|
||||
revision: Some("review-17".into()),
|
||||
fragment: Some("pin-3".into()),
|
||||
};
|
||||
let response = commands(
|
||||
&server,
|
||||
&before,
|
||||
vec![
|
||||
Command::EditElement(voltage, invalid),
|
||||
Command::EditRelation(nav, allocation.clone()),
|
||||
Command::CreateArtifact(artifact.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let changed = server.get(resource).await;
|
||||
assert_eq!(changed.revision, before.revision + 1);
|
||||
assert_eq!(changed.model.relations[&other], model.relations[&other]);
|
||||
assert_eq!(changed.model.relations[&nav], allocation);
|
||||
assert_eq!(changed.model.artifact_links[&artifact.id], artifact);
|
||||
assert_pair(&changed);
|
||||
assert_eq!(
|
||||
commands(&server, &before, vec![Command::Rename(nav, "Stale".into())])
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
|
||||
let renamed = Id::new_v4();
|
||||
assert_eq!(
|
||||
commands(&server, &changed, vec![Command::Reidentify(nav, renamed)])
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let reidentified = server.get(resource).await;
|
||||
assert_eq!(
|
||||
reidentified.model.artifact_links[&artifact.id].owner,
|
||||
renamed
|
||||
);
|
||||
// This allocation is not enforcing a live boundary, so removal is valid.
|
||||
assert_eq!(
|
||||
commands(&server, &reidentified, vec![Command::Delete(renamed)])
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let removed = server.get(resource).await;
|
||||
assert!(removed.model.elements.contains_key(&id(&model, "navigate")));
|
||||
assert_eq!(removed.model.relations[&other], model.relations[&other]);
|
||||
assert!(!removed.model.artifact_links.contains_key(&artifact.id));
|
||||
assert_pair(&removed);
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.model, removed.model);
|
||||
assert_eq!(reopened.etag(), removed.etag());
|
||||
assert_pair(&reopened);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_files() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let old = SourceBundle {
|
||||
entry: "legacy.arcadia".into(), files: BTreeMap::from([
|
||||
("legacy.arcadia".into(), "// Entry rationale\narcadia 3; project old_data \"Old data\" { include \"dictionary.arcadia\"; }\n".into()),
|
||||
("dictionary.arcadia".into(), "// Event rationale\nsa_exchange_item notification \"Notification\" { kind Event; }\n// Interface rationale\nsa_interface contract \"Contract\" { items [@notification]; }\n".into())
|
||||
])
|
||||
};
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url("/projects/import-source-project"))
|
||||
.json(&old)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let summary: Value = response.json().await.unwrap();
|
||||
let resource = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let stored = server.get(resource).await;
|
||||
let report = stored.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 4));
|
||||
assert_eq!(report.generated_interface_allocations.len(), 1);
|
||||
assert!(!report.semantic_defaults.is_empty());
|
||||
let validated: Value = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{resource}/validation")))
|
||||
.json(&json!({"format":"source-project","source_project":old}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validated["valid"], true);
|
||||
assert_eq!(validated["migration"]["from_schema"], 3);
|
||||
assert_eq!(
|
||||
validated["migration"]["generated_interface_allocations"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(stored.source_project.files.len(), 2);
|
||||
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 4;"));
|
||||
for text in [
|
||||
"// Event rationale",
|
||||
"// Interface rationale",
|
||||
"interface_item_allocation",
|
||||
] {
|
||||
assert!(stored.source_project.files["dictionary.arcadia"].contains(text));
|
||||
}
|
||||
assert_pair(&stored);
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let bytes = std::fs::read(&filename).unwrap();
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.model, stored.model);
|
||||
assert_eq!(reopened.source_project, stored.source_project);
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(std::fs::read(filename).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let resource = Id::new_v4();
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let old_model: Value = serde_json::from_str(include_str!(
|
||||
"../../../oa-core/tests/fixtures/v3-data-contract.json"
|
||||
))
|
||||
.unwrap();
|
||||
let old_bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([(
|
||||
"project.arcadia".into(),
|
||||
include_str!("../../../oa-core/tests/fixtures/v3-data-contract.arcadia").into(),
|
||||
)]),
|
||||
};
|
||||
let original = serde_json::to_vec(&json!({"resource_id":resource,"revision":17,"model":old_model,"source_project":old_bundle})).unwrap();
|
||||
std::fs::write(&filename, &original).unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let opened = server.get(resource).await;
|
||||
assert_eq!(opened.revision, 17);
|
||||
assert_eq!(opened.model.schema_version, 4);
|
||||
assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 4;"));
|
||||
assert_pair(&opened);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), original);
|
||||
assert_eq!(
|
||||
commands(
|
||||
&server,
|
||||
&opened,
|
||||
vec![Command::Rename(
|
||||
opened.model.id,
|
||||
"Reviewed data migration".into()
|
||||
)]
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let saved = server.get(resource).await;
|
||||
assert_eq!(saved.revision, 18);
|
||||
let bytes = std::fs::read(&filename).unwrap();
|
||||
assert_ne!(bytes, original);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.model, saved.model);
|
||||
assert_eq!(reopened.source_project, saved.source_project);
|
||||
assert_eq!(reopened.etag(), saved.etag());
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
@@ -6,10 +6,10 @@ fn authored() -> SourceBundle {
|
||||
let bundle = SourceBundle {
|
||||
entry: "harbor.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 3; project harbor \"Harbor rescue\" {\r\n include \"team/first.arcadia\";\r\n include \"team/second.arcadia\";\r\n include \"contracts.arcadia\";\r\n include \"work.arcadia\";\r\n}\r\n".into()),
|
||||
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 4; project harbor \"Harbor rescue\" {\r\n include \"team/first.arcadia\";\r\n include \"team/second.arcadia\";\r\n include \"contracts.arcadia\";\r\n include \"work.arcadia\";\r\n}\r\n".into()),
|
||||
("team/first.arcadia".into(), "// First payload rationale\r\nsa_exchange_item first \"First payload\" { description \"Reviewed first\"; }\r\n".into()),
|
||||
("team/second.arcadia".into(), "// Second payload rationale\r\nsa_exchange_item second \"Second payload\" {}\r\n".into()),
|
||||
("contracts.arcadia".into(), "// Contract ownership stays here\r\nsa_interface contract \"Contract\" { items [\r\n @first // first reference rationale\r\n ,\r\n @second // second reference rationale\r\n ]; }\r\n".into()),
|
||||
("contracts.arcadia".into(), "// Contract ownership stays here\r\nsa_interface contract \"Contract\" {}\r\ninterface_item_allocation first_allocation \"First allocation\" { source @contract; target @first // first reference rationale\r\n; order 0; }\r\ninterface_item_allocation second_allocation \"Second allocation\" { source @contract; target @second // second reference rationale\r\n; order 1; }\r\n".into()),
|
||||
("work.arcadia".into(), "// Operational rationale\r\noa_activity assess \"Assess assistance\" {}\r\n".into()),
|
||||
]),
|
||||
};
|
||||
|
||||
+32
-8
@@ -784,8 +784,8 @@ candidate validation reports the same diagnostic in its 200 response.
|
||||
Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.</p>
|
||||
<p>Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2 native JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 3 / arcadia 3. Native JSON
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
<span class="docutils literal"><span class="pre">?revision=...</span></span> to model/source export is an error, not historical retrieval.</p>
|
||||
@@ -796,7 +796,7 @@ Only documented query parameters are accepted. In particular, appending
|
||||
<pre class="literal-block">{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 3; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}</pre>
|
||||
@@ -807,8 +807,9 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Bundle syntax is source3;
|
||||
single-file legacy migration remains available through interchange import.</p>
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.</p>
|
||||
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
|
||||
Location/ETag; GET source-project retrieves the stabilized accepted bundle.
|
||||
PUT returns <span class="docutils literal">revision</span>, <span class="docutils literal">changed</span>, located <span class="docutils literal">issues</span> and that accepted
|
||||
@@ -965,8 +966,11 @@ does not execute conditions or simulate a process.</p>
|
||||
store <span class="docutils literal">message_kind</span>, optional correlated-call <span class="docutils literal">reply_to</span> and contextual
|
||||
<span class="docutils literal">values</span>. Message kinds are Asynchronous, Call, Reply, Create, Delete, or
|
||||
<span class="docutils literal"><span class="pre">{"Timer":{"expression":"after</span> 5 <span class="pre">s"}}</span></span>. Each value record contains a
|
||||
carried DataField UUID in <span class="docutils literal">field</span> and its textual <span class="docutils literal">expression</span>; expressions
|
||||
do not change the shared exchange item's type or evaluate server-side.</p>
|
||||
carried DataField UUID in <span class="docutils literal">field</span>, enclosing field UUID list <span class="docutils literal">path</span> (default
|
||||
[]) and textual <span class="docutils literal">expression</span>. Identity is (path, field), allowing repeated uses
|
||||
of a Record. Operation Call/Reply direction checks use the direct parameter;
|
||||
Reference storage stops nested payload traversal. Expressions do not change the
|
||||
shared exchange item's type or evaluate server-side.</p>
|
||||
<p><span class="docutils literal">ScenarioUse</span> contains a referenced <span class="docutils literal">scenario</span> and one-to-one lifeline
|
||||
<span class="docutils literal">bindings</span> records with <span class="docutils literal">referenced</span> and <span class="docutils literal">local</span> UUIDs. The core validates
|
||||
viewpoint/layer compatibility, call/reply order and reversal, lifetime order,
|
||||
@@ -978,6 +982,26 @@ exports make those compatibility defaults explicit. This preserves original V2
|
||||
models while the release schema is being integrated; it does not silently
|
||||
reinterpret old mixed lifelines as a strict new viewpoint.</p>
|
||||
</section>
|
||||
<section id="data-and-interface-contracts">
|
||||
<h2>Data and interface contracts</h2>
|
||||
<p>Schema4 uses typed bounded DataTypeDefinition and DataValue constructors, field
|
||||
storage/defaults/directions, and single specialization. Exact JSON shapes are in
|
||||
<span class="docutils literal">openapi.json</span> and semantic rules and runnable source in <span class="docutils literal">language.rst</span> section 4.5.
|
||||
Interface membership is an identified InterfaceItemAllocation relation with
|
||||
independent order/transmission/acquisition. DataAssociation relates Record types
|
||||
with named, navigable, multiplicity-bearing ends; it does not add carried fields.</p>
|
||||
<p>Use ordinary relation queries, transactions and artifact links, for example
|
||||
<span class="docutils literal">GET <span class="pre">/api/v1/projects/{id}/relations?kind=InterfaceItemAllocation</span></span>. The service
|
||||
and desktop use the same inheritance/path/default validation. Invalid bounds,
|
||||
defaults, directions, cycles or contract-breaking deletions reject atomically,
|
||||
preserving the accepted source/model, revision and stored bytes. Correct a related
|
||||
contract in the same transaction when removing a used interface allocation.</p>
|
||||
<p>Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.</p>
|
||||
</section>
|
||||
<section id="validation">
|
||||
<h2>Validation</h2>
|
||||
<p>GET returns current-model diagnostics. POST accepts either:</p>
|
||||
@@ -985,7 +1009,7 @@ reinterpret old mixed lifelines as a strict new viewpoint.</p>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"arcadia", "source":"complete source document"}</pre>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}</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, including a model record that fails typed decoding.
|
||||
Malformed request JSON or an invalid format/payload envelope is a 400.
|
||||
|
||||
+35
-8
@@ -146,8 +146,8 @@ Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.
|
||||
|
||||
Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2 native JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 3 / arcadia 3. Native JSON
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
``?revision=...`` to model/source export is an error, not historical retrieval.
|
||||
@@ -160,7 +160,7 @@ A source bundle contains ``entry`` and a map of ``files``. For example::
|
||||
{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 3; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}
|
||||
@@ -172,8 +172,9 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Bundle syntax is source3;
|
||||
single-file legacy migration remains available through interchange import.
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.
|
||||
|
||||
Compilation inserts UUIDs into headers that omit them. Import returns 201 with
|
||||
Location/ETag; GET source-project retrieves the stabilized accepted bundle.
|
||||
@@ -355,8 +356,11 @@ Scenario bodies store an explicit ``viewpoint``. Occurrence Exchange bodies
|
||||
store ``message_kind``, optional correlated-call ``reply_to`` and contextual
|
||||
``values``. Message kinds are Asynchronous, Call, Reply, Create, Delete, or
|
||||
``{"Timer":{"expression":"after 5 s"}}``. Each value record contains a
|
||||
carried DataField UUID in ``field`` and its textual ``expression``; expressions
|
||||
do not change the shared exchange item's type or evaluate server-side.
|
||||
carried DataField UUID in ``field``, enclosing field UUID list ``path`` (default
|
||||
[]) and textual ``expression``. Identity is (path, field), allowing repeated uses
|
||||
of a Record. Operation Call/Reply direction checks use the direct parameter;
|
||||
Reference storage stops nested payload traversal. Expressions do not change the
|
||||
shared exchange item's type or evaluate server-side.
|
||||
|
||||
``ScenarioUse`` contains a referenced ``scenario`` and one-to-one lifeline
|
||||
``bindings`` records with ``referenced`` and ``local`` UUIDs. The core validates
|
||||
@@ -370,6 +374,29 @@ exports make those compatibility defaults explicit. This preserves original V2
|
||||
models while the release schema is being integrated; it does not silently
|
||||
reinterpret old mixed lifelines as a strict new viewpoint.
|
||||
|
||||
Data and interface contracts
|
||||
----------------------------
|
||||
|
||||
Schema4 uses typed bounded DataTypeDefinition and DataValue constructors, field
|
||||
storage/defaults/directions, and single specialization. Exact JSON shapes are in
|
||||
``openapi.json`` and semantic rules and runnable source in ``language.rst`` section 4.5.
|
||||
Interface membership is an identified InterfaceItemAllocation relation with
|
||||
independent order/transmission/acquisition. DataAssociation relates Record types
|
||||
with named, navigable, multiplicity-bearing ends; it does not add carried fields.
|
||||
|
||||
Use ordinary relation queries, transactions and artifact links, for example
|
||||
``GET /api/v1/projects/{id}/relations?kind=InterfaceItemAllocation``. The service
|
||||
and desktop use the same inheritance/path/default validation. Invalid bounds,
|
||||
defaults, directions, cycles or contract-breaking deletions reject atomically,
|
||||
preserving the accepted source/model, revision and stored bytes. Correct a related
|
||||
contract in the same transaction when removing a used interface allocation.
|
||||
|
||||
Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.
|
||||
|
||||
Validation
|
||||
----------
|
||||
|
||||
@@ -383,7 +410,7 @@ or::
|
||||
|
||||
or::
|
||||
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}
|
||||
|
||||
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
|
||||
+215
-42
@@ -623,7 +623,7 @@ a { text-decoration: none; }
|
||||
<h1 class="title">Arcadia Studio OA / SA Language and Editing Contract</h1>
|
||||
<dl class="docinfo simple">
|
||||
<dt class="version">Version<span class="colon">:</span></dt>
|
||||
<dd class="version">3</dd>
|
||||
<dd class="version">4</dd>
|
||||
<dt class="status">Status<span class="colon">:</span></dt>
|
||||
<dd class="status">Normative current implementation contract; first-release integration</dd>
|
||||
<dt class="core-implementation">Core implementation<span class="colon">:</span></dt>
|
||||
@@ -637,14 +637,13 @@ a { text-decoration: none; }
|
||||
<h2>1. Scope and conformance</h2>
|
||||
<p>This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.</p>
|
||||
<p>Schema 3 introduces typed function and component ports, required functional
|
||||
exchange endpoints, independently oriented boundary exchanges, and explicit
|
||||
PortAllocation relations. First-release scenario/state additions remain in
|
||||
scope; this schema change does not declare native acceptance or the wider
|
||||
release complete. The preserved schema-2 reader validates the original
|
||||
contract before migration.</p>
|
||||
<p>Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
This contract does not by itself establish native acceptance or release completion.</p>
|
||||
<p>OA describes stakeholders, operational work and needs. SA describes the
|
||||
selected system boundary, external actors, functions, services and expected
|
||||
behavior. SA permits at most one System of Interest; its absence is an
|
||||
@@ -662,7 +661,7 @@ Actual verification evidence is separate from this contract.</p>
|
||||
</section>
|
||||
<section id="one-authoritative-model">
|
||||
<h2>2. One authoritative model</h2>
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (3), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (4), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<span class="docutils literal">description</span>, and maps named <span class="docutils literal">elements</span>, <span class="docutils literal">relations</span>, <span class="docutils literal">diagrams</span>,
|
||||
<span class="docutils literal">artifact_links</span>, and <span class="docutils literal">traces</span>. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.</p>
|
||||
@@ -722,20 +721,20 @@ lists all additional fields. <span class="docutils literal">ref</span> means <sp
|
||||
<td><p><span class="docutils literal">[]</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_exchange_item</span>, <span class="docutils literal">sa_exchange_item</span></p></td>
|
||||
<td><p><span class="docutils literal">kind</span>: Information, Material, Energy, Event</p></td>
|
||||
<td><p>Information</p></td>
|
||||
<td><p><span class="docutils literal">kind</span>: Information, Material, Energy; <span class="docutils literal">mechanism</span>: ExchangeMechanism; <span class="docutils literal">supertype</span>: item ref?</p></td>
|
||||
<td><p>Information; Unspecified; none</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_data_type</span>, <span class="docutils literal">sa_data_type</span></p></td>
|
||||
<td><p><span class="docutils literal">definition</span>: Boolean, Integer, Real, String, Record, or Enumeration constructor</p></td>
|
||||
<td><p>String</p></td>
|
||||
<td><p><span class="docutils literal">definition</span>: DataTypeDefinition; <span class="docutils literal">default</span>: DataValue?; <span class="docutils literal">unit</span>: string</p></td>
|
||||
<td><p>String with minimum_length 0 / maximum_length none; none; <span class="docutils literal">""</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_data_field</span>, <span class="docutils literal">sa_data_field</span></p></td>
|
||||
<td><p><span class="docutils literal">type_ref</span>; <span class="docutils literal">minimum</span>: u32; <span class="docutils literal">maximum</span>: optional u32; <span class="docutils literal">unit</span>: string</p></td>
|
||||
<td><p>String; 1; 1; <span class="docutils literal">""</span></p></td>
|
||||
<td><p><span class="docutils literal">type_ref</span>; <span class="docutils literal">minimum</span>: u32; <span class="docutils literal">maximum</span>: optional u32; <span class="docutils literal">unit</span>: string; <span class="docutils literal">direction</span>: ParameterDirection; <span class="docutils literal">storage</span>: FieldStorage; <span class="docutils literal">default</span>: DataValue list?</p></td>
|
||||
<td><p>String; 1; 1; <span class="docutils literal">""</span>; Unspecified; Value; none</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_interface</span>, <span class="docutils literal">sa_interface</span></p></td>
|
||||
<td><p><span class="docutils literal">items</span>: exchange-item refs</p></td>
|
||||
<td><p><span class="docutils literal">[]</span></p></td>
|
||||
<td><p><span class="docutils literal">supertype</span>: interface ref?</p></td>
|
||||
<td><p>none; membership is an identified interface_item_allocation relation</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_port</span>, <span class="docutils literal">sa_port</span></p></td>
|
||||
<td><p><span class="docutils literal">contract</span>: required PortContract; <span class="docutils literal">legacy</span>: optional LegacyPortContract</p></td>
|
||||
@@ -883,6 +882,14 @@ references. Their kind is the declaration keyword:</p>
|
||||
<td><p>Same-layer participant → leaf activity/function</p></td>
|
||||
<td><p>None</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">interface_item_allocation</span></p></td>
|
||||
<td><p>Same-layer Interface → ExchangeItem</p></td>
|
||||
<td><p><span class="docutils literal">order</span> u32 / 0; <span class="docutils literal">transmission</span> TransmissionProtocol / Unspecified; <span class="docutils literal">acquisition</span> AcquisitionProtocol / Unspecified</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">data_association</span></p></td>
|
||||
<td><p>Same-layer Record DataType → Record DataType</p></td>
|
||||
<td><p><span class="docutils literal">source_role</span>, <span class="docutils literal">target_role</span> / ""; <span class="docutils literal">source_minimum</span>, <span class="docutils literal">target_minimum</span> / 0; <span class="docutils literal">source_maximum</span>, <span class="docutils literal">target_maximum</span> / none; <span class="docutils literal">source_navigable</span>, <span class="docutils literal">target_navigable</span> / false</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">interaction</span></p></td>
|
||||
<td><p>OA activity → OA activity</p></td>
|
||||
<td><p><span class="docutils literal">items</span> refs / []</p></td>
|
||||
@@ -963,7 +970,8 @@ references. Their kind is the declaration keyword:</p>
|
||||
</table>
|
||||
<p>Relation endpoints and carried ExchangeItems stay in the same layer. Parallel
|
||||
exchanges with distinct identities are permitted. Self-relations are invalid
|
||||
except for a state transition that returns to its source state.</p>
|
||||
except for a state transition that returns to its source state or an explicit
|
||||
DataAssociation between two ends of the same Record type.</p>
|
||||
<p>Direct Allocation has at most one performer per leaf. Role sharing is a
|
||||
different relationship: several OA participants can realize a Role. A direct
|
||||
allocation inconsistent with its role's realized performers produces an
|
||||
@@ -1011,12 +1019,13 @@ not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||
Port deletion cascades to incident FEs; FE deletion retains independent ports.</p>
|
||||
<p>For example:</p>
|
||||
<pre class="literal-block">arcadia 3;
|
||||
<pre class="literal-block">arcadia 4;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
sa_exchange_item payload "Payload" {}
|
||||
sa_interface old_service "Preserved service" { items [@payload]; }
|
||||
sa_interface old_service "Preserved service" {}
|
||||
interface_item_allocation service_payload "Service payload" { source @old_service; target @payload; }
|
||||
sa_port output "Output" {
|
||||
parent @send;
|
||||
contract { FunctionOutput { outgoing_items [@payload]; }; };
|
||||
@@ -1125,11 +1134,19 @@ local to its sender lifeline, has no global exchange or values, and has either
|
||||
no receiver or the same receiver as sender. Its nonempty expression describes
|
||||
the delay/deadline; an empty expression warns. Conditions and expressions are
|
||||
stored engineering annotations, not an executable simulation language.</p>
|
||||
<p>Each MessageValue is <span class="docutils literal">{ field @data_field; expression "value expression"; }</span>.
|
||||
The field MUST be a DataField carried by the message exchange, including fields
|
||||
inside named nested records. At most one annotation for a given field is
|
||||
allowed in one message. Values do not overwrite the referenced type or global
|
||||
exchange item. Expression text is preserved exactly; an empty expression warns.</p>
|
||||
<p>Each MessageValue is <span class="docutils literal">{ path [@parameter]; field @data_field; expression "value expression"; }</span>.
|
||||
Its identity is the pair (path, field), so two uses of one Record can have
|
||||
different values for the same terminal field. Path contains enclosing DataField
|
||||
references, excluding the terminal field; direct parameters use <span class="docutils literal">[]</span>. Every
|
||||
hop MUST follow Value storage through a named Record's effective fields. Reference
|
||||
storage stops traversal. Operation Call accepts In/InOut and Reply accepts
|
||||
Out/InOut/Return, interpreted from the provider's perspective. Nested field paths
|
||||
inherit their direct parameter's direction. Unspecified direction warns.
|
||||
At most one annotation for a pair is allowed. Omitted path defaults to <span class="docutils literal">[]</span>;
|
||||
an ambiguous preserved legacy nested annotation warns <span class="docutils literal">message_value_ambiguous_path</span>
|
||||
until an explicit path is selected. New graphical choices always identify a path.
|
||||
Values never overwrite types or items. Expression text is descriptive, preserved
|
||||
exactly and not evaluated; an empty expression warns.</p>
|
||||
<p>ScenarioUse references an existing same-layer scenario and maps its lifelines
|
||||
with <span class="docutils literal">bindings [ { referenced @callee_line; local @caller_line; } ];</span>.
|
||||
Each binding is one-to-one and references a direct lifeline of the appropriate
|
||||
@@ -1280,6 +1297,137 @@ DeepHistory remembers nested configuration. Fallback applies when no resumable
|
||||
history exists. History is per Region, including top-level Regions in this
|
||||
Studio profile; no running history state is persisted or synthesized.</p>
|
||||
</section>
|
||||
<section id="data-contracts-specialization-and-interface-allocation">
|
||||
<h3>4.5. Data contracts, specialization and interface allocation</h3>
|
||||
<p>ExchangeItem <span class="docutils literal">kind</span> describes nature: Information, Material or Energy.
|
||||
<span class="docutils literal">mechanism</span> is Unspecified, Flow, Event, Operation or SharedData. Unknown
|
||||
mechanisms remain explicit Unspecified drafts with a warning. Material/Energy
|
||||
with Event/Operation/SharedData warns for engineering review; it is not silently
|
||||
reclassified. Empty Event notifications and void Operations are valid. An empty
|
||||
carried/allocated Flow, SharedData or Unspecified item warns.</p>
|
||||
<p>An Operation's directly owned DataFields are parameters. <span class="docutils literal">direction</span> is
|
||||
Unspecified, In, Out, InOut or Return, from the provider's perspective. At most
|
||||
one effective parameter is Return. Unspecified parameters warn; non-operation
|
||||
fields MUST use Unspecified. Nested record fields inherit direction from their
|
||||
direct parameter when used in message paths, rather than storing context on a
|
||||
shared type. <span class="docutils literal">storage</span> is Value or Reference. Reference requires a named Record,
|
||||
has no explicit default, and does not convey ownership or deletion of the type.</p>
|
||||
<p>DataTypeDefinition has exactly the following constructors:</p>
|
||||
<table>
|
||||
<caption>Reusable definitions</caption>
|
||||
<thead>
|
||||
<tr><th class="head"><p>Constructor</p></th>
|
||||
<th class="head"><p>Required fields</p></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><p>Boolean</p></td>
|
||||
<td><p>None</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Integer</p></td>
|
||||
<td><p><span class="docutils literal">minimum</span>, <span class="docutils literal">maximum</span>: optional signed 64-bit integers</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Real</p></td>
|
||||
<td><p><span class="docutils literal">minimum</span>, <span class="docutils literal">maximum</span>: optional finite IEEE-754 binary64 values</p></td>
|
||||
</tr>
|
||||
<tr><td><p>String</p></td>
|
||||
<td><p><span class="docutils literal">minimum_length</span>: u32; <span class="docutils literal">maximum_length</span>: optional u32</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Enumeration</p></td>
|
||||
<td><p><span class="docutils literal">literals</span>: unique nonempty, case-sensitive strings</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Record</p></td>
|
||||
<td><p><span class="docutils literal">supertype</span>: same-layer Record ref?</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Bounds are inclusive and ordered; an absent bound means unbounded within the
|
||||
represented numeric domain. Integer comparisons MUST NOT convert to binary64.
|
||||
NaN, infinities, overflow and reversed bounds reject. String length counts Unicode
|
||||
scalar values, not UTF-8 bytes or grapheme clusters. Enum defaults match exact
|
||||
literals. Empty enum definitions remain incomplete drafts with a warning.</p>
|
||||
<p>DataValue is a tagged scalar: <span class="docutils literal">{ Boolean true; }</span>, <span class="docutils literal">{ Integer 42; }</span>,
|
||||
<span class="docutils literal">{ Real 1.5; }</span>, <span class="docutils literal">{ String "value"; }</span> or <span class="docutils literal">{ Enumeration "Ready"; }</span>.
|
||||
There is no implicit numeric or string coercion. A type default is one compatible
|
||||
scalar within its bounds. Record type defaults and record/object instance literals
|
||||
are unsupported. A field default is absent (<span class="docutils literal">none</span>) or a list of compatible
|
||||
scalars with length inside its multiplicity. <span class="docutils literal">[]</span> explicitly supplies an empty
|
||||
collection and requires minimum 0, including Value Record fields; a nonempty
|
||||
Record literal rejects. Reference fields MUST use <span class="docutils literal">none</span>. A one-valued scalar
|
||||
field uses a one-value list. A named type default supplies an individual element's
|
||||
default, not a collection or an implicit instance of a mandatory field.</p>
|
||||
<p>Numeric types may carry unit text. Empty field units inherit the named type unit;
|
||||
an explicit different unit warns when that type has a unit. Units on nonnumeric
|
||||
fields/types warn and remain preserved. Unit strings never convert values or
|
||||
establish dimensional compatibility. Expressions are not executed.</p>
|
||||
<p>Record, ExchangeItem and Interface each permit one same-family, same-layer
|
||||
supertype. Specialization is acyclic and separate from containment. Effective
|
||||
fields are base fields followed by local fields, preserving original field UUIDs
|
||||
and owners. Local names MUST NOT shadow inherited names. Item specialization
|
||||
preserves base nature and any specified mechanism; an Unspecified base mechanism
|
||||
may be refined. Deleting a derived record never deletes its base. Mandatory
|
||||
Value-field cycles warn <span class="docutils literal">record_no_finite_value</span>; optional or Reference cycles
|
||||
are valid without that warning. Traversal is cycle guarded and bounded; a payload
|
||||
path budget failure is an explicit diagnostic, not silent truncation.</p>
|
||||
<p>InterfaceItemAllocation is an ordinary identified relation from Interface to
|
||||
ExchangeItem. It stores <span class="docutils literal">order</span> and independent transmission/acquisition
|
||||
protocols. Transmission is Unspecified, Synchronous, Asynchronous, Unicast,
|
||||
Multicast or Broadcast; acquisition is Unspecified, Read or Accept. No protocol
|
||||
combination is inferred from mechanism. Effective allocations are base-first,
|
||||
then local ordered by (order, alias, UUID). A local item already allocated locally
|
||||
or inherited is an error. Removing an allocation keeps its item and other
|
||||
allocations; if live boundary compatibility would break, the transaction MUST
|
||||
repair the affected contract explicitly or reject atomically. Artifacts can
|
||||
reference the allocation identity independently of the global item.</p>
|
||||
<p>An exchange carrying a specialized item satisfies a declaration of its base,
|
||||
never the reverse. Standard provided/required compatibility uses explicit
|
||||
interface ancestry. Identity/ancestry, not matching names, defines conformance.
|
||||
The current typed function/component port contracts remain unchanged.</p>
|
||||
<p>DataAssociation connects Record types without adding fields or transitively
|
||||
carrying payload. Each named end has role, multiplicity and navigability. Source
|
||||
multiplicity counts source instances per target instance and vice versa; it is
|
||||
not a count of model elements. An end's navigable flag permits navigation toward
|
||||
that end. Self-associations are valid; if both ends are navigable they require
|
||||
distinct nonempty roles. Empty roles and duplicate role contracts warn, whereas
|
||||
invalid ranges reject. Composition/shared aggregation, multiple inheritance,
|
||||
redefinition, instance literals and arbitrary-precision decimal arithmetic are
|
||||
outside this bounded contract.</p>
|
||||
<p>This complete data contract example is also exercised through the REST API:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
default { Real 24.0; }; unit "V";
|
||||
}
|
||||
sa_data_type coordinate "Coordinate" {
|
||||
definition { Record { supertype none; }; };
|
||||
sa_data_field latitude "Latitude" { type_ref Real; }
|
||||
}
|
||||
sa_data_type located "Located asset" {
|
||||
definition { Record { supertype @coordinate; }; };
|
||||
sa_data_field supply "Supply" { type_ref { Named @voltage; }; }
|
||||
}
|
||||
sa_exchange_item navigate "Navigate" {
|
||||
mechanism Operation;
|
||||
sa_data_field origin "Origin" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field destination "Destination" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field accepted "Accepted" { type_ref Boolean; direction Return; }
|
||||
}
|
||||
sa_interface navigation "Navigation" {}
|
||||
sa_interface monitoring "Monitoring" {}
|
||||
interface_item_allocation nav_operation "Navigation operation" {
|
||||
source @navigation; target @navigate; transmission Synchronous; acquisition Accept;
|
||||
}
|
||||
interface_item_allocation monitor_operation "Monitoring operation" {
|
||||
source @monitoring; target @navigate; transmission Asynchronous; acquisition Read;
|
||||
}
|
||||
data_association location_link "Location relationship" {
|
||||
source @coordinate; target @located;
|
||||
source_role "location"; target_role "assets";
|
||||
source_maximum 1;
|
||||
}
|
||||
}</code></pre>
|
||||
</section>
|
||||
</section>
|
||||
<section id="diagrams-artifacts-and-cross-layer-traceability">
|
||||
<h2>5. Diagrams, artifacts, and cross-layer traceability</h2>
|
||||
@@ -1308,7 +1456,7 @@ 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;
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
@@ -1517,7 +1665,7 @@ spellings can be used in alias positions because references begin with <span cla
|
||||
<span class="docutils literal"><span class="pre">element-kind</span></span> and <span class="docutils literal"><span class="pre">relation-kind</span></span> are exactly the corresponding declaration
|
||||
keywords listed above. Typed field
|
||||
sets and enum constructors are closed by sections 3–5.</p>
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "3" ";" project
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "4" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -1554,16 +1702,16 @@ Ordinary string fields require quoted strings and cannot contain an unquoted
|
||||
<span class="docutils literal">@alias</span> reference. Enum fields require unquoted named values or their typed
|
||||
constructor records. Typed values MUST NOT nest more than 64 levels; malformed
|
||||
deeply nested drafts produce a diagnostic instead of exhausting the parser stack.</p>
|
||||
<section id="modular-source3-projects">
|
||||
<h3>6.1. Modular source3 projects</h3>
|
||||
<section id="modular-source-projects">
|
||||
<h3>6.1. Modular source projects</h3>
|
||||
<p>The pure <span class="docutils literal">source_project</span> core API compiles a virtual source bundle, containing
|
||||
<span class="docutils literal">entry</span> and a <span class="docutils literal">files</span> map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source3 project header above. A project entry
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source4 project header above. A project entry
|
||||
MAY also be <span class="docutils literal">include string ";"</span>. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example:</p>
|
||||
<pre class="literal-block">arcadia 3;
|
||||
<pre class="literal-block">arcadia 4;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -1621,9 +1769,12 @@ the source index. Columns remain one-based Unicode scalars within the real file;
|
||||
spans never refer to concatenated or synthetic source.</p>
|
||||
<p>Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through their existing migration readers and reports. Modular projects
|
||||
require source3; migration is performed before modular export, not independently
|
||||
on included fragments. Modular source does not change the semantic JSON schema.</p>
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.</p>
|
||||
</section>
|
||||
<section id="nested-element-ownership">
|
||||
<h3>6.2. Nested element ownership</h3>
|
||||
@@ -1827,9 +1978,31 @@ explicitly before Apply succeeds.</p>
|
||||
<h2>9. Persistence and explicit migration</h2>
|
||||
<p>Loading detects JSON from its first non-whitespace <span class="docutils literal">{</span>; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an <span class="docutils literal">.arcadia</span> suffix uses
|
||||
the accepted V3 source bundle (<span class="docutils literal">arcadia 3</span>); other suffixes use V3 JSON. Both forms preserve the same
|
||||
the accepted V4 source bundle (<span class="docutils literal">arcadia 4</span>); other suffixes use V4 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.</p>
|
||||
<p>The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
their nature with Unspecified mechanism; old Event becomes Information with Event
|
||||
mechanism. Old bounds/defaults/supertypes are absent, String length is 0..unbounded,
|
||||
and fields retain type/multiplicity/unit with Unspecified direction, Value storage
|
||||
and no explicit default. No physical mechanism or direction is guessed.</p>
|
||||
<p>Each old Interface.items entry generates one InterfaceItemAllocation with its
|
||||
original zero-based order and Unspecified protocols. UUID v5 uses the project UUID
|
||||
namespace and name <span class="docutils literal"><span class="pre">v2-interface-item:<interface-uuid>:<item-uuid></span></span> consistently
|
||||
for schema-2 and schema-3 inputs. Aliases are deterministic and collision-free;
|
||||
collision with any existing identity is an error rather than an overwrite. Existing
|
||||
interface/item identities remain intact. Old nested message values acquire a path
|
||||
only when exactly one valid path exists. Ambiguous paths, including recursive
|
||||
reuse, retain the old annotation with a located warning rather than guessing.</p>
|
||||
<p>Migration reports additionally contain <span class="docutils literal">semantic_defaults</span> (object, field,
|
||||
description), <span class="docutils literal">generated_interface_allocations</span> (id, source, target, alias,
|
||||
order) and <span class="docutils literal">unresolved_message_values</span> (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.</p>
|
||||
<p>Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
@@ -1853,7 +2026,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
<span class="docutils literal"><span class="pre">crates/oa-core/tests/fixtures</span></span> MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3 migration.</p>
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.</p>
|
||||
<p>The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
direction/interface record. Existing FE endpoint IDs remain unchanged. For
|
||||
@@ -1870,13 +2043,13 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 3. Model/source exports contain only the canonical model. Migration
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.</p>
|
||||
<p>Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V3 graph are
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses this same V2-to-3 stage.</p>
|
||||
model and then uses these same V2-to-3-to-4 stages.</p>
|
||||
<p>Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
not truncate a previous file. External concurrent-writer conflict resolution
|
||||
@@ -1892,20 +2065,20 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V3 result, including repeated occurrences.
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.</p>
|
||||
<p>V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V3 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
V4 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
</section>
|
||||
<section id="complete-authoring-example">
|
||||
<h2>10. Complete authoring example</h2>
|
||||
<p>This document intentionally omits IDs for new authoring. Apply and Save write
|
||||
the assigned UUIDs explicitly.</p>
|
||||
<pre class="code text literal-block"><code>arcadia 3;
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
|
||||
+219
-41
@@ -1,7 +1,7 @@
|
||||
Arcadia Studio OA / SA Language and Editing Contract
|
||||
====================================================
|
||||
|
||||
:Version: 3
|
||||
:Version: 4
|
||||
:Status: Normative current implementation contract; first-release integration
|
||||
:Core implementation: ``crates/oa-core``
|
||||
:Text suffix: ``.arcadia``
|
||||
@@ -11,15 +11,14 @@ Arcadia Studio OA / SA Language and Editing Contract
|
||||
|
||||
This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.
|
||||
|
||||
Schema 3 introduces typed function and component ports, required functional
|
||||
exchange endpoints, independently oriented boundary exchanges, and explicit
|
||||
PortAllocation relations. First-release scenario/state additions remain in
|
||||
scope; this schema change does not declare native acceptance or the wider
|
||||
release complete. The preserved schema-2 reader validates the original
|
||||
contract before migration.
|
||||
Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
This contract does not by itself establish native acceptance or release completion.
|
||||
|
||||
OA describes stakeholders, operational work and needs. SA describes the
|
||||
selected system boundary, external actors, functions, services and expected
|
||||
@@ -40,7 +39,7 @@ Actual verification evidence is separate from this contract.
|
||||
2. One authoritative model
|
||||
--------------------------
|
||||
|
||||
A Project stores ``schema_version`` (3), ``id``, ``alias``, ``name``,
|
||||
A Project stores ``schema_version`` (4), ``id``, ``alias``, ``name``,
|
||||
``description``, and maps named ``elements``, ``relations``, ``diagrams``,
|
||||
``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.
|
||||
@@ -96,17 +95,17 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
|
||||
- ``steps``: occurrence refs
|
||||
- ``[]``
|
||||
* - ``oa_exchange_item``, ``sa_exchange_item``
|
||||
- ``kind``: Information, Material, Energy, Event
|
||||
- Information
|
||||
- ``kind``: Information, Material, Energy; ``mechanism``: ExchangeMechanism; ``supertype``: item ref?
|
||||
- Information; Unspecified; none
|
||||
* - ``oa_data_type``, ``sa_data_type``
|
||||
- ``definition``: Boolean, Integer, Real, String, Record, or Enumeration constructor
|
||||
- String
|
||||
- ``definition``: DataTypeDefinition; ``default``: DataValue?; ``unit``: string
|
||||
- String with minimum_length 0 / maximum_length none; none; ``""``
|
||||
* - ``oa_data_field``, ``sa_data_field``
|
||||
- ``type_ref``; ``minimum``: u32; ``maximum``: optional u32; ``unit``: string
|
||||
- String; 1; 1; ``""``
|
||||
- ``type_ref``; ``minimum``: u32; ``maximum``: optional u32; ``unit``: string; ``direction``: ParameterDirection; ``storage``: FieldStorage; ``default``: DataValue list?
|
||||
- String; 1; 1; ``""``; Unspecified; Value; none
|
||||
* - ``oa_interface``, ``sa_interface``
|
||||
- ``items``: exchange-item refs
|
||||
- ``[]``
|
||||
- ``supertype``: interface ref?
|
||||
- none; membership is an identified interface_item_allocation relation
|
||||
* - ``oa_port``, ``sa_port``
|
||||
- ``contract``: required PortContract; ``legacy``: optional LegacyPortContract
|
||||
- No implicit contract; ``legacy`` defaults none. OA admits Legacy only.
|
||||
@@ -230,6 +229,12 @@ references. Their kind is the declaration keyword:
|
||||
* - ``allocation``
|
||||
- Same-layer participant → leaf activity/function
|
||||
- None
|
||||
* - ``interface_item_allocation``
|
||||
- Same-layer Interface → ExchangeItem
|
||||
- ``order`` u32 / 0; ``transmission`` TransmissionProtocol / Unspecified; ``acquisition`` AcquisitionProtocol / Unspecified
|
||||
* - ``data_association``
|
||||
- Same-layer Record DataType → Record DataType
|
||||
- ``source_role``, ``target_role`` / ""; ``source_minimum``, ``target_minimum`` / 0; ``source_maximum``, ``target_maximum`` / none; ``source_navigable``, ``target_navigable`` / false
|
||||
* - ``interaction``
|
||||
- OA activity → OA activity
|
||||
- ``items`` refs / []
|
||||
@@ -290,7 +295,8 @@ references. Their kind is the declaration keyword:
|
||||
|
||||
Relation endpoints and carried ExchangeItems stay in the same layer. Parallel
|
||||
exchanges with distinct identities are permitted. Self-relations are invalid
|
||||
except for a state transition that returns to its source state.
|
||||
except for a state transition that returns to its source state or an explicit
|
||||
DataAssociation between two ends of the same Record type.
|
||||
|
||||
Direct Allocation has at most one performer per leaf. Role sharing is a
|
||||
different relationship: several OA participants can realize a Role. A direct
|
||||
@@ -347,12 +353,13 @@ Port deletion cascades to incident FEs; FE deletion retains independent ports.
|
||||
|
||||
For example::
|
||||
|
||||
arcadia 3;
|
||||
arcadia 4;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
sa_exchange_item payload "Payload" {}
|
||||
sa_interface old_service "Preserved service" { items [@payload]; }
|
||||
sa_interface old_service "Preserved service" {}
|
||||
interface_item_allocation service_payload "Service payload" { source @old_service; target @payload; }
|
||||
sa_port output "Output" {
|
||||
parent @send;
|
||||
contract { FunctionOutput { outgoing_items [@payload]; }; };
|
||||
@@ -474,11 +481,19 @@ no receiver or the same receiver as sender. Its nonempty expression describes
|
||||
the delay/deadline; an empty expression warns. Conditions and expressions are
|
||||
stored engineering annotations, not an executable simulation language.
|
||||
|
||||
Each MessageValue is ``{ field @data_field; expression "value expression"; }``.
|
||||
The field MUST be a DataField carried by the message exchange, including fields
|
||||
inside named nested records. At most one annotation for a given field is
|
||||
allowed in one message. Values do not overwrite the referenced type or global
|
||||
exchange item. Expression text is preserved exactly; an empty expression warns.
|
||||
Each MessageValue is ``{ path [@parameter]; field @data_field; expression "value expression"; }``.
|
||||
Its identity is the pair (path, field), so two uses of one Record can have
|
||||
different values for the same terminal field. Path contains enclosing DataField
|
||||
references, excluding the terminal field; direct parameters use ``[]``. Every
|
||||
hop MUST follow Value storage through a named Record's effective fields. Reference
|
||||
storage stops traversal. Operation Call accepts In/InOut and Reply accepts
|
||||
Out/InOut/Return, interpreted from the provider's perspective. Nested field paths
|
||||
inherit their direct parameter's direction. Unspecified direction warns.
|
||||
At most one annotation for a pair is allowed. Omitted path defaults to ``[]``;
|
||||
an ambiguous preserved legacy nested annotation warns ``message_value_ambiguous_path``
|
||||
until an explicit path is selected. New graphical choices always identify a path.
|
||||
Values never overwrite types or items. Expression text is descriptive, preserved
|
||||
exactly and not evaluated; an empty expression warns.
|
||||
|
||||
ScenarioUse references an existing same-layer scenario and maps its lifelines
|
||||
with ``bindings [ { referenced @callee_line; local @caller_line; } ];``.
|
||||
@@ -620,6 +635,141 @@ DeepHistory remembers nested configuration. Fallback applies when no resumable
|
||||
history exists. History is per Region, including top-level Regions in this
|
||||
Studio profile; no running history state is persisted or synthesized.
|
||||
|
||||
4.5. Data contracts, specialization and interface allocation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
ExchangeItem ``kind`` describes nature: Information, Material or Energy.
|
||||
``mechanism`` is Unspecified, Flow, Event, Operation or SharedData. Unknown
|
||||
mechanisms remain explicit Unspecified drafts with a warning. Material/Energy
|
||||
with Event/Operation/SharedData warns for engineering review; it is not silently
|
||||
reclassified. Empty Event notifications and void Operations are valid. An empty
|
||||
carried/allocated Flow, SharedData or Unspecified item warns.
|
||||
|
||||
An Operation's directly owned DataFields are parameters. ``direction`` is
|
||||
Unspecified, In, Out, InOut or Return, from the provider's perspective. At most
|
||||
one effective parameter is Return. Unspecified parameters warn; non-operation
|
||||
fields MUST use Unspecified. Nested record fields inherit direction from their
|
||||
direct parameter when used in message paths, rather than storing context on a
|
||||
shared type. ``storage`` is Value or Reference. Reference requires a named Record,
|
||||
has no explicit default, and does not convey ownership or deletion of the type.
|
||||
|
||||
DataTypeDefinition has exactly the following constructors:
|
||||
|
||||
.. list-table:: Reusable definitions
|
||||
:header-rows: 1
|
||||
|
||||
* - Constructor
|
||||
- Required fields
|
||||
* - Boolean
|
||||
- None
|
||||
* - Integer
|
||||
- ``minimum``, ``maximum``: optional signed 64-bit integers
|
||||
* - Real
|
||||
- ``minimum``, ``maximum``: optional finite IEEE-754 binary64 values
|
||||
* - String
|
||||
- ``minimum_length``: u32; ``maximum_length``: optional u32
|
||||
* - Enumeration
|
||||
- ``literals``: unique nonempty, case-sensitive strings
|
||||
* - Record
|
||||
- ``supertype``: same-layer Record ref?
|
||||
|
||||
Bounds are inclusive and ordered; an absent bound means unbounded within the
|
||||
represented numeric domain. Integer comparisons MUST NOT convert to binary64.
|
||||
NaN, infinities, overflow and reversed bounds reject. String length counts Unicode
|
||||
scalar values, not UTF-8 bytes or grapheme clusters. Enum defaults match exact
|
||||
literals. Empty enum definitions remain incomplete drafts with a warning.
|
||||
|
||||
DataValue is a tagged scalar: ``{ Boolean true; }``, ``{ Integer 42; }``,
|
||||
``{ Real 1.5; }``, ``{ String "value"; }`` or ``{ Enumeration "Ready"; }``.
|
||||
There is no implicit numeric or string coercion. A type default is one compatible
|
||||
scalar within its bounds. Record type defaults and record/object instance literals
|
||||
are unsupported. A field default is absent (``none``) or a list of compatible
|
||||
scalars with length inside its multiplicity. ``[]`` explicitly supplies an empty
|
||||
collection and requires minimum 0, including Value Record fields; a nonempty
|
||||
Record literal rejects. Reference fields MUST use ``none``. A one-valued scalar
|
||||
field uses a one-value list. A named type default supplies an individual element's
|
||||
default, not a collection or an implicit instance of a mandatory field.
|
||||
|
||||
Numeric types may carry unit text. Empty field units inherit the named type unit;
|
||||
an explicit different unit warns when that type has a unit. Units on nonnumeric
|
||||
fields/types warn and remain preserved. Unit strings never convert values or
|
||||
establish dimensional compatibility. Expressions are not executed.
|
||||
|
||||
Record, ExchangeItem and Interface each permit one same-family, same-layer
|
||||
supertype. Specialization is acyclic and separate from containment. Effective
|
||||
fields are base fields followed by local fields, preserving original field UUIDs
|
||||
and owners. Local names MUST NOT shadow inherited names. Item specialization
|
||||
preserves base nature and any specified mechanism; an Unspecified base mechanism
|
||||
may be refined. Deleting a derived record never deletes its base. Mandatory
|
||||
Value-field cycles warn ``record_no_finite_value``; optional or Reference cycles
|
||||
are valid without that warning. Traversal is cycle guarded and bounded; a payload
|
||||
path budget failure is an explicit diagnostic, not silent truncation.
|
||||
|
||||
InterfaceItemAllocation is an ordinary identified relation from Interface to
|
||||
ExchangeItem. It stores ``order`` and independent transmission/acquisition
|
||||
protocols. Transmission is Unspecified, Synchronous, Asynchronous, Unicast,
|
||||
Multicast or Broadcast; acquisition is Unspecified, Read or Accept. No protocol
|
||||
combination is inferred from mechanism. Effective allocations are base-first,
|
||||
then local ordered by (order, alias, UUID). A local item already allocated locally
|
||||
or inherited is an error. Removing an allocation keeps its item and other
|
||||
allocations; if live boundary compatibility would break, the transaction MUST
|
||||
repair the affected contract explicitly or reject atomically. Artifacts can
|
||||
reference the allocation identity independently of the global item.
|
||||
|
||||
An exchange carrying a specialized item satisfies a declaration of its base,
|
||||
never the reverse. Standard provided/required compatibility uses explicit
|
||||
interface ancestry. Identity/ancestry, not matching names, defines conformance.
|
||||
The current typed function/component port contracts remain unchanged.
|
||||
|
||||
DataAssociation connects Record types without adding fields or transitively
|
||||
carrying payload. Each named end has role, multiplicity and navigability. Source
|
||||
multiplicity counts source instances per target instance and vice versa; it is
|
||||
not a count of model elements. An end's navigable flag permits navigation toward
|
||||
that end. Self-associations are valid; if both ends are navigable they require
|
||||
distinct nonempty roles. Empty roles and duplicate role contracts warn, whereas
|
||||
invalid ranges reject. Composition/shared aggregation, multiple inheritance,
|
||||
redefinition, instance literals and arbitrary-precision decimal arithmetic are
|
||||
outside this bounded contract.
|
||||
|
||||
This complete data contract example is also exercised through the REST API:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 4;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
default { Real 24.0; }; unit "V";
|
||||
}
|
||||
sa_data_type coordinate "Coordinate" {
|
||||
definition { Record { supertype none; }; };
|
||||
sa_data_field latitude "Latitude" { type_ref Real; }
|
||||
}
|
||||
sa_data_type located "Located asset" {
|
||||
definition { Record { supertype @coordinate; }; };
|
||||
sa_data_field supply "Supply" { type_ref { Named @voltage; }; }
|
||||
}
|
||||
sa_exchange_item navigate "Navigate" {
|
||||
mechanism Operation;
|
||||
sa_data_field origin "Origin" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field destination "Destination" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field accepted "Accepted" { type_ref Boolean; direction Return; }
|
||||
}
|
||||
sa_interface navigation "Navigation" {}
|
||||
sa_interface monitoring "Monitoring" {}
|
||||
interface_item_allocation nav_operation "Navigation operation" {
|
||||
source @navigation; target @navigate; transmission Synchronous; acquisition Accept;
|
||||
}
|
||||
interface_item_allocation monitor_operation "Monitoring operation" {
|
||||
source @monitoring; target @navigate; transmission Asynchronous; acquisition Read;
|
||||
}
|
||||
data_association location_link "Location relationship" {
|
||||
source @coordinate; target @located;
|
||||
source_role "location"; target_role "assets";
|
||||
source_maximum 1;
|
||||
}
|
||||
}
|
||||
|
||||
5. Diagrams, artifacts, and cross-layer traceability
|
||||
----------------------------------------------------
|
||||
|
||||
@@ -654,7 +804,7 @@ This complete source example places one state from an existing machine:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 3;
|
||||
arcadia 4;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
@@ -875,7 +1025,7 @@ sets and enum constructors are closed by sections 3–5.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
document = "arcadia" "3" ";" project
|
||||
document = "arcadia" "4" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -917,18 +1067,18 @@ Ordinary string fields require quoted strings and cannot contain an unquoted
|
||||
constructor records. Typed values MUST NOT nest more than 64 levels; malformed
|
||||
deeply nested drafts produce a diagnostic instead of exhausting the parser stack.
|
||||
|
||||
6.1. Modular source3 projects
|
||||
6.1. Modular source projects
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The pure ``source_project`` core API compiles a virtual source bundle, containing
|
||||
``entry`` and a ``files`` map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level ``.arcadia`` file with the source3 project header above. A project entry
|
||||
root-level ``.arcadia`` file with the source4 project header above. A project entry
|
||||
MAY also be ``include string ";"``. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example::
|
||||
|
||||
arcadia 3;
|
||||
arcadia 4;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -994,9 +1144,12 @@ spans never refer to concatenated or synthetic source.
|
||||
|
||||
Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through their existing migration readers and reports. Modular projects
|
||||
require source3; migration is performed before modular export, not independently
|
||||
on included fragments. Modular source does not change the semantic JSON schema.
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.
|
||||
|
||||
6.2. Nested element ownership
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -1228,10 +1381,35 @@ explicitly before Apply succeeds.
|
||||
|
||||
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
|
||||
the accepted V3 source bundle (``arcadia 3``); other suffixes use V3 JSON. Both forms preserve the same
|
||||
the accepted V4 source bundle (``arcadia 4``); other suffixes use V4 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.
|
||||
|
||||
The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
their nature with Unspecified mechanism; old Event becomes Information with Event
|
||||
mechanism. Old bounds/defaults/supertypes are absent, String length is 0..unbounded,
|
||||
and fields retain type/multiplicity/unit with Unspecified direction, Value storage
|
||||
and no explicit default. No physical mechanism or direction is guessed.
|
||||
|
||||
Each old Interface.items entry generates one InterfaceItemAllocation with its
|
||||
original zero-based order and Unspecified protocols. UUID v5 uses the project UUID
|
||||
namespace and name ``v2-interface-item:<interface-uuid>:<item-uuid>`` consistently
|
||||
for schema-2 and schema-3 inputs. Aliases are deterministic and collision-free;
|
||||
collision with any existing identity is an error rather than an overwrite. Existing
|
||||
interface/item identities remain intact. Old nested message values acquire a path
|
||||
only when exactly one valid path exists. Ambiguous paths, including recursive
|
||||
reuse, retain the old annotation with a located warning rather than guessing.
|
||||
|
||||
Migration reports additionally contain ``semantic_defaults`` (object, field,
|
||||
description), ``generated_interface_allocations`` (id, source, target, alias,
|
||||
order) and ``unresolved_message_values`` (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.
|
||||
|
||||
Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
@@ -1256,7 +1434,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3 migration.
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.
|
||||
|
||||
The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
@@ -1275,14 +1453,14 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 3. Model/source exports contain only the canonical model. Migration
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.
|
||||
|
||||
Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V3 graph are
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses this same V2-to-3 stage.
|
||||
model and then uses these same V2-to-3-to-4 stages.
|
||||
|
||||
Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
@@ -1301,7 +1479,7 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V3 result, including repeated occurrences.
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.
|
||||
|
||||
V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
@@ -1309,7 +1487,7 @@ canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V3 projects. Future versions remain errors until a separate migration exists.
|
||||
V4 projects. Future versions remain errors until a separate migration exists.
|
||||
|
||||
10. Complete authoring example
|
||||
------------------------------
|
||||
@@ -1319,7 +1497,7 @@ the assigned UUIDs explicitly.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 3;
|
||||
arcadia 4;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
|
||||
+652
-63
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Arcadia Studio model API",
|
||||
"version": "1.0.0",
|
||||
"description": "Current schema-3 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag. Each accepted envelope couples model and authored source under one revision. Source-only edits advance the shared ETag. Source bundles resolve logical paths only, preserve authored files/comments, and have a stabilized JSON cap of 8 MiB, 256 files, 32 include edges along a path and 1,024 include edges overall."
|
||||
"description": "Current schema-4 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag. Each accepted envelope couples model and authored source under one revision. Source-only edits advance the shared ETag. Source bundles resolve logical paths only, preserve authored files/comments, and have a stabilized JSON cap of 8 MiB, 256 files, 32 include edges along a path and 1,024 include edges overall."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -389,7 +389,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Schema-3 native JSON is described below. Schema-1 and schema-2 JSON/source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Reports list preserved Legacy contracts, generated FE endpoint IDs/aliases and boundary orientation defaults. Export emits schema 3 / arcadia 3. See docs/language.rst."
|
||||
"description": "Schema-4 native JSON is described below. Schema-1, schema-2 and schema-3 JSON/source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Reports list preserved Legacy contracts, generated FE endpoint IDs/aliases and boundary orientation defaults. Export emits schema 4 / arcadia 4. See docs/language.rst."
|
||||
}
|
||||
},
|
||||
"/api/v1/projects/{id}": {
|
||||
@@ -1902,7 +1902,9 @@
|
||||
"CapabilityGeneralization",
|
||||
"MissionInvolvement",
|
||||
"SequenceLink",
|
||||
"PortAllocation"
|
||||
"PortAllocation",
|
||||
"InterfaceItemAllocation",
|
||||
"DataAssociation"
|
||||
]
|
||||
},
|
||||
"Position": {
|
||||
@@ -2004,40 +2006,185 @@
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Boolean",
|
||||
"Integer",
|
||||
"Real",
|
||||
"String",
|
||||
"Record"
|
||||
"Boolean"
|
||||
]
|
||||
},
|
||||
{
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Integer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Enumeration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"literals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
"minimum": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": -9223372036854775808,
|
||||
"maximum": 9223372036854775807
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"literals"
|
||||
],
|
||||
"additionalProperties": false
|
||||
]
|
||||
},
|
||||
"maximum": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": -9223372036854775808,
|
||||
"maximum": 9223372036854775807
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Enumeration"
|
||||
"minimum",
|
||||
"maximum"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": [
|
||||
"Integer"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Real": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"minimum": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Finite IEEE-754 binary64; no coercion from typed Integer values."
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"maximum": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Finite IEEE-754 binary64; no coercion from typed Integer values."
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"minimum",
|
||||
"maximum"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Real"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"String": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"minimum_length": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"maximum_length": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"minimum_length",
|
||||
"maximum_length"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"String"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Enumeration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"literals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"literals"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Enumeration"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Record": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"supertype": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"supertype"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Record"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2402,17 +2549,27 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Information",
|
||||
"Material",
|
||||
"Energy",
|
||||
"Event"
|
||||
"$ref": "#/components/schemas/ExchangeItemKind"
|
||||
},
|
||||
"mechanism": {
|
||||
"$ref": "#/components/schemas/ExchangeMechanism"
|
||||
},
|
||||
"supertype": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
"kind",
|
||||
"mechanism",
|
||||
"supertype"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -2430,10 +2587,25 @@
|
||||
"properties": {
|
||||
"definition": {
|
||||
"$ref": "#/components/schemas/DataTypeDefinition"
|
||||
},
|
||||
"default": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/DataValue"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"definition"
|
||||
"definition",
|
||||
"default",
|
||||
"unit"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -2454,13 +2626,15 @@
|
||||
},
|
||||
"minimum": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"maximum": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -2469,13 +2643,35 @@
|
||||
},
|
||||
"unit": {
|
||||
"type": "string"
|
||||
},
|
||||
"direction": {
|
||||
"$ref": "#/components/schemas/ParameterDirection"
|
||||
},
|
||||
"storage": {
|
||||
"$ref": "#/components/schemas/FieldStorage"
|
||||
},
|
||||
"default": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DataValue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type_ref",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"unit"
|
||||
"unit",
|
||||
"direction",
|
||||
"storage",
|
||||
"default"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -2491,16 +2687,20 @@
|
||||
"Interface": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
"supertype": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
"supertype"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -3581,6 +3781,128 @@
|
||||
"PortAllocation"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"InterfaceItemAllocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"transmission": {
|
||||
"$ref": "#/components/schemas/TransmissionProtocol"
|
||||
},
|
||||
"acquisition": {
|
||||
"$ref": "#/components/schemas/AcquisitionProtocol"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source",
|
||||
"target",
|
||||
"order",
|
||||
"transmission",
|
||||
"acquisition"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"InterfaceItemAllocation"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DataAssociation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"source_role": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_role": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_minimum": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"source_maximum": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"target_minimum": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"target_maximum": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"source_navigable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"target_navigable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source",
|
||||
"target",
|
||||
"source_role",
|
||||
"target_role",
|
||||
"source_minimum",
|
||||
"source_maximum",
|
||||
"target_minimum",
|
||||
"target_maximum",
|
||||
"source_navigable",
|
||||
"target_navigable"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"DataAssociation"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3617,7 +3939,12 @@
|
||||
},
|
||||
"PortAnchorSide": {
|
||||
"type": "string",
|
||||
"enum": ["Left", "Right", "Top", "Bottom"],
|
||||
"enum": [
|
||||
"Left",
|
||||
"Right",
|
||||
"Top",
|
||||
"Bottom"
|
||||
],
|
||||
"description": "Presentation side of the owner placement; independent of semantic port direction."
|
||||
},
|
||||
"PortAnchor": {
|
||||
@@ -3638,7 +3965,11 @@
|
||||
"description": "Finite binary32 fraction of the complete owner edge, measured from top on Left/Right and from left on Top/Bottom."
|
||||
}
|
||||
},
|
||||
"required": ["port", "side", "offset"],
|
||||
"required": [
|
||||
"port",
|
||||
"side",
|
||||
"offset"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NodePlacement": {
|
||||
@@ -3956,7 +4287,7 @@
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
"const": 4
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
@@ -4070,7 +4401,7 @@
|
||||
"source_project"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"description": "Accepted coupled model/source snapshot. Compiling source_project reproduces model; revision changes once if either representation changes. Old model-only envelopes acquire source in memory without a write or revision change."
|
||||
"description": "Accepted coupled model/source snapshot. Compiling source_project reproduces model; revision changes once if either representation changes. Older model-only or schema3 coupled envelopes migrate in memory without a write or revision change; both representations must independently migrate to the same graph."
|
||||
},
|
||||
"ProjectSummary": {
|
||||
"type": "object",
|
||||
@@ -4814,15 +5145,29 @@
|
||||
"type": "object",
|
||||
"description": "Upsert manual port position by port identity on an existing owner placement, preserving unrelated entries. Port must be a direct child of the placement's semantic element.",
|
||||
"properties": {
|
||||
"diagram": { "type": "string", "format": "uuid" },
|
||||
"node": { "type": "string", "format": "uuid" },
|
||||
"anchor": { "$ref": "#/components/schemas/PortAnchor" }
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"node": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"anchor": {
|
||||
"$ref": "#/components/schemas/PortAnchor"
|
||||
}
|
||||
},
|
||||
"required": ["diagram", "node", "anchor"],
|
||||
"required": [
|
||||
"diagram",
|
||||
"node",
|
||||
"anchor"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["SetPortAnchor"],
|
||||
"required": [
|
||||
"SetPortAnchor"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
@@ -4832,15 +5177,30 @@
|
||||
"type": "object",
|
||||
"description": "Restore automatic positioning for an existing direct child Port. No-op when that valid owner placement has no manual anchor for the port; wrong or stale ownership is rejected.",
|
||||
"properties": {
|
||||
"diagram": { "type": "string", "format": "uuid" },
|
||||
"node": { "type": "string", "format": "uuid" },
|
||||
"port": { "type": "string", "format": "uuid" }
|
||||
"diagram": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"node": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"port": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"required": ["diagram", "node", "port"],
|
||||
"required": [
|
||||
"diagram",
|
||||
"node",
|
||||
"port"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["RemovePortAnchor"],
|
||||
"required": [
|
||||
"RemovePortAnchor"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
@@ -5809,6 +6169,14 @@
|
||||
"MessageValue": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"field": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
@@ -5821,7 +6189,8 @@
|
||||
"field",
|
||||
"expression"
|
||||
],
|
||||
"additionalProperties": false
|
||||
"additionalProperties": false,
|
||||
"description": "Identity is (path, field). Path lists enclosing DataField UUIDs, excluding the terminal field. Value storage traverses named Records; Reference storage stops traversal. Operation Call accepts In/InOut and Reply accepts Out/InOut/Return. Unspecified direction and retained ambiguous legacy empty paths warn. Expressions are descriptive, never executed."
|
||||
},
|
||||
"LifelineBinding": {
|
||||
"type": "object",
|
||||
@@ -6083,12 +6452,13 @@
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
1,
|
||||
2
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
"to_schema": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
"const": 4
|
||||
},
|
||||
"preserved_ports": {
|
||||
"type": "array",
|
||||
@@ -6179,6 +6549,91 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"semantic_defaults": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"object",
|
||||
"field",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"generated_interface_allocations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4294967295
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"source",
|
||||
"target",
|
||||
"alias",
|
||||
"order"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"unresolved_message_values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"occurrence": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"field": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"occurrence",
|
||||
"field",
|
||||
"reason"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -6190,11 +6645,11 @@
|
||||
"required_resolutions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"description": "Transient observations from explicit V1→V2→V3 or V2→V3 migration. No save or revision increase occurs merely by reading an old envelope."
|
||||
"description": "Transient observations from explicit V1/V2/V3 to V4 migration. New report lists default to empty when reading older responses. Reading an old envelope neither saves nor changes its revision. Allocation UUIDv5 uses project namespace and v2-interface-item:<interface>:<item>."
|
||||
},
|
||||
"SourceBundle": {
|
||||
"type": "object",
|
||||
"description": "Complete source3 workspace. The entry is a root filename. Files use canonical portable slash-separated relative .arcadia paths and all must be reachable. Includes resolve only against this map, never the service filesystem. Up to 256 files, 32 include edges along a path and 1,024 include edges overall. Serialized bundle JSON is at most 8 MiB, checked again after missing UUIDs are inserted. GET returns exact accepted stable text.",
|
||||
"description": "Complete source4 workspace (source3 migrates explicitly). The entry is a root filename. Files use canonical portable slash-separated relative .arcadia paths and all must be reachable. Includes resolve only against this map, never the service filesystem. Up to 256 files, 32 include edges along a path and 1,024 include edges overall. Serialized bundle JSON is at most 8 MiB, checked again after missing UUIDs are inserted. GET returns exact accepted stable text.",
|
||||
"properties": {
|
||||
"entry": {
|
||||
"type": "string"
|
||||
@@ -6267,15 +6722,149 @@
|
||||
},
|
||||
"source_project": {
|
||||
"$ref": "#/components/schemas/SourceBundle"
|
||||
},
|
||||
"migration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MigrationReport"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"revision",
|
||||
"changed",
|
||||
"issues",
|
||||
"source_project"
|
||||
"source_project",
|
||||
"migration"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExchangeItemKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Information",
|
||||
"Material",
|
||||
"Energy"
|
||||
]
|
||||
},
|
||||
"ExchangeMechanism": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Unspecified",
|
||||
"Flow",
|
||||
"Event",
|
||||
"Operation",
|
||||
"SharedData"
|
||||
]
|
||||
},
|
||||
"ParameterDirection": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Unspecified",
|
||||
"In",
|
||||
"Out",
|
||||
"InOut",
|
||||
"Return"
|
||||
]
|
||||
},
|
||||
"FieldStorage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Value",
|
||||
"Reference"
|
||||
]
|
||||
},
|
||||
"TransmissionProtocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Unspecified",
|
||||
"Synchronous",
|
||||
"Asynchronous",
|
||||
"Unicast",
|
||||
"Multicast",
|
||||
"Broadcast"
|
||||
]
|
||||
},
|
||||
"AcquisitionProtocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Unspecified",
|
||||
"Read",
|
||||
"Accept"
|
||||
]
|
||||
},
|
||||
"DataValue": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Boolean": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Boolean"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Integer": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": -9223372036854775808,
|
||||
"maximum": 9223372036854775807
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Integer"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Real": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Finite IEEE-754 binary64; no coercion from typed Integer values."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Real"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"String": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"String"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Enumeration": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Enumeration"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# C: finite typed facet reviews — executable comparison
|
||||
|
||||
Status: isolated proof, pending root freeze. Production remains at `959b83c78fcaa82b0184aade81a0508b47eb1e41`; B1 evidence is commit `8ac9f464eaf0ec813ee3ecf903404891ae2a3841`. No R6/schema/source contract changed.
|
||||
|
||||
## Result and proposed shape
|
||||
|
||||
**11 tests pass: the original 3 B1 tests plus 8 C tests.** C stores reviewed facts directly, without an own-record carrier, reviewed mask, order shadow, observation version or recursive value tree. `OwnRecord` is reused only for transient source/target candidates and existing fixture helpers. The exact types/adapter are in `src/typed_reviews.rs`; assertions are in `src/typed_reviews_tests.rs`.
|
||||
|
||||
```rust
|
||||
enum Observed<T> { NotApplicable, Value(T) }
|
||||
struct Reviewed<T> { oa: Observed<T>, sa: Observed<T> }
|
||||
struct KeyPair<K> { oa: Option<K>, sa: Option<K> }
|
||||
struct ValueKey { path: Vec<Id>, field: Id }
|
||||
struct Baseline { facets: Vec<FacetReview> }
|
||||
```
|
||||
|
||||
`FacetReview` is self-tagged: Data family leaves carry `Reviewed<Option<i64>>`, `Reviewed<Option<f64>>`, `Reviewed<Option<DataValue>>`, etc.; the tag identifies the facet. Other proof leaves are field minimum, state guard, mapped trigger/step membership, step order, atomic keyed annotations and atomic keyed bindings. Existing `MessageValue`, `LifelineBinding`, `DataValue` and `DataTypeDefinition` are reused. `T` is a concrete closed type in each variant, not an arbitrary recursively nested value.
|
||||
|
||||
An absent entry means Unreviewed. `Value(None)` means an applicable optional slot has no value (for example an unbounded Real minimum). `NotApplicable` means a selected constructor branch does not exist on that side. A mixed review keeping an SA Integer while acknowledging an OA Real records Real-minimum OA=`Value(None)`, SA=`NotApplicable`; Integer-maximum records the reverse applicability. A both-side-inapplicable leaf is rejected.
|
||||
|
||||
Recommendation remains **C for the full contract**, because facts are explicit and independent. This proof does **not** show fewer lines of implementation: see the measured cost below. B1 remains viable at restricted granularity; C removes its storage-overlap invariants rather than eliminating exhaustive adapter work.
|
||||
|
||||
## What the executable proof establishes
|
||||
|
||||
| Case | Observed assertions |
|
||||
| --- | --- |
|
||||
| Bounds/unit and field minimum | Keep acknowledges only selected facts; unselected maximum still drifts. Take maximum retains target unit/minimum. Field minimum retains target maximum. Repeat application is unchanged. |
|
||||
| Guard/triggers after deletion | Deleted source UUID remains in a typed historical key. Removal unlinks the mapped trigger but leaves its target object; extra target trigger and local guard survive. |
|
||||
| Ordered steps | Accepting membership leaves the order fact byte-for-byte unchanged. Explicit final order must contain all surviving target members exactly once. Target-only step object survives unchanged. |
|
||||
| Qualified annotation rows | Two paths with the same terminal latitude UUID remain separate. Updating/removing the first row does not acknowledge or overwrite the second row's expression. Removal acknowledges the whole selected row. |
|
||||
| Binding rows | Key is the referenced lifeline; changing `local` updates that row through frozen mapping. Other binding remains unchanged. Missing local mapping rejects. |
|
||||
| Mixed constructor group | Keep can acknowledge Real/Integer as intentionally different constructors, including correct applicable/not-applicable leaves. Take changes definition+default while preserving unit. A later explicit target switch can reconsider a previous Keep. Retained Record child blocks an incompatible switch atomically. |
|
||||
| Prepared induced changes | A real inherited-field removal causes core `prepare_command` to prune annotations. C compares the actual prepared project with the reviewed expected project and rejects these unreviewed changes. Project and baseline inputs remain unchanged. |
|
||||
| Adversarial admission | Exact duplicates, keys colliding on either side, mismatched frozen key mapping, changed pairing against old baseline, wrong-family/both-side-NA Keep, unchanged-constructor acknowledgement, invalid historical row/key combinations, empty key pairs and nonfinite values reject. Collision permutations both reject. Returned baseline is revalidated. |
|
||||
|
||||
Constructor grouping copies the definition and default in this subset; accepting a switch retires old definition facts and records the union of both resulting constructor branches. Unit stays independent. An unchanged constructor cannot be used as a shortcut to acknowledge changed bounds/defaults. Constructor and descendant decisions in one request reject as overlapping. Membership operations precede order regardless of request order.
|
||||
|
||||
Pair remapping is deliberately conservative: a new KeyPair colliding with an old baseline pair rejects with an explicit review-invalidation requirement. This probe does not implement the subsequent mapping-reset workflow or agreement-based many-to-one merges. It cannot silently accumulate a baseline that fails on its next application.
|
||||
|
||||
## Measured implementation cost
|
||||
|
||||
`measure-code.ps1` counts nonblank, non-comment rustfmt lines. B1 is read from its committed version, not a moving file. These are code-size measurements, not a normalized complexity or speed benchmark.
|
||||
|
||||
| Portion | B1 | C | Difference |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Public types/requests | 69 | 118 | +49 |
|
||||
| Adapter/admission/application | 256 | 778 | +522 |
|
||||
| Tests | 512 | 1,035 | +523 |
|
||||
|
||||
Both use the same 52 lines of basic own-record/list helpers; C does not call B1's carrier copy/compare/apply logic. C covers 18 selector cases versus B1's 9, including both keyed row kinds and all DataType constructor branches. C also adds admission and prepared-project checks absent from B1. Therefore attributing the entire difference to representation would be misleading. C's repeated capture/tag/write matches are real maintenance cost; one finite catalog should supply selector/type metadata during implementation, without introducing a general metamodel framework.
|
||||
|
||||
## Full coverage and minimal granularity recommendation
|
||||
|
||||
| Family | Facets required by the current model |
|
||||
| --- | --- |
|
||||
| Headers | Name, description, element parent. No incidental identity/alias/layer edits. |
|
||||
| OA/SA primitive bodies | No payload; Process/FunctionalChain step membership and order. |
|
||||
| ExchangeItem / Interface | Nature, mechanism, supertype; interface supertype. Allocations remain real relation records. |
|
||||
| DataType | Constructor, integer/real bounds, string lengths, enum literal membership, Record supertype, default, unit. |
|
||||
| DataField | Whole TypeRef, multiplicity bounds, unit, direction, storage, whole optional default vector. No unstable per-index default selection. |
|
||||
| Port | Contract constructor; input/output item membership; component kind/orientation; legacy direction/interface. Existing typed/Legacy resolution and split semantics remain authoritative. |
|
||||
| Scenario / Lifeline | Viewpoint, context, pre/postconditions, step membership/order; represented reference. |
|
||||
| Occurrence envelope | Constructor and separate common pre/postconditions. |
|
||||
| Exchange occurrence | Exchange, sender, receiver, message kind/Timer expression, reply correlation, annotations keyed by full `(path,field)`. |
|
||||
| Remaining occurrences | Action/action-lifeline; control kind; sequence reference; scenario reference/bindings; state/state-lifeline; fragment operator/branches; branch guard/ordered occurrences. |
|
||||
| Event / State | Event constructor and Signal items/Time kind+expression/Change expression; StateKind structural change and entry/exit/do prose. Machine/Region have no own payload. |
|
||||
| Every relation | Source and target endpoints, plus name/description. |
|
||||
| Relation payloads | Interface allocation order/protocols; association roles/multiplicities/navigation; exchange items/ports/orientation; capability-extend condition; sequence guard; transition kind/trigger+effect sets and three prose fields; StateAction phase. All other relation kinds are endpoint-only. |
|
||||
| External membership/artifacts | Direct children; incident relation identity+endpoint role; attached artifact membership and name/description/owner/relation/URI/revision/fragment. |
|
||||
|
||||
Atomic annotation and binding rows are the smallest useful contract: key changes are explicit remove/add, while all other rows remain independent. A separate expression-versus-presence checkpoint is unnecessary at this granularity. If such controls are later required, C can add separate typed facts without a second carrier state.
|
||||
|
||||
Steps/branch occurrences are ordered. Trigger alternatives, bindings and annotations must not accidentally acquire execution ordering semantics from vector position. Enum literals need string-keyed membership to preserve target-only literals; standalone whole-list `TakeSource` is explicitly rejected by this probe, pending that finite selector. A constructor conversion that would overwrite retained target enum literals also requires an explicit candidate. Field default vectors remain atomic.
|
||||
|
||||
Constructor scopes own discriminator and introduced/removed variant fields. Dependent changes outside that scope require explicit resolutions. In particular, changing Occurrence's constructor must not acknowledge its outer pre/postconditions; Call→Timer must expose exchange/reply/value effects; StateKind is semantic even though it changes `Element::kind()`. Prepared induced changes must be reviewed for every representation.
|
||||
|
||||
Current Trace endpoints/properties do not accept artifacts. Prefer attached artifact review facts owned by the semantic trace instead of silently widening trace compatibility. Missing artifact versus present artifact with `revision=None` needs distinct typed observations.
|
||||
|
||||
## Reuse the existing source decoder, with typed context
|
||||
|
||||
Baseline JSON roundtrips, including deleted historical UUIDs and mixed constructor observations. All accepted live candidates roundtrip through current Arcadia 4 source. **C baseline source syntax is not implemented or proved.**
|
||||
|
||||
The concrete reuse route is:
|
||||
|
||||
1. `language.rs::Parser::value_inner`: recognize a distinct historical `uuid "..."` AST literal. Do not convert it to an ordinary quoted string too early.
|
||||
2. `Ast::resolve`: accept an explicit slot context supplied by the finite facet catalog. Historical reference slots accept UUID literals; live reference slots keep `@alias` requirements. A new context must be entered only through the semantic-baseline subtree of a Trace declaration.
|
||||
3. Keep `resolve_declarations` and its existing `serde_json::from_value::<Trace>`/body decoding as the typed authority. Do not duplicate the live body factory/default merge logic for historical facts.
|
||||
4. `write_fields`/`write_value`: use the same slot metadata to emit historical UUIDs without current alias lookup. Generic `Reviewed<T>{oa,sa}` cannot pass through current field-name-only rules: `oa`/`sa` are currently classified as references even when T is a unit string. Renaming keys alone does not distinguish reference/string/enum types.
|
||||
5. `edit_positions`/`EditKind` and `source_project.rs::item_key`: retain full UUID token spans, historical identity remapping, and full annotation/binding keys for comment ownership.
|
||||
6. Extend finite historical validation/admission counts and explicit remap visitors; exclude baseline IDs from live dangling-reference and deletion traversal. Historical carrier/facet validation must not fetch old objects or impose cross-facet live range invariants.
|
||||
|
||||
This needs explicit schema/source migration and OpenAPI descriptions before acceptance. No source4 or data4 changes were made here.
|
||||
|
||||
## Commands, evidence, and stopping point
|
||||
|
||||
From `D:/CodexBuilds/Capella2-transition-probe`:
|
||||
|
||||
```powershell
|
||||
cargo test --manifest-path experiments/r3-facet-carrier/Cargo.toml --locked --offline --config target/r3-probe/cargo.toml --target-dir D:/CodexBuilds/Capella2-transition-probe-target -- --nocapture 2>&1 | Tee-Object -FilePath target/r3-probe/c-tests.log; exit $LASTEXITCODE
|
||||
```
|
||||
|
||||
Exit 0: **11 passed, 0 failed**, no compiler warnings. The vendor config is unchanged from the B1 report. `& experiments/r3-facet-carrier/measure-code.ps1` reproduces counts. Logs, `code-counts.json` and generated `evidence/c-*-baseline.json` / `c-*-candidate.arcadia` remain under `target/r3-probe/`.
|
||||
|
||||
Still outside this proof: complete facet catalog; reverse direction; multiple target records; historical identity remapping; mapping-reset and agreeing many-to-one resolution; unambiguous automatic ordering; source baseline parser/formatter; OpenAPI/migration; size budgets for every new fact; and production baseline+undo/revision/ETag/disk atomicity. String literal/row keys and historical UUID admission need full adversarial coverage at implementation time. No claim of full R3 implementation or acceptance is made.
|
||||
|
||||
Stop here for the root's freeze decision. No native UI or other checkout/target was used.
|
||||
Generated
+396
@@ -0,0 +1,396 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "r3-facet-carrier-probe"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"oa-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "r3-facet-carrier-probe"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
oa-core = { path = "../../crates/oa-core" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,118 @@
|
||||
# R3 facet carrier experiment
|
||||
|
||||
Status: isolated design/probe, not R3 implementation or an accepted persistence contract.
|
||||
|
||||
Follow-up: [C-REPORT.md](C-REPORT.md) contains the executable self-tagged typed-facet comparison, current recommendation, additional adversarial cases and measured code costs. This document preserves the earlier B0/B1 analysis.
|
||||
|
||||
Base: `959b83c78fcaa82b0184aade81a0508b47eb1e41`. Worktree: `D:/CodexBuilds/Capella2-transition-probe`, branch `codex/transition-probe`. The standalone crate depends on the unchanged local `oa-core`. It changes no accepted data4 shape or production code.
|
||||
|
||||
## Finding
|
||||
|
||||
Prefer **B: existing typed own-record carriers plus finite facet selectors** for further review. A carrier holds independently acknowledged values, not a coherent historical model. This removes the main reason to add immutable observation histories/cursors. One narrow extension is justified by a demonstrated overlap: store a separately acknowledged order vector for each ordered collection.
|
||||
|
||||
This is a proposal to investigate further. The probe covers three concrete cases, not every model constructor, mapping direction, source baseline format, or transition command lifecycle.
|
||||
|
||||
| Design | Benefits | Cost or failure demonstrated |
|
||||
| --- | --- | --- |
|
||||
| A: `SemanticPath(Vec<String>)` + recursive `SemanticValue` | Each leaf has independent storage; arbitrary nested values can be presented uniformly | Adds a second public model of numbers, enums, records, lists, references, and absence. Every read/write still needs an exhaustive typed adapter and closed path catalog. Wrong path/value combinations become runtime errors. Constructor groups, mapping, collection deltas, and validation remain necessary. Its flexibility supplies no demonstrated benefit for the three cases. |
|
||||
| B0: one typed carrier per side + reviewed selectors | Reuses `ElementBody`, `RelationBody`, serde validation of shapes, and existing payload types; handles partial scalar review and historical references | **Insufficient for independently acknowledged membership and order.** A new step appended to the carrier can falsely acknowledge its pending placement. |
|
||||
| B1: B0 + typed `OrderCheckpoint` | Preserves the older reviewed order while independently changing member presence; passes the counterexample | Duplicates only an order's identity vector. Other overlapping projections must be identified before adoption. |
|
||||
| Immutable observation pairs + facet cursors | Preserves actual whole-record observations and independently selects review epochs | Adds observation IDs/indexes, retention, compaction, duplicate snapshots, and cursor validation. Mixed-time carrier values are legitimate, so the tested scalar cases do not justify this machinery. Order needs only its own typed checkpoint. Rejected for now, not proved universally unnecessary. |
|
||||
|
||||
A is not intrinsically incorrect. The rejection is unnecessary public representation and validation cost for a closed typed model, not a claim that it cannot implement the semantics.
|
||||
|
||||
## Smallest public shape under test
|
||||
|
||||
The exact compiled types are in `src/lib.rs`. They deliberately leave current prose `TransitionField::{Name,Description}`, `FieldDecision::Value(String)`, and `TracedProperties` unchanged. A production extension could add `TraceBaseline.semantic: Option<SemanticBaseline>` and `TransitionField::Semantic(Facet)`; old absent semantic baselines remain Unreviewed.
|
||||
|
||||
```rust
|
||||
enum OwnRecord {
|
||||
Element { parent: Option<Id>, body: ElementBody },
|
||||
Relation { body: RelationBody },
|
||||
}
|
||||
struct MemberPair { oa: Option<Id>, sa: Option<Id> }
|
||||
struct SemanticBaseline {
|
||||
oa: OwnRecord,
|
||||
sa: OwnRecord,
|
||||
reviewed: Vec<Facet>,
|
||||
orders: Vec<OrderCheckpoint>,
|
||||
}
|
||||
struct OrderCheckpoint {
|
||||
collection: OrderedCollection,
|
||||
oa: Vec<Id>,
|
||||
sa: Vec<Id>,
|
||||
}
|
||||
enum OrderedCollection { Steps } // finite catalog to extend
|
||||
enum Facet {
|
||||
IntegerMinimum, IntegerMaximum, DataUnit, FieldMinimum,
|
||||
StateGuard, Trigger(MemberPair), Step(MemberPair), StepsOrder,
|
||||
DataConstructor,
|
||||
}
|
||||
enum Decision { TakeSource, KeepTarget, Candidate }
|
||||
struct Resolution { facet: Facet, decision: Decision }
|
||||
struct Review {
|
||||
source: Id,
|
||||
target: Id,
|
||||
candidate: Option<OwnRecord>,
|
||||
resolutions: Vec<Resolution>,
|
||||
}
|
||||
```
|
||||
|
||||
`Facet` is the closed proof subset, not a proposed complete catalog. A final catalog needs typed enum branches for element/relation scalar fields, parent/endpoints, reference members, order, and constructor scopes. It must not accept string paths. Message annotations need their existing `(path, field)` member identity; binding keys and other non-UUID members likewise need a finite typed key variant. `MemberPair` uses fixed OA/SA coordinates so reverse transition does not rename persistent selectors. At least one side must exist; conflicting pairs must be rejected. Historical pairs permit removal deltas even after a source object and its ordinary trace have been deleted.
|
||||
|
||||
All carrier types derive `PartialEq`; bodies can contain finite floats. Runtime admission must reject duplicate selectors, invalid selector/body combinations, non-finite values and excessive sizes. It must **not** impose live graph existence or cross-facet invariants on historical carriers. A baseline with independently acknowledged minimum=120 and maximum=100 is valid history of those two facets even though that combination was never a valid live range. Unreviewed slots are carrier storage, not evidence of acknowledgement.
|
||||
|
||||
`DataConstructor` is a grouped decision: the probe copies definition and default together. Production drift should compare constructor discriminants separately from descendant values; accepting a switch must refresh the complete new constructor group and retire old descendant selectors. That full catalog/acknowledgement expansion is not implemented by this subset. Constructor scope does not include unrelated unit/prose or silently delete target children.
|
||||
|
||||
A typed candidate is supplied once per target record, not a recursive merged value per leaf. The adapter selects only acknowledged facets. The probe reconstructs the expected final record and rejects a supplied candidate that changes any unselected slot. Identity/alias are absent; the existing body layer/kind must remain consistent with the explicit mapping. Full model validation still decides whether all accepted choices can coexist.
|
||||
|
||||
Preview can carry `source_before`, `source_now`, `target_before`, `target_now`, and `candidate` as `OwnRecord`, plus finite changed-facet rows. The GUI reads typed selected slots from these carriers. Historical `before` carriers must be labeled as per-facet reviewed values, never as a single past project state. Existing prose `FieldChange` can stay unchanged; a sibling semantic review payload avoids replacing its strings with a new value language.
|
||||
|
||||
## Executed cases
|
||||
|
||||
1. **Bounded data and field multiplicity.** Source integer 0..100 m becomes 120..200 cm; target remains 0..100 ft. Keep minimum and unit acknowledges those facets only. The source carrier intentionally becomes 120..100, while maximum still reports drift. Taking maximum produces target 0..200 ft. A second application is unchanged. Taking a field minimum preserves its independently larger target maximum. Replacing a target Record with Integer is rejected atomically while its target-only field remains; applying an Integer-minimum selector across Record/Integer constructors also rejects.
|
||||
2. **State guard and triggers.** Delete the old OA Event; source then points to a new Event and changes guard. Target has an extra trigger and its own guard. Keep guard, take old-trigger removal and new-trigger addition. The historical `MemberPair` still identifies the removed source reference. Result preserves target-only trigger and guard, unlinks the old mapped target trigger, and leaves the target Event object alive. Reapplication is unchanged. Baseline JSON still roundtrips the deleted UUID.
|
||||
3. **Ordered steps.** Source reverses A/B while target inserts independent step X. Take-source ordering reports that an explicit final order is needed. Candidate `[B,A]` rejects because X is missing; `[B,X,A]` passes and X's record stays byte-for-byte equal. Duplicate resolutions and a hidden parent edit reject. The same test proves the B0 counterexample: starting from `[A,B]`, independently acknowledging appended C changes the carrier to `[A,B,C]`, falsely hiding unreviewed order. B1 keeps order checkpoint `[A,B]` and reports the pending change.
|
||||
|
||||
The merge functions are intentionally small OA→SA experiments. They use real `prepare_command(Command::Batch(...))` validation before returning either the project or updated baseline, so the rejected constructor leaves both inputs intact. The baseline is outside the real Project; undo/redo, revision, ETag, fingerprint and on-disk atomic baseline integration are not claimed proved.
|
||||
|
||||
## Source and JSON parity implication
|
||||
|
||||
The tests prove serde JSON roundtrip for the new carrier and for each live candidate. Each live candidate also roundtrips through the existing `language::format` / `language::parse` typed Arcadia 4 implementation. No public arbitrary value tree or escaped JSON blob is persisted by this proposal.
|
||||
|
||||
**They do not prove persisted R3 baseline source parity.** The source language currently resolves live body reference fields through `@alias` and rejects a quoted UUID there (`language.rs`, `Ast::resolve`). A negative test transplants the deleted-Event historical body into a live declaration: its formatted source is rejected. This demonstrates the need for an explicit historical context; inventing a live stub or alias would alter semantics.
|
||||
|
||||
The smallest integration route is to reuse the existing typed body serde decoder/constructor catalog inside a new baseline-carrier source context. In that context, references should use a dedicated literal such as `uuid "..."`, resolve directly to `Id`, and bypass live-reference existence checks. Context is essential: a live `@alias` must not become valid after its object disappears, and historical carrier fields must not be pruned by ordinary deletion. The same historical visitor must explicitly remap UUIDs during real identity remapping, including member pairs and order vectors. A generic field-name-only decoder cannot distinguish these meanings.
|
||||
|
||||
The new wrapper/cursor-free types still require source constructor registration, historical token ownership/comment behavior, OpenAPI declarations, resource budgets, and an explicit schema/source migration. Accepted data4 stays unchanged in this experiment. Whether all current typed decoder logic can be reused without duplication needs a targeted follow-up inspection; this report does not assume that work is already solved.
|
||||
|
||||
## Open questions before adoption
|
||||
|
||||
- Exhaustive scalar/reference/constructor coverage and overlap audit. The proof's `DataConstructor` comparison is a group-copy shortcut; production needs separate discriminant drift and complete descendant acknowledgement. Other composite fields may need similar finite split storage, not a new arbitrary history mechanism by default.
|
||||
- Per-member edits to annotated records, bindings and repeated occurrence contributions. Stable identity is not always a single UUID. Ownership and incident-relation membership plus artifact facets need explicit typed storage outside the own body.
|
||||
- Automatic ordering when anchors are genuinely unambiguous. The probe conservatively requires an explicit order; it does not implement the contract's allowed automatic insertions.
|
||||
- New/changed mappings, many-to-one conflict agreement, reverse direction, deleted mapped targets, historic remapping, missing/ambiguous reference counterparts, and validation of client-supplied masks and member pairs.
|
||||
- Coherent constructor transitions with target-only dependents: one blocking example passed, but successful transitions and every nested constructor group still need coverage.
|
||||
- Canonical processing of membership before order and constructor groups before descendants, independent of request array order. The bounded probe consumes resolutions in request order.
|
||||
- Drift caused by shared referenced records, child membership rollups, bounds on historical membership, migration from old prose-only baselines, and production atomic baseline/history/source persistence.
|
||||
|
||||
## Reproduce and evidence
|
||||
|
||||
Run from the worktree in PowerShell. The standalone lockfile is included; the vendor configuration is generated locally and points read-only to the pre-existing vendor directory. The target belongs only to this experiment.
|
||||
|
||||
```powershell
|
||||
cargo test --manifest-path experiments/r3-facet-carrier/Cargo.toml --locked --offline --config target/r3-probe/cargo.toml --target-dir D:/CodexBuilds/Capella2-transition-probe-target -- --nocapture
|
||||
```
|
||||
|
||||
Result: **3 passed, 0 failed**. The final captured command additionally pipes output through `Tee-Object -FilePath target/r3-probe/tests.log` and exits with `$LASTEXITCODE`.
|
||||
|
||||
`target/r3-probe/cargo.toml`:
|
||||
|
||||
```toml
|
||||
[source.crates-io]
|
||||
replace-with = "vendored-sources"
|
||||
[source.vendored-sources]
|
||||
directory = "D:/CodexBuilds/Capella2-01a06e2e/vendor"
|
||||
```
|
||||
|
||||
Evidence is under `target/r3-probe/`: `tests.log`, and generated `evidence/*-baseline.json` / `*-candidate.arcadia` for mixed range, deleted trigger and target-only step. Generated IDs differ between runs; the asserted behavior does not depend on them. No native UI was used, no package installed, and no other checkout or build target was changed.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Run from the isolated worktree root. Counts rustfmt lines, not complexity.
|
||||
$r3B = @(git show 8ac9f46:experiments/r3-facet-carrier/src/lib.rs)
|
||||
$r3C = @(Get-Content -LiteralPath experiments/r3-facet-carrier/src/typed_reviews.rs)
|
||||
$r3Tests = @(Get-Content -LiteralPath experiments/r3-facet-carrier/src/typed_reviews_tests.rs)
|
||||
function Count-R3Lines($Lines) {
|
||||
@($Lines | Where-Object { $_.Trim().Length -gt 0 -and -not $_.Trim().StartsWith('//') }).Count
|
||||
}
|
||||
function Find-R3Line($Lines, $Prefix) {
|
||||
for ($r3Index = 0; $r3Index -lt $Lines.Length; $r3Index++) {
|
||||
if ($Lines[$r3Index].StartsWith($Prefix)) { return $r3Index }
|
||||
}
|
||||
throw "Missing section marker: $Prefix"
|
||||
}
|
||||
$r3BOwn = Find-R3Line $r3B 'pub fn own('
|
||||
$r3BCopy = Find-R3Line $r3B 'fn copy('
|
||||
$r3BTests = Find-R3Line $r3B '#[cfg(test)]'
|
||||
$r3CAdapter = Find-R3Line $r3C '// ADAPTER:'
|
||||
$r3CTestStart = Find-R3Line $r3C '#[cfg(test)]'
|
||||
[ordered]@{
|
||||
method = 'Nonblank non-comment rustfmt lines; excludes test module registration'
|
||||
b_public = Count-R3Lines $r3B[5..($r3BOwn-1)]
|
||||
shared_record_helpers = Count-R3Lines $r3B[$r3BOwn..($r3BCopy-1)]
|
||||
b_adapter = Count-R3Lines $r3B[$r3BCopy..($r3BTests-1)]
|
||||
b_tests = Count-R3Lines $r3B[($r3BTests+1)..($r3B.Length-1)]
|
||||
c_public = Count-R3Lines $r3C[5..($r3CAdapter-1)]
|
||||
c_adapter = Count-R3Lines $r3C[$r3CAdapter..($r3CTestStart-1)]
|
||||
c_tests = Count-R3Lines $r3Tests
|
||||
} | ConvertTo-Json
|
||||
@@ -0,0 +1,934 @@
|
||||
//! Throwaway R3 experiment. Nothing in oa-core depends on this crate.
|
||||
//! A carrier stores independently reviewed facets; it is NOT a past valid model.
|
||||
use oa_core::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub mod typed_reviews;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum OwnRecord {
|
||||
Element {
|
||||
parent: Option<Id>,
|
||||
body: ElementBody,
|
||||
},
|
||||
Relation {
|
||||
body: RelationBody,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemberPair {
|
||||
pub oa: Option<Id>,
|
||||
pub sa: Option<Id>,
|
||||
}
|
||||
|
||||
/// Deliberately finite proof subset, not the final exhaustive R3 catalog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Facet {
|
||||
IntegerMinimum,
|
||||
IntegerMaximum,
|
||||
DataUnit,
|
||||
FieldMinimum,
|
||||
StateGuard,
|
||||
Trigger(MemberPair),
|
||||
Step(MemberPair),
|
||||
StepsOrder,
|
||||
/// Definition AND default form one explicit constructor replacement group.
|
||||
DataConstructor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SemanticBaseline {
|
||||
pub oa: OwnRecord,
|
||||
pub sa: OwnRecord,
|
||||
pub reviewed: Vec<Facet>,
|
||||
pub orders: Vec<OrderCheckpoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OrderedCollection {
|
||||
Steps,
|
||||
}
|
||||
|
||||
/// Order overlaps membership in the live Vec, so it needs a separate review value.
|
||||
/// No observation IDs, history versions, or generic value representation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OrderCheckpoint {
|
||||
pub collection: OrderedCollection,
|
||||
pub oa: Vec<Id>,
|
||||
pub sa: Vec<Id>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Decision {
|
||||
TakeSource,
|
||||
KeepTarget,
|
||||
Candidate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Resolution {
|
||||
pub facet: Facet,
|
||||
pub decision: Decision,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Review {
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub candidate: Option<OwnRecord>,
|
||||
pub resolutions: Vec<Resolution>,
|
||||
}
|
||||
|
||||
pub fn own(p: &Project, id: Id) -> OwnRecord {
|
||||
if let Some(e) = p.elements.get(&id) {
|
||||
OwnRecord::Element {
|
||||
parent: e.parent,
|
||||
body: e.body.clone(),
|
||||
}
|
||||
} else {
|
||||
OwnRecord::Relation {
|
||||
body: p.relations[&id].body.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn shared(record: &OwnRecord) -> Result<&SharedBody, String> {
|
||||
match record {
|
||||
OwnRecord::Element { body, .. } => body.shared().ok_or("Not a shared body".into()),
|
||||
_ => Err("Not an element".into()),
|
||||
}
|
||||
}
|
||||
fn shared_mut(record: &mut OwnRecord) -> Result<&mut SharedBody, String> {
|
||||
match record {
|
||||
OwnRecord::Element { body, .. } => body.shared_mut().ok_or("Not a shared body".into()),
|
||||
_ => Err("Not an element".into()),
|
||||
}
|
||||
}
|
||||
fn list(record: &OwnRecord, trigger: bool) -> Result<&Vec<Id>, String> {
|
||||
match (record, trigger) {
|
||||
(
|
||||
OwnRecord::Relation {
|
||||
body: RelationBody::StateTransition { triggers, .. },
|
||||
},
|
||||
true,
|
||||
) => Ok(triggers),
|
||||
(OwnRecord::Element { body, .. }, false) => {
|
||||
body.steps().ok_or("Not an ordered sequence".into())
|
||||
}
|
||||
_ => Err("Wrong member selector".into()),
|
||||
}
|
||||
}
|
||||
fn list_mut(record: &mut OwnRecord, trigger: bool) -> Result<&mut Vec<Id>, String> {
|
||||
match (record, trigger) {
|
||||
(
|
||||
OwnRecord::Relation {
|
||||
body: RelationBody::StateTransition { triggers, .. },
|
||||
},
|
||||
true,
|
||||
) => Ok(triggers),
|
||||
(OwnRecord::Element { body, .. }, false) => {
|
||||
body.steps_mut().ok_or("Not an ordered sequence".into())
|
||||
}
|
||||
_ => Err("Wrong member selector".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy same-coordinate facet; mapped reference proposals use copy_member below.
|
||||
fn copy(dst: &mut OwnRecord, src: &OwnRecord, facet: &Facet, oa: bool) -> Result<(), String> {
|
||||
match facet {
|
||||
Facet::IntegerMinimum | Facet::IntegerMaximum => {
|
||||
let SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { minimum, maximum },
|
||||
..
|
||||
} = shared(src)?
|
||||
else {
|
||||
return Err("Explicit constructor decision required".into());
|
||||
};
|
||||
let (minimum, maximum) = (*minimum, *maximum);
|
||||
let SharedBody::DataType {
|
||||
definition:
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: dst_min,
|
||||
maximum: dst_max,
|
||||
},
|
||||
..
|
||||
} = shared_mut(dst)?
|
||||
else {
|
||||
return Err("Explicit constructor decision required".into());
|
||||
};
|
||||
if *facet == Facet::IntegerMinimum {
|
||||
*dst_min = minimum;
|
||||
} else {
|
||||
*dst_max = maximum;
|
||||
}
|
||||
}
|
||||
Facet::DataUnit => {
|
||||
let SharedBody::DataType { unit, .. } = shared(src)? else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
let unit = unit.clone();
|
||||
let SharedBody::DataType { unit: dst_unit, .. } = shared_mut(dst)? else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
*dst_unit = unit;
|
||||
}
|
||||
Facet::FieldMinimum => {
|
||||
let SharedBody::DataField { minimum, .. } = shared(src)? else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
let minimum = *minimum;
|
||||
let SharedBody::DataField {
|
||||
minimum: dst_min, ..
|
||||
} = shared_mut(dst)?
|
||||
else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
*dst_min = minimum;
|
||||
}
|
||||
Facet::StateGuard => {
|
||||
let OwnRecord::Relation {
|
||||
body: RelationBody::StateTransition { guard, .. },
|
||||
} = src
|
||||
else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
let OwnRecord::Relation {
|
||||
body:
|
||||
RelationBody::StateTransition {
|
||||
guard: dst_guard, ..
|
||||
},
|
||||
} = dst
|
||||
else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
*dst_guard = guard.clone();
|
||||
}
|
||||
Facet::Trigger(pair) | Facet::Step(pair) => {
|
||||
let id = if oa { pair.oa } else { pair.sa };
|
||||
copy_member(dst, src, matches!(facet, Facet::Trigger(_)), id, id)?;
|
||||
}
|
||||
Facet::StepsOrder => *list_mut(dst, false)? = list(src, false)?.clone(),
|
||||
Facet::DataConstructor => {
|
||||
let SharedBody::DataType {
|
||||
definition,
|
||||
default,
|
||||
..
|
||||
} = shared(src)?
|
||||
else {
|
||||
return Err("Wrong constructor scope".into());
|
||||
};
|
||||
let (definition, default) = (definition.clone(), default.clone());
|
||||
let SharedBody::DataType {
|
||||
definition: dst_definition,
|
||||
default: dst_default,
|
||||
..
|
||||
} = shared_mut(dst)?
|
||||
else {
|
||||
return Err("Wrong constructor scope".into());
|
||||
};
|
||||
*dst_definition = definition;
|
||||
*dst_default = default;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn copy_member(
|
||||
dst: &mut OwnRecord,
|
||||
src: &OwnRecord,
|
||||
trigger: bool,
|
||||
src_id: Option<Id>,
|
||||
dst_id: Option<Id>,
|
||||
) -> Result<(), String> {
|
||||
if src_id.is_none() && dst_id.is_none() {
|
||||
return Err("Empty member identity".into());
|
||||
}
|
||||
let present =
|
||||
src_id.is_some_and(|id| list(src, trigger).is_ok_and(|items| items.contains(&id)));
|
||||
let Some(dst_id) = dst_id else {
|
||||
return if present {
|
||||
Err("Missing counterpart mapping".into())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
};
|
||||
let items = list_mut(dst, trigger)?;
|
||||
if present && !items.contains(&dst_id) {
|
||||
items.push(dst_id);
|
||||
}
|
||||
if !present {
|
||||
items.retain(|id| *id != dst_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn equal(a: &OwnRecord, b: &OwnRecord, facet: &Facet, oa: bool) -> Result<bool, String> {
|
||||
let mut projected = a.clone();
|
||||
copy(&mut projected, b, facet, oa)?;
|
||||
Ok(projected == *a)
|
||||
}
|
||||
pub fn reviewed_changed(
|
||||
base: &SemanticBaseline,
|
||||
now: &OwnRecord,
|
||||
facet: &Facet,
|
||||
oa: bool,
|
||||
) -> Result<Option<bool>, String> {
|
||||
if !base.reviewed.contains(facet) {
|
||||
return Ok(None);
|
||||
}
|
||||
if *facet == Facet::StepsOrder {
|
||||
let checkpoint = base
|
||||
.orders
|
||||
.iter()
|
||||
.find(|o| o.collection == OrderedCollection::Steps)
|
||||
.ok_or("Missing order checkpoint")?;
|
||||
return Ok(Some(
|
||||
list(now, false)? != if oa { &checkpoint.oa } else { &checkpoint.sa },
|
||||
));
|
||||
}
|
||||
equal(if oa { &base.oa } else { &base.sa }, now, facet, oa).map(|same| Some(!same))
|
||||
}
|
||||
|
||||
/// One validated core candidate, then baseline assignment. No partial commit.
|
||||
/// OA→SA only in this bounded experiment.
|
||||
pub fn apply(
|
||||
p: &Project,
|
||||
base: &SemanticBaseline,
|
||||
review: &Review,
|
||||
) -> Result<(Project, SemanticBaseline), String> {
|
||||
let source = own(p, review.source);
|
||||
let current = own(p, review.target);
|
||||
let mut target = current.clone();
|
||||
let mut acknowledged = base.clone();
|
||||
let mut seen = Vec::new();
|
||||
if review
|
||||
.resolutions
|
||||
.iter()
|
||||
.any(|r| r.facet == Facet::DataConstructor)
|
||||
&& review
|
||||
.resolutions
|
||||
.iter()
|
||||
.any(|r| matches!(r.facet, Facet::IntegerMinimum | Facet::IntegerMaximum))
|
||||
{
|
||||
return Err("Constructor and descendant facets overlap".into());
|
||||
}
|
||||
for resolution in &review.resolutions {
|
||||
if seen.contains(&resolution.facet) {
|
||||
return Err("Duplicate facet".into());
|
||||
}
|
||||
seen.push(resolution.facet.clone());
|
||||
match resolution.decision {
|
||||
Decision::KeepTarget => {}
|
||||
Decision::TakeSource => match &resolution.facet {
|
||||
Facet::Trigger(pair) | Facet::Step(pair) => copy_member(
|
||||
&mut target,
|
||||
&source,
|
||||
matches!(resolution.facet, Facet::Trigger(_)),
|
||||
pair.oa,
|
||||
pair.sa,
|
||||
)?,
|
||||
Facet::StepsOrder => {
|
||||
return Err("This probe requires an explicit final order".into());
|
||||
}
|
||||
facet => copy(&mut target, &source, facet, false)?,
|
||||
},
|
||||
Decision::Candidate => {
|
||||
let candidate = review.candidate.as_ref().ok_or("Missing candidate")?;
|
||||
if resolution.facet == Facet::StepsOrder {
|
||||
let mut expected = list(&target, false)?.clone();
|
||||
let mut proposed = list(candidate, false)?.clone();
|
||||
expected.sort();
|
||||
proposed.sort();
|
||||
if expected != proposed || proposed.windows(2).any(|p| p[0] == p[1]) {
|
||||
return Err(
|
||||
"Final order must contain every surviving target member exactly once"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
copy(&mut target, candidate, &resolution.facet, false)?;
|
||||
}
|
||||
}
|
||||
if resolution.facet == Facet::DataConstructor {
|
||||
acknowledged.reviewed.retain(|f| {
|
||||
!matches!(
|
||||
f,
|
||||
Facet::IntegerMinimum | Facet::IntegerMaximum | Facet::DataConstructor
|
||||
)
|
||||
});
|
||||
}
|
||||
if resolution.facet == Facet::StepsOrder {
|
||||
acknowledged
|
||||
.orders
|
||||
.retain(|o| o.collection != OrderedCollection::Steps);
|
||||
acknowledged.orders.push(OrderCheckpoint {
|
||||
collection: OrderedCollection::Steps,
|
||||
oa: list(&source, false)?.clone(),
|
||||
sa: list(&target, false)?.clone(),
|
||||
});
|
||||
} else {
|
||||
copy(&mut acknowledged.oa, &source, &resolution.facet, true)?;
|
||||
copy(&mut acknowledged.sa, &target, &resolution.facet, false)?;
|
||||
}
|
||||
if !acknowledged.reviewed.contains(&resolution.facet) {
|
||||
acknowledged.reviewed.push(resolution.facet.clone());
|
||||
}
|
||||
}
|
||||
// Explicit candidate cannot smuggle changes to any unreviewed facet.
|
||||
if let Some(candidate) = &review.candidate {
|
||||
if *candidate != target {
|
||||
return Err("Candidate changes an unselected facet".into());
|
||||
}
|
||||
}
|
||||
let command = match target {
|
||||
OwnRecord::Element { parent, body } => {
|
||||
let mut e = p.elements[&review.target].clone();
|
||||
e.parent = parent;
|
||||
e.body = body;
|
||||
Command::EditElement(review.target, e)
|
||||
}
|
||||
OwnRecord::Relation { body } => {
|
||||
let mut r = p.relations[&review.target].clone();
|
||||
r.body = body;
|
||||
Command::EditRelation(review.target, r)
|
||||
}
|
||||
};
|
||||
let prepared = prepare_command(p, &Command::Batch(vec![command])).map_err(|e| e.to_string())?;
|
||||
Ok((prepared.project, acknowledged))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
pub(crate) fn add(
|
||||
p: &mut Project,
|
||||
kind: Kind,
|
||||
layer: Layer,
|
||||
name: &str,
|
||||
parent: Option<Id>,
|
||||
) -> Id {
|
||||
p.add_element(kind, layer, name, parent).unwrap()
|
||||
}
|
||||
pub(crate) fn set(p: &mut Project, id: Id, body: SharedBody) {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
|
||||
}
|
||||
pub(crate) fn valid(p: &Project) {
|
||||
let errors: Vec<_> = validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn baseline(p: &Project, oa: Id, sa: Id, reviewed: Vec<Facet>) -> SemanticBaseline {
|
||||
let orders = if reviewed.contains(&Facet::StepsOrder) {
|
||||
vec![OrderCheckpoint {
|
||||
collection: OrderedCollection::Steps,
|
||||
oa: list(&own(p, oa), false).unwrap().clone(),
|
||||
sa: list(&own(p, sa), false).unwrap().clone(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
SemanticBaseline {
|
||||
oa: own(p, oa),
|
||||
sa: own(p, sa),
|
||||
reviewed,
|
||||
orders,
|
||||
}
|
||||
}
|
||||
fn resolution(facet: Facet, decision: Decision) -> Resolution {
|
||||
Resolution { facet, decision }
|
||||
}
|
||||
fn roundtrip(p: &Project, base: &SemanticBaseline) {
|
||||
let json = serde_json::to_string(base).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SemanticBaseline>(&json).unwrap(),
|
||||
*base
|
||||
);
|
||||
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Project>(&serde_json::to_string(p).unwrap()).unwrap(),
|
||||
*p
|
||||
);
|
||||
}
|
||||
fn evidence(name: &str, p: &Project, base: &SemanticBaseline) {
|
||||
let folder =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/r3-probe/evidence");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(
|
||||
folder.join(format!("{name}-baseline.json")),
|
||||
serde_json::to_string_pretty(base).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
folder.join(format!("{name}-candidate.arcadia")),
|
||||
language::format(p),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
pub(crate) fn integer(minimum: i64, maximum: i64, unit: &str) -> SharedBody {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(minimum),
|
||||
maximum: Some(maximum),
|
||||
},
|
||||
default: None,
|
||||
unit: unit.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_data_partial_keep_take_and_constructor_atomicity() {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::DataType, Layer::Oa, "OA bounded", None);
|
||||
let sa = add(&mut p, Kind::DataType, Layer::Sa, "SA bounded", None);
|
||||
set(&mut p, oa, integer(0, 100, "m"));
|
||||
set(&mut p, sa, integer(0, 100, "ft"));
|
||||
let base = baseline(
|
||||
&p,
|
||||
oa,
|
||||
sa,
|
||||
vec![
|
||||
Facet::IntegerMinimum,
|
||||
Facet::IntegerMaximum,
|
||||
Facet::DataUnit,
|
||||
],
|
||||
);
|
||||
set(&mut p, oa, integer(120, 200, "cm"));
|
||||
let review = Review {
|
||||
source: oa,
|
||||
target: sa,
|
||||
candidate: None,
|
||||
resolutions: vec![
|
||||
resolution(Facet::IntegerMinimum, Decision::KeepTarget),
|
||||
resolution(Facet::DataUnit, Decision::KeepTarget),
|
||||
],
|
||||
};
|
||||
let (kept, mixed) = apply(&p, &base, &review).unwrap();
|
||||
assert_eq!(kept, p);
|
||||
// OA baseline now min=120,max=100. This is intentional: fields were reviewed independently.
|
||||
assert!(matches!(
|
||||
shared(&mixed.oa).unwrap(),
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(120),
|
||||
maximum: Some(100)
|
||||
},
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMinimum, true).unwrap(),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMaximum, true).unwrap(),
|
||||
Some(true)
|
||||
);
|
||||
let take = Review {
|
||||
source: oa,
|
||||
target: sa,
|
||||
candidate: None,
|
||||
resolutions: vec![resolution(Facet::IntegerMaximum, Decision::TakeSource)],
|
||||
};
|
||||
let (p, merged) = apply(&p, &mixed, &take).unwrap();
|
||||
assert!(
|
||||
matches!(shared(&own(&p, sa)).unwrap(), SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(200) }, unit, .. } if unit == "ft")
|
||||
);
|
||||
let (again, same) = apply(&p, &merged, &take).unwrap();
|
||||
assert_eq!(again, p);
|
||||
assert_eq!(same, merged);
|
||||
roundtrip(&p, &mixed);
|
||||
evidence("mixed-range", &p, &mixed);
|
||||
|
||||
let mut fields = p;
|
||||
let owner_oa = add(&mut fields, Kind::DataType, Layer::Oa, "OA record", None);
|
||||
let owner_sa = add(&mut fields, Kind::DataType, Layer::Sa, "SA record", None);
|
||||
for id in [owner_oa, owner_sa] {
|
||||
set(
|
||||
&mut fields,
|
||||
id,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let field_oa = add(
|
||||
&mut fields,
|
||||
Kind::DataField,
|
||||
Layer::Oa,
|
||||
"OA field",
|
||||
Some(owner_oa),
|
||||
);
|
||||
let field_sa = add(
|
||||
&mut fields,
|
||||
Kind::DataField,
|
||||
Layer::Sa,
|
||||
"SA field",
|
||||
Some(owner_sa),
|
||||
);
|
||||
for (id, max) in [(field_oa, 5), (field_sa, 9)] {
|
||||
set(
|
||||
&mut fields,
|
||||
id,
|
||||
SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(if id == field_oa { oa } else { sa }),
|
||||
minimum: 0,
|
||||
maximum: Some(max),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let field_base = baseline(&fields, field_oa, field_sa, vec![Facet::FieldMinimum]);
|
||||
if let Some(SharedBody::DataField { minimum, .. }) =
|
||||
fields.elements.get_mut(&field_oa).unwrap().shared_mut()
|
||||
{
|
||||
*minimum = 2;
|
||||
}
|
||||
let (fields, field_base) = apply(
|
||||
&fields,
|
||||
&field_base,
|
||||
&Review {
|
||||
source: field_oa,
|
||||
target: field_sa,
|
||||
candidate: None,
|
||||
resolutions: vec![resolution(Facet::FieldMinimum, Decision::TakeSource)],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
fields.elements[&field_sa].shared(),
|
||||
Some(SharedBody::DataField {
|
||||
minimum: 2,
|
||||
maximum: Some(9),
|
||||
..
|
||||
})
|
||||
));
|
||||
roundtrip(&fields, &field_base);
|
||||
|
||||
let mut switched = fields.clone();
|
||||
let constructor_base = baseline(&switched, owner_oa, owner_sa, vec![]);
|
||||
// Remove only the source's child; the target-only field remains real independent work.
|
||||
switched = prepare_command(&switched, &Command::Delete(field_oa))
|
||||
.unwrap()
|
||||
.project;
|
||||
set(&mut switched, owner_oa, integer(0, 10, ""));
|
||||
valid(&switched);
|
||||
let before = switched.clone();
|
||||
let before_base = constructor_base.clone();
|
||||
let result = apply(
|
||||
&switched,
|
||||
&constructor_base,
|
||||
&Review {
|
||||
source: owner_oa,
|
||||
target: owner_sa,
|
||||
candidate: None,
|
||||
resolutions: vec![resolution(Facet::DataConstructor, Decision::TakeSource)],
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"target Record child blocks primitive constructor switch"
|
||||
);
|
||||
assert_eq!(switched, before);
|
||||
assert_eq!(constructor_base, before_base);
|
||||
let forbidden = apply(
|
||||
&switched,
|
||||
&constructor_base,
|
||||
&Review {
|
||||
source: owner_oa,
|
||||
target: owner_sa,
|
||||
candidate: None,
|
||||
resolutions: vec![resolution(Facet::IntegerMinimum, Decision::TakeSource)],
|
||||
},
|
||||
);
|
||||
assert!(forbidden.unwrap_err().contains("constructor"));
|
||||
println!(
|
||||
"bounded data: mixed historical min/max permitted; unselected drift retained; target max/unit preserved; constructor candidate rejected atomically"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn state_side(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) {
|
||||
let owner = add(
|
||||
p,
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
&format!("{prefix} owner"),
|
||||
None,
|
||||
);
|
||||
let machine = add(
|
||||
p,
|
||||
Kind::StateMachine,
|
||||
layer,
|
||||
&format!("{prefix} machine"),
|
||||
Some(owner),
|
||||
);
|
||||
let region = add(
|
||||
p,
|
||||
Kind::Region,
|
||||
layer,
|
||||
&format!("{prefix} region"),
|
||||
Some(machine),
|
||||
);
|
||||
let a = add(p, Kind::State, layer, &format!("{prefix} A"), Some(region));
|
||||
let b = add(p, Kind::State, layer, &format!("{prefix} B"), Some(region));
|
||||
let old = add(p, Kind::Event, layer, &format!("{prefix} old event"), None);
|
||||
let new = add(p, Kind::Event, layer, &format!("{prefix} new event"), None);
|
||||
let relation = p.relate(
|
||||
RelationKind::StateTransition,
|
||||
&format!("{prefix} transition"),
|
||||
a,
|
||||
b,
|
||||
);
|
||||
if let RelationBody::StateTransition {
|
||||
triggers, guard, ..
|
||||
} = &mut p.relations.get_mut(&relation).unwrap().body
|
||||
{
|
||||
*triggers = vec![old];
|
||||
*guard = "ready".into();
|
||||
}
|
||||
(relation, old, new)
|
||||
}
|
||||
#[test]
|
||||
fn state_guard_and_trigger_delta_retain_deleted_historical_identity() {
|
||||
let mut p = Project::empty();
|
||||
let (oa, old_oa, new_oa) = state_side(&mut p, Layer::Oa, "OA");
|
||||
let (sa, old_sa, new_sa) = state_side(&mut p, Layer::Sa, "SA");
|
||||
let extra = add(&mut p, Kind::Event, Layer::Sa, "Target only", None);
|
||||
let old = MemberPair {
|
||||
oa: Some(old_oa),
|
||||
sa: Some(old_sa),
|
||||
};
|
||||
let new = MemberPair {
|
||||
oa: Some(new_oa),
|
||||
sa: Some(new_sa),
|
||||
};
|
||||
let base = baseline(
|
||||
&p,
|
||||
oa,
|
||||
sa,
|
||||
vec![Facet::StateGuard, Facet::Trigger(old), Facet::Trigger(new)],
|
||||
);
|
||||
p = prepare_command(&p, &Command::Delete(old_oa))
|
||||
.unwrap()
|
||||
.project;
|
||||
if let RelationBody::StateTransition {
|
||||
triggers, guard, ..
|
||||
} = &mut p.relations.get_mut(&oa).unwrap().body
|
||||
{
|
||||
*triggers = vec![new_oa];
|
||||
*guard = "armed".into();
|
||||
}
|
||||
if let RelationBody::StateTransition {
|
||||
triggers, guard, ..
|
||||
} = &mut p.relations.get_mut(&sa).unwrap().body
|
||||
{
|
||||
triggers.push(extra);
|
||||
*guard = "local inhibit clear".into();
|
||||
}
|
||||
valid(&p);
|
||||
assert!(!p.elements.contains_key(&old_oa));
|
||||
assert!(list(&base.oa, true).unwrap().contains(&old_oa));
|
||||
// Current source uses live @alias semantics. A historical body needs a
|
||||
// distinct source context, not transplantation into a live declaration.
|
||||
let mut historical_as_live = p.clone();
|
||||
if let OwnRecord::Relation { body } = &base.oa {
|
||||
historical_as_live.relations.get_mut(&oa).unwrap().body = body.clone();
|
||||
}
|
||||
let rejected = language::parse(&language::format(&historical_as_live));
|
||||
assert!(
|
||||
rejected.is_err(),
|
||||
"deleted historical UUID is not a live alias"
|
||||
);
|
||||
let review = Review {
|
||||
source: oa,
|
||||
target: sa,
|
||||
candidate: None,
|
||||
resolutions: vec![
|
||||
resolution(Facet::StateGuard, Decision::KeepTarget),
|
||||
resolution(Facet::Trigger(old), Decision::TakeSource),
|
||||
resolution(Facet::Trigger(new), Decision::TakeSource),
|
||||
],
|
||||
};
|
||||
let (p, acknowledged) = apply(&p, &base, &review).unwrap();
|
||||
assert_eq!(list(&own(&p, sa), true).unwrap(), &vec![extra, new_sa]);
|
||||
assert!(p.elements.contains_key(&old_sa));
|
||||
assert!(
|
||||
matches!(&p.relations[&sa].body, RelationBody::StateTransition { guard, .. } if guard == "local inhibit clear")
|
||||
);
|
||||
roundtrip(&p, &base);
|
||||
roundtrip(&p, &acknowledged);
|
||||
evidence("deleted-trigger", &p, &base);
|
||||
let (again, same) = apply(&p, &acknowledged, &review).unwrap();
|
||||
assert_eq!(again, p);
|
||||
assert_eq!(same, acknowledged);
|
||||
println!(
|
||||
"state: old source Event deleted; historical member pair still removes its target link, preserves target object and extra trigger, keeps guard"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn sequence(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) {
|
||||
let sequence = add(
|
||||
p,
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
prefix,
|
||||
None,
|
||||
);
|
||||
let a = add(
|
||||
p,
|
||||
Kind::Occurrence,
|
||||
layer,
|
||||
&format!("{prefix} A"),
|
||||
Some(sequence),
|
||||
);
|
||||
let b = add(
|
||||
p,
|
||||
Kind::Occurrence,
|
||||
layer,
|
||||
&format!("{prefix} B"),
|
||||
Some(sequence),
|
||||
);
|
||||
for id in [a, b] {
|
||||
set(
|
||||
p,
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
*p.elements.get_mut(&sequence).unwrap().steps_mut().unwrap() = vec![a, b];
|
||||
(sequence, a, b)
|
||||
}
|
||||
#[test]
|
||||
fn ordered_steps_preserve_target_only_members_and_reject_hidden_edits() {
|
||||
let mut p = Project::empty();
|
||||
let (oa, oa_a, oa_b) = sequence(&mut p, Layer::Oa, "OA flow");
|
||||
let (sa, sa_a, sa_b) = sequence(&mut p, Layer::Sa, "SA flow");
|
||||
let base = baseline(&p, oa, sa, vec![Facet::StepsOrder]);
|
||||
// Counterexample: a bare carrier loses pending placement review after
|
||||
// independently acknowledging append membership. The order checkpoint
|
||||
// keeps the exact older order and therefore retains the drift.
|
||||
let oa_c = Id::new_v4();
|
||||
let sa_c = Id::new_v4();
|
||||
let mut source_with_append = own(&p, oa);
|
||||
list_mut(&mut source_with_append, false).unwrap().push(oa_c);
|
||||
let mut membership_only = base.clone();
|
||||
copy(
|
||||
&mut membership_only.oa,
|
||||
&source_with_append,
|
||||
&Facet::Step(MemberPair {
|
||||
oa: Some(oa_c),
|
||||
sa: Some(sa_c),
|
||||
}),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
membership_only.oa, source_with_append,
|
||||
"bare carrier would falsely report no order drift"
|
||||
);
|
||||
assert_eq!(
|
||||
reviewed_changed(
|
||||
&membership_only,
|
||||
&source_with_append,
|
||||
&Facet::StepsOrder,
|
||||
true
|
||||
)
|
||||
.unwrap(),
|
||||
Some(true)
|
||||
);
|
||||
let extra = add(
|
||||
&mut p,
|
||||
Kind::Occurrence,
|
||||
Layer::Sa,
|
||||
"Target only step",
|
||||
Some(sa),
|
||||
);
|
||||
set(
|
||||
&mut p,
|
||||
extra,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control {
|
||||
kind: ControlKind::Merge,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
},
|
||||
);
|
||||
*p.elements.get_mut(&sa).unwrap().steps_mut().unwrap() = vec![sa_a, extra, sa_b];
|
||||
*p.elements.get_mut(&oa).unwrap().steps_mut().unwrap() = vec![oa_b, oa_a];
|
||||
valid(&p);
|
||||
let take = Review {
|
||||
source: oa,
|
||||
target: sa,
|
||||
candidate: None,
|
||||
resolutions: vec![resolution(Facet::StepsOrder, Decision::TakeSource)],
|
||||
};
|
||||
assert!(
|
||||
apply(&p, &base, &take)
|
||||
.unwrap_err()
|
||||
.contains("explicit final order")
|
||||
);
|
||||
let mut candidate = own(&p, sa);
|
||||
*list_mut(&mut candidate, false).unwrap() = vec![sa_b, sa_a];
|
||||
let mut review = Review {
|
||||
candidate: Some(candidate),
|
||||
resolutions: vec![resolution(Facet::StepsOrder, Decision::Candidate)],
|
||||
..take
|
||||
};
|
||||
assert!(
|
||||
apply(&p, &base, &review)
|
||||
.unwrap_err()
|
||||
.contains("every surviving")
|
||||
);
|
||||
*list_mut(review.candidate.as_mut().unwrap(), false).unwrap() = vec![sa_b, extra, sa_a];
|
||||
let target_only = p.elements[&extra].clone();
|
||||
let (p, acknowledged) = apply(&p, &base, &review).unwrap();
|
||||
assert_eq!(list(&own(&p, sa), false).unwrap(), &vec![sa_b, extra, sa_a]);
|
||||
assert_eq!(p.elements[&extra], target_only);
|
||||
roundtrip(&p, &acknowledged);
|
||||
evidence("target-only-step", &p, &acknowledged);
|
||||
let mut duplicate = review.clone();
|
||||
duplicate.resolutions.push(duplicate.resolutions[0].clone());
|
||||
assert!(
|
||||
apply(&p, &acknowledged, &duplicate)
|
||||
.unwrap_err()
|
||||
.contains("Duplicate")
|
||||
);
|
||||
let mut hidden = review;
|
||||
if let Some(OwnRecord::Element { parent, .. }) = &mut hidden.candidate {
|
||||
*parent = Some(extra);
|
||||
}
|
||||
assert!(
|
||||
apply(&p, &acknowledged, &hidden)
|
||||
.unwrap_err()
|
||||
.contains("unselected")
|
||||
);
|
||||
println!(
|
||||
"steps: ambiguous source reorder demands explicit candidate; missing target-only member and unselected parent edits reject"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
//! C: closed typed reviewed facts. Whole records are transient candidates only.
|
||||
use crate::{Decision, OwnRecord, list, list_mut, own, shared, shared_mut};
|
||||
use oa_core::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// PUBLIC TYPES: persisted facts have no carrier, review mask or shadow order.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Observed<T> {
|
||||
NotApplicable,
|
||||
Value(T),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Reviewed<T> {
|
||||
pub oa: Observed<T>,
|
||||
pub sa: Observed<T>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyPair<K> {
|
||||
pub oa: Option<K>,
|
||||
pub sa: Option<K>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ValueKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataConstructor {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration,
|
||||
Record,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataFacet {
|
||||
Constructor,
|
||||
IntegerMinimum,
|
||||
IntegerMaximum,
|
||||
RealMinimum,
|
||||
RealMaximum,
|
||||
MinimumLength,
|
||||
MaximumLength,
|
||||
Literals,
|
||||
Supertype,
|
||||
Default,
|
||||
Unit,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DataReview {
|
||||
Constructor(Reviewed<DataConstructor>),
|
||||
IntegerMinimum(Reviewed<Option<i64>>),
|
||||
IntegerMaximum(Reviewed<Option<i64>>),
|
||||
RealMinimum(Reviewed<Option<f64>>),
|
||||
RealMaximum(Reviewed<Option<f64>>),
|
||||
MinimumLength(Reviewed<u32>),
|
||||
MaximumLength(Reviewed<Option<u32>>),
|
||||
Literals(Reviewed<Vec<String>>),
|
||||
Supertype(Reviewed<Option<Id>>),
|
||||
Default(Reviewed<Option<DataValue>>),
|
||||
Unit(Reviewed<String>),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Selector {
|
||||
Data(DataFacet),
|
||||
FieldMinimum,
|
||||
StateGuard,
|
||||
Trigger(KeyPair<Id>),
|
||||
Step(KeyPair<Id>),
|
||||
StepOrder,
|
||||
Annotation(KeyPair<ValueKey>),
|
||||
Binding(KeyPair<Id>),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FacetReview {
|
||||
Data(DataReview),
|
||||
FieldMinimum(Reviewed<u32>),
|
||||
StateGuard(Reviewed<String>),
|
||||
Trigger {
|
||||
keys: KeyPair<Id>,
|
||||
values: Reviewed<bool>,
|
||||
},
|
||||
Step {
|
||||
keys: KeyPair<Id>,
|
||||
values: Reviewed<bool>,
|
||||
},
|
||||
StepOrder(Reviewed<Vec<Id>>),
|
||||
Annotation {
|
||||
keys: KeyPair<ValueKey>,
|
||||
values: Reviewed<Option<MessageValue>>,
|
||||
},
|
||||
Binding {
|
||||
keys: KeyPair<Id>,
|
||||
values: Reviewed<Option<LifelineBinding>>,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Baseline {
|
||||
pub facets: Vec<FacetReview>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Resolution {
|
||||
pub facet: Selector,
|
||||
pub decision: Decision,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Review {
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub candidate: Option<OwnRecord>,
|
||||
pub resolutions: Vec<Resolution>,
|
||||
pub references: BTreeMap<Id, Id>,
|
||||
}
|
||||
|
||||
// ADAPTER: exhaustive typed capture, comparison, and application for this subset.
|
||||
fn observed<T>(v: Option<T>) -> Observed<T> {
|
||||
v.map_or(Observed::NotApplicable, Observed::Value)
|
||||
}
|
||||
fn pair<T>(a: &OwnRecord, b: &OwnRecord, f: impl Fn(&OwnRecord) -> Option<T>) -> Reviewed<T> {
|
||||
Reviewed {
|
||||
oa: observed(f(a)),
|
||||
sa: observed(f(b)),
|
||||
}
|
||||
}
|
||||
fn data(r: &OwnRecord) -> Option<(&DataTypeDefinition, &Option<DataValue>, &String)> {
|
||||
match shared(r).ok()? {
|
||||
SharedBody::DataType {
|
||||
definition,
|
||||
default,
|
||||
unit,
|
||||
} => Some((definition, default, unit)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn constructor(d: &DataTypeDefinition) -> DataConstructor {
|
||||
match d {
|
||||
DataTypeDefinition::Boolean => DataConstructor::Boolean,
|
||||
DataTypeDefinition::Integer { .. } => DataConstructor::Integer,
|
||||
DataTypeDefinition::Real { .. } => DataConstructor::Real,
|
||||
DataTypeDefinition::String { .. } => DataConstructor::String,
|
||||
DataTypeDefinition::Enumeration { .. } => DataConstructor::Enumeration,
|
||||
DataTypeDefinition::Record { .. } => DataConstructor::Record,
|
||||
}
|
||||
}
|
||||
fn annotation(r: &OwnRecord, key: &Option<ValueKey>) -> Option<Option<MessageValue>> {
|
||||
let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
} = shared(r).ok()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(key.as_ref().and_then(|key| {
|
||||
values
|
||||
.iter()
|
||||
.find(|v| v.path == key.path && v.field == key.field)
|
||||
.cloned()
|
||||
}))
|
||||
}
|
||||
fn binding(r: &OwnRecord, key: Option<Id>) -> Option<Option<LifelineBinding>> {
|
||||
let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::ScenarioUse { bindings, .. },
|
||||
..
|
||||
} = shared(r).ok()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(key.and_then(|key| bindings.iter().find(|v| v.referenced == key).cloned()))
|
||||
}
|
||||
impl DataReview {
|
||||
fn facet(&self) -> DataFacet {
|
||||
match self {
|
||||
Self::Constructor(_) => DataFacet::Constructor,
|
||||
Self::IntegerMinimum(_) => DataFacet::IntegerMinimum,
|
||||
Self::IntegerMaximum(_) => DataFacet::IntegerMaximum,
|
||||
Self::RealMinimum(_) => DataFacet::RealMinimum,
|
||||
Self::RealMaximum(_) => DataFacet::RealMaximum,
|
||||
Self::MinimumLength(_) => DataFacet::MinimumLength,
|
||||
Self::MaximumLength(_) => DataFacet::MaximumLength,
|
||||
Self::Literals(_) => DataFacet::Literals,
|
||||
Self::Supertype(_) => DataFacet::Supertype,
|
||||
Self::Default(_) => DataFacet::Default,
|
||||
Self::Unit(_) => DataFacet::Unit,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FacetReview {
|
||||
pub fn selector(&self) -> Selector {
|
||||
match self {
|
||||
Self::Data(v) => Selector::Data(v.facet()),
|
||||
Self::FieldMinimum(_) => Selector::FieldMinimum,
|
||||
Self::StateGuard(_) => Selector::StateGuard,
|
||||
Self::Trigger { keys, .. } => Selector::Trigger(keys.clone()),
|
||||
Self::Step { keys, .. } => Selector::Step(keys.clone()),
|
||||
Self::StepOrder(_) => Selector::StepOrder,
|
||||
Self::Annotation { keys, .. } => Selector::Annotation(keys.clone()),
|
||||
Self::Binding { keys, .. } => Selector::Binding(keys.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn capture(a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> FacetReview {
|
||||
match selector {
|
||||
Selector::Data(f) => FacetReview::Data(match f {
|
||||
DataFacet::Constructor => {
|
||||
DataReview::Constructor(pair(a, b, |r| Some(constructor(data(r)?.0))))
|
||||
}
|
||||
DataFacet::IntegerMinimum => DataReview::IntegerMinimum(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::Integer { minimum, .. } = data(r)?.0 {
|
||||
Some(*minimum)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::IntegerMaximum => DataReview::IntegerMaximum(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::Integer { maximum, .. } = data(r)?.0 {
|
||||
Some(*maximum)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::RealMinimum => DataReview::RealMinimum(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::Real { minimum, .. } = data(r)?.0 {
|
||||
Some(*minimum)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::RealMaximum => DataReview::RealMaximum(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::Real { maximum, .. } = data(r)?.0 {
|
||||
Some(*maximum)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::MinimumLength => DataReview::MinimumLength(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::String { minimum_length, .. } = data(r)?.0 {
|
||||
Some(*minimum_length)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::MaximumLength => DataReview::MaximumLength(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::String { maximum_length, .. } = data(r)?.0 {
|
||||
Some(*maximum_length)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::Literals => DataReview::Literals(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::Enumeration { literals } = data(r)?.0 {
|
||||
Some(literals.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::Supertype => DataReview::Supertype(pair(a, b, |r| {
|
||||
if let DataTypeDefinition::Record { supertype } = data(r)?.0 {
|
||||
Some(*supertype)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
DataFacet::Default => DataReview::Default(pair(a, b, |r| Some(data(r)?.1.clone()))),
|
||||
DataFacet::Unit => DataReview::Unit(pair(a, b, |r| Some(data(r)?.2.clone()))),
|
||||
}),
|
||||
Selector::FieldMinimum => FacetReview::FieldMinimum(pair(a, b, |r| {
|
||||
if let SharedBody::DataField { minimum, .. } = shared(r).ok()? {
|
||||
Some(*minimum)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
Selector::StateGuard => FacetReview::StateGuard(pair(a, b, |r| {
|
||||
if let OwnRecord::Relation {
|
||||
body: RelationBody::StateTransition { guard, .. },
|
||||
} = r
|
||||
{
|
||||
Some(guard.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})),
|
||||
Selector::Trigger(keys) | Selector::Step(keys) => {
|
||||
let triggers = matches!(selector, Selector::Trigger(_));
|
||||
let values = Reviewed {
|
||||
oa: observed(
|
||||
list(a, triggers)
|
||||
.ok()
|
||||
.map(|v| keys.oa.is_some_and(|id| v.contains(&id))),
|
||||
),
|
||||
sa: observed(
|
||||
list(b, triggers)
|
||||
.ok()
|
||||
.map(|v| keys.sa.is_some_and(|id| v.contains(&id))),
|
||||
),
|
||||
};
|
||||
if triggers {
|
||||
FacetReview::Trigger {
|
||||
keys: keys.clone(),
|
||||
values,
|
||||
}
|
||||
} else {
|
||||
FacetReview::Step {
|
||||
keys: keys.clone(),
|
||||
values,
|
||||
}
|
||||
}
|
||||
}
|
||||
Selector::StepOrder => FacetReview::StepOrder(pair(a, b, |r| list(r, false).ok().cloned())),
|
||||
Selector::Annotation(keys) => FacetReview::Annotation {
|
||||
keys: keys.clone(),
|
||||
values: Reviewed {
|
||||
oa: observed(annotation(a, &keys.oa)),
|
||||
sa: observed(annotation(b, &keys.sa)),
|
||||
},
|
||||
},
|
||||
Selector::Binding(keys) => FacetReview::Binding {
|
||||
keys: keys.clone(),
|
||||
values: Reviewed {
|
||||
oa: observed(binding(a, keys.oa)),
|
||||
sa: observed(binding(b, keys.sa)),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
impl Baseline {
|
||||
pub fn capture(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
target: Id,
|
||||
selectors: &[Selector],
|
||||
) -> Result<Self, String> {
|
||||
unique(selectors)?;
|
||||
let result = Self {
|
||||
facets: selectors
|
||||
.iter()
|
||||
.map(|f| capture(&own(p, source), &own(p, target), f))
|
||||
.collect(),
|
||||
};
|
||||
result.validate()?;
|
||||
Ok(result)
|
||||
}
|
||||
pub fn changed(&self, a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> Option<bool> {
|
||||
self.facets
|
||||
.iter()
|
||||
.find(|v| v.selector() == *selector)
|
||||
.map(|v| *v != capture(a, b, selector))
|
||||
}
|
||||
fn upsert(&mut self, review: FacetReview) {
|
||||
self.facets.retain(|v| v.selector() != review.selector());
|
||||
self.facets.push(review);
|
||||
}
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
unique(
|
||||
&self
|
||||
.facets
|
||||
.iter()
|
||||
.map(FacetReview::selector)
|
||||
.collect::<Vec<_>>(),
|
||||
)?;
|
||||
for fact in &self.facets {
|
||||
if !has_applicable_value(fact) {
|
||||
return Err("Facet is inapplicable on both sides".into());
|
||||
}
|
||||
match fact {
|
||||
FacetReview::Annotation { keys, values } => {
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty keyed facet".into());
|
||||
}
|
||||
for (key, value) in [(&keys.oa, &values.oa), (&keys.sa, &values.sa)] {
|
||||
if let Observed::Value(Some(value)) = value
|
||||
&& key.as_ref()
|
||||
!= Some(&ValueKey {
|
||||
path: value.path.clone(),
|
||||
field: value.field,
|
||||
})
|
||||
{
|
||||
return Err("Historical annotation payload disagrees with key".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
FacetReview::Binding { keys, values } => {
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty keyed facet".into());
|
||||
}
|
||||
for (key, value) in [(&keys.oa, &values.oa), (&keys.sa, &values.sa)] {
|
||||
if let Observed::Value(Some(value)) = value
|
||||
&& *key != Some(value.referenced)
|
||||
{
|
||||
return Err("Historical binding payload disagrees with key".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
FacetReview::Trigger { keys, .. } | FacetReview::Step { keys, .. }
|
||||
if keys.oa.is_none() && keys.sa.is_none() =>
|
||||
{
|
||||
return Err("Empty keyed facet".into());
|
||||
}
|
||||
FacetReview::Data(DataReview::RealMinimum(v) | DataReview::RealMaximum(v)) => {
|
||||
for v in [&v.oa, &v.sa] {
|
||||
if let Observed::Value(Some(v)) = v
|
||||
&& !v.is_finite()
|
||||
{
|
||||
return Err("Historical real must be finite".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
FacetReview::Data(DataReview::Default(v)) => {
|
||||
for v in [&v.oa, &v.sa] {
|
||||
if let Observed::Value(Some(DataValue::Real(v))) = v
|
||||
&& !v.is_finite()
|
||||
{
|
||||
return Err("Historical real default must be finite".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn has_applicable_value(fact: &FacetReview) -> bool {
|
||||
fn any<T>(v: &Reviewed<T>) -> bool {
|
||||
matches!(v.oa, Observed::Value(_)) || matches!(v.sa, Observed::Value(_))
|
||||
}
|
||||
match fact {
|
||||
FacetReview::Data(v) => match v {
|
||||
DataReview::Constructor(v) => any(v),
|
||||
DataReview::IntegerMinimum(v) | DataReview::IntegerMaximum(v) => any(v),
|
||||
DataReview::RealMinimum(v) | DataReview::RealMaximum(v) => any(v),
|
||||
DataReview::MinimumLength(v) => any(v),
|
||||
DataReview::MaximumLength(v) => any(v),
|
||||
DataReview::Literals(v) => any(v),
|
||||
DataReview::Supertype(v) => any(v),
|
||||
DataReview::Default(v) => any(v),
|
||||
DataReview::Unit(v) => any(v),
|
||||
},
|
||||
FacetReview::FieldMinimum(v) => any(v),
|
||||
FacetReview::StateGuard(v) => any(v),
|
||||
FacetReview::Trigger { values, .. } | FacetReview::Step { values, .. } => any(values),
|
||||
FacetReview::StepOrder(v) => any(v),
|
||||
FacetReview::Annotation { values, .. } => any(values),
|
||||
FacetReview::Binding { values, .. } => any(values),
|
||||
}
|
||||
}
|
||||
fn unique(selectors: &[Selector]) -> Result<(), String> {
|
||||
fn overlap<K: PartialEq>(a: &KeyPair<K>, b: &KeyPair<K>) -> bool {
|
||||
a.oa.as_ref().is_some_and(|k| b.oa.as_ref() == Some(k))
|
||||
|| a.sa.as_ref().is_some_and(|k| b.sa.as_ref() == Some(k))
|
||||
}
|
||||
for (index, selector) in selectors.iter().enumerate() {
|
||||
if selectors[..index].iter().any(|old| {
|
||||
old == selector
|
||||
|| match (old, selector) {
|
||||
(Selector::Trigger(a), Selector::Trigger(b))
|
||||
| (Selector::Step(a), Selector::Step(b))
|
||||
| (Selector::Binding(a), Selector::Binding(b)) => overlap(a, b),
|
||||
(Selector::Annotation(a), Selector::Annotation(b)) => overlap(a, b),
|
||||
_ => false,
|
||||
}
|
||||
}) {
|
||||
return Err("Duplicate facet or conflicting keyed slot".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn applicable(a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> bool {
|
||||
[a, b].into_iter().all(|r| match selector {
|
||||
Selector::Data(_) => data(r).is_some(),
|
||||
Selector::FieldMinimum => matches!(shared(r), Ok(SharedBody::DataField { .. })),
|
||||
Selector::StateGuard | Selector::Trigger(_) => matches!(
|
||||
r,
|
||||
OwnRecord::Relation {
|
||||
body: RelationBody::StateTransition { .. }
|
||||
}
|
||||
),
|
||||
Selector::Step(_) | Selector::StepOrder => list(r, false).is_ok(),
|
||||
Selector::Annotation(_) => annotation(r, &None).is_some(),
|
||||
Selector::Binding(_) => binding(r, None).is_some(),
|
||||
})
|
||||
}
|
||||
fn check_keys(selector: &Selector, refs: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
fn ids(keys: &KeyPair<Id>, refs: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
match (keys.oa, keys.sa) {
|
||||
(None, None) => Err("Empty keyed facet".into()),
|
||||
(Some(a), Some(b)) if refs.get(&a) != Some(&b) => {
|
||||
Err("Key pair disagrees with frozen reference mapping".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
match selector {
|
||||
Selector::Trigger(k) | Selector::Step(k) | Selector::Binding(k) => ids(k, refs),
|
||||
Selector::Annotation(k) => match (&k.oa, &k.sa) {
|
||||
(None, None) => Err("Empty keyed facet".into()),
|
||||
(Some(a), Some(b)) => {
|
||||
if a.path.len() != b.path.len() {
|
||||
return Err("Annotation path mapping differs".into());
|
||||
}
|
||||
for (oa, sa) in a
|
||||
.path
|
||||
.iter()
|
||||
.chain([&a.field])
|
||||
.zip(b.path.iter().chain([&b.field]))
|
||||
{
|
||||
ids(
|
||||
&KeyPair {
|
||||
oa: Some(*oa),
|
||||
sa: Some(*sa),
|
||||
},
|
||||
refs,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
fn chosen<T: Clone>(v: &Reviewed<T>, source: bool) -> Result<T, String> {
|
||||
match if source { &v.oa } else { &v.sa } {
|
||||
Observed::Value(v) => Ok(v.clone()),
|
||||
Observed::NotApplicable => {
|
||||
Err("Facet is not applicable; explicit constructor review required".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn definition_mut(r: &mut OwnRecord) -> Result<&mut DataTypeDefinition, String> {
|
||||
match shared_mut(r)? {
|
||||
SharedBody::DataType { definition, .. } => Ok(definition),
|
||||
_ => Err("Wrong data constructor scope".into()),
|
||||
}
|
||||
}
|
||||
fn map_ref(id: Id, refs: &BTreeMap<Id, Id>) -> Result<Id, String> {
|
||||
refs.get(&id)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("Missing reference mapping for {id}"))
|
||||
}
|
||||
fn write(
|
||||
target: &mut OwnRecord,
|
||||
fact: &FacetReview,
|
||||
source: bool,
|
||||
refs: &BTreeMap<Id, Id>,
|
||||
) -> Result<(), String> {
|
||||
macro_rules! bound {
|
||||
($v:expr, $variant:ident, $field:ident) => {{
|
||||
let value = chosen($v, source)?;
|
||||
let DataTypeDefinition::$variant { $field, .. } = definition_mut(target)? else {
|
||||
return Err("Explicit constructor review required".into());
|
||||
};
|
||||
*$field = value;
|
||||
}};
|
||||
}
|
||||
match fact {
|
||||
FacetReview::Data(v) => match v {
|
||||
DataReview::IntegerMinimum(v) => bound!(v, Integer, minimum),
|
||||
DataReview::IntegerMaximum(v) => bound!(v, Integer, maximum),
|
||||
DataReview::RealMinimum(v) => bound!(v, Real, minimum),
|
||||
DataReview::RealMaximum(v) => bound!(v, Real, maximum),
|
||||
DataReview::MinimumLength(v) => bound!(v, String, minimum_length),
|
||||
DataReview::MaximumLength(v) => bound!(v, String, maximum_length),
|
||||
DataReview::Literals(v) => bound!(v, Enumeration, literals),
|
||||
DataReview::Supertype(v) => {
|
||||
let mut value = chosen(v, source)?;
|
||||
if source {
|
||||
value = value.map(|id| map_ref(id, refs)).transpose()?;
|
||||
}
|
||||
let DataTypeDefinition::Record { supertype } = definition_mut(target)? else {
|
||||
return Err("Explicit constructor review required".into());
|
||||
};
|
||||
*supertype = value;
|
||||
}
|
||||
DataReview::Default(v) => {
|
||||
let SharedBody::DataType { default, .. } = shared_mut(target)? else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
*default = chosen(v, source)?;
|
||||
}
|
||||
DataReview::Unit(v) => {
|
||||
let SharedBody::DataType { unit, .. } = shared_mut(target)? else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
*unit = chosen(v, source)?;
|
||||
}
|
||||
DataReview::Constructor(_) => return Err("Constructor is a grouped operation".into()),
|
||||
},
|
||||
FacetReview::FieldMinimum(v) => {
|
||||
let SharedBody::DataField { minimum, .. } = shared_mut(target)? else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
*minimum = chosen(v, source)?;
|
||||
}
|
||||
FacetReview::StateGuard(v) => {
|
||||
let OwnRecord::Relation {
|
||||
body: RelationBody::StateTransition { guard, .. },
|
||||
} = target
|
||||
else {
|
||||
return Err("Wrong facet".into());
|
||||
};
|
||||
*guard = chosen(v, source)?;
|
||||
}
|
||||
FacetReview::Trigger { keys, values } | FacetReview::Step { keys, values } => {
|
||||
let present = chosen(values, source)?;
|
||||
let Some(id) = keys.sa else {
|
||||
return if present {
|
||||
Err("Missing member mapping".into())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
};
|
||||
let items = list_mut(target, matches!(fact, FacetReview::Trigger { .. }))?;
|
||||
if present && !items.contains(&id) {
|
||||
items.push(id);
|
||||
} else if !present {
|
||||
items.retain(|v| *v != id);
|
||||
}
|
||||
}
|
||||
FacetReview::StepOrder(values) => {
|
||||
if source {
|
||||
return Err("Explicit final order required by this probe".into());
|
||||
}
|
||||
let value = chosen(values, false)?;
|
||||
let mut expected = list(target, false)?.clone();
|
||||
let mut proposed = value.clone();
|
||||
expected.sort();
|
||||
proposed.sort();
|
||||
if expected != proposed || proposed.windows(2).any(|p| p[0] == p[1]) {
|
||||
return Err("Final order must contain every surviving member exactly once".into());
|
||||
}
|
||||
*list_mut(target, false)? = value;
|
||||
}
|
||||
FacetReview::Annotation { keys, values } => {
|
||||
let mut value = chosen(values, source)?;
|
||||
let Some(key) = &keys.sa else {
|
||||
return if value.is_some() {
|
||||
Err("Missing annotation mapping".into())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
};
|
||||
if let Some(value) = &mut value {
|
||||
value.path = key.path.clone();
|
||||
value.field = key.field;
|
||||
}
|
||||
let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
} = shared_mut(target)?
|
||||
else {
|
||||
return Err("Wrong annotation scope".into());
|
||||
};
|
||||
if let Some(index) = values
|
||||
.iter()
|
||||
.position(|v| v.path == key.path && v.field == key.field)
|
||||
{
|
||||
if let Some(value) = value {
|
||||
values[index] = value;
|
||||
} else {
|
||||
values.remove(index);
|
||||
}
|
||||
} else if let Some(value) = value {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
FacetReview::Binding { keys, values } => {
|
||||
let mut value = chosen(values, source)?;
|
||||
let Some(key) = keys.sa else {
|
||||
return if value.is_some() {
|
||||
Err("Missing binding mapping".into())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
};
|
||||
if let Some(value) = &mut value {
|
||||
value.referenced = key;
|
||||
if source {
|
||||
value.local = map_ref(value.local, refs)?;
|
||||
}
|
||||
}
|
||||
let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::ScenarioUse { bindings, .. },
|
||||
..
|
||||
} = shared_mut(target)?
|
||||
else {
|
||||
return Err("Wrong binding scope".into());
|
||||
};
|
||||
if let Some(index) = bindings.iter().position(|v| v.referenced == key) {
|
||||
if let Some(value) = value {
|
||||
bindings[index] = value;
|
||||
} else {
|
||||
bindings.remove(index);
|
||||
}
|
||||
} else if let Some(value) = value {
|
||||
bindings.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn in_constructor(selector: &Selector) -> bool {
|
||||
matches!(selector, Selector::Data(f) if *f != DataFacet::Unit)
|
||||
}
|
||||
fn constructor_group(a: &OwnRecord, b: &OwnRecord) -> Vec<FacetReview> {
|
||||
use DataFacet::*;
|
||||
let mut facets = vec![Constructor, Default];
|
||||
for r in [a, b] {
|
||||
if let Some((definition, ..)) = data(r) {
|
||||
for f in match definition {
|
||||
DataTypeDefinition::Integer { .. } => vec![IntegerMinimum, IntegerMaximum],
|
||||
DataTypeDefinition::Real { .. } => vec![RealMinimum, RealMaximum],
|
||||
DataTypeDefinition::String { .. } => vec![MinimumLength, MaximumLength],
|
||||
DataTypeDefinition::Enumeration { .. } => vec![Literals],
|
||||
DataTypeDefinition::Record { .. } => vec![Supertype],
|
||||
DataTypeDefinition::Boolean => vec![],
|
||||
} {
|
||||
if !facets.contains(&f) {
|
||||
facets.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
facets
|
||||
.into_iter()
|
||||
.map(|f| capture(a, b, &Selector::Data(f)))
|
||||
.collect()
|
||||
}
|
||||
pub fn apply(p: &Project, base: &Baseline, review: &Review) -> Result<(Project, Baseline), String> {
|
||||
base.validate()?;
|
||||
unique(
|
||||
&review
|
||||
.resolutions
|
||||
.iter()
|
||||
.map(|r| r.facet.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
)?;
|
||||
let switch = review
|
||||
.resolutions
|
||||
.iter()
|
||||
.any(|r| r.facet == Selector::Data(DataFacet::Constructor));
|
||||
if switch
|
||||
&& review
|
||||
.resolutions
|
||||
.iter()
|
||||
.any(|r| in_constructor(&r.facet) && r.facet != Selector::Data(DataFacet::Constructor))
|
||||
{
|
||||
return Err("Overlapping constructor and descendant decisions".into());
|
||||
}
|
||||
let source = own(p, review.source);
|
||||
let current = own(p, review.target);
|
||||
let mut target = current.clone();
|
||||
let constructor_selector = Selector::Data(DataFacet::Constructor);
|
||||
for r in &review.resolutions {
|
||||
if !applicable(&source, ¤t, &r.facet) {
|
||||
return Err("Facet does not belong to this record family".into());
|
||||
}
|
||||
check_keys(&r.facet, &review.references)?;
|
||||
if !has_applicable_value(&capture(&source, ¤t, &r.facet)) {
|
||||
return Err("Facet is inapplicable on both sides".into());
|
||||
}
|
||||
for previous in &base.facets {
|
||||
if previous.selector() != r.facet
|
||||
&& unique(&[previous.selector(), r.facet.clone()]).is_err()
|
||||
{
|
||||
return Err(
|
||||
"Mapping changed; explicitly invalidate the affected previous facet review"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if r.facet == Selector::Data(DataFacet::Literals) && r.decision == Decision::TakeSource {
|
||||
return Err("String-keyed literal deltas are outside this probe; no whole-list TakeSource overwrite".into());
|
||||
}
|
||||
}
|
||||
if switch && base.changed(&source, ¤t, &constructor_selector) == Some(false) {
|
||||
let decision = review
|
||||
.resolutions
|
||||
.iter()
|
||||
.find(|r| r.facet == constructor_selector)
|
||||
.unwrap()
|
||||
.decision;
|
||||
let proposed = match decision {
|
||||
Decision::TakeSource => Some(&source),
|
||||
Decision::Candidate => review.candidate.as_ref(),
|
||||
Decision::KeepTarget => None,
|
||||
};
|
||||
let actual_target_switch = proposed
|
||||
.and_then(data)
|
||||
.zip(data(¤t))
|
||||
.is_some_and(|(new, old)| constructor(new.0) != constructor(old.0));
|
||||
if !actual_target_switch {
|
||||
return Err("No constructor change; review descendant facets individually".into());
|
||||
}
|
||||
}
|
||||
if !switch
|
||||
&& base.changed(&source, ¤t, &constructor_selector) == Some(true)
|
||||
&& review.resolutions.iter().any(|r| in_constructor(&r.facet))
|
||||
{
|
||||
return Err("Explicit constructor review required".into());
|
||||
}
|
||||
let mut resolutions = review.resolutions.iter().collect::<Vec<_>>();
|
||||
resolutions.sort_by_key(|r| {
|
||||
if r.facet == constructor_selector {
|
||||
0
|
||||
} else if r.facet == Selector::StepOrder {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
for r in &resolutions {
|
||||
if r.decision == Decision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
let from_source = r.decision == Decision::TakeSource;
|
||||
let supplied = if from_source {
|
||||
&source
|
||||
} else {
|
||||
review.candidate.as_ref().ok_or("Missing candidate")?
|
||||
};
|
||||
if r.facet == constructor_selector {
|
||||
let (definition, default, _) = data(supplied).ok_or("Wrong constructor group")?;
|
||||
let mut definition = definition.clone();
|
||||
if from_source
|
||||
&& let DataTypeDefinition::Enumeration { literals } = &definition
|
||||
&& let Some((DataTypeDefinition::Enumeration { literals: old }, ..)) = data(&target)
|
||||
&& old.iter().any(|v| !literals.contains(v))
|
||||
{
|
||||
return Err(
|
||||
"Constructor candidate must explicitly review target-only enum literals".into(),
|
||||
);
|
||||
}
|
||||
if from_source
|
||||
&& let DataTypeDefinition::Record {
|
||||
supertype: Some(id),
|
||||
} = &mut definition
|
||||
{
|
||||
*id = map_ref(*id, &review.references)?;
|
||||
}
|
||||
let SharedBody::DataType {
|
||||
definition: to_definition,
|
||||
default: to_default,
|
||||
..
|
||||
} = shared_mut(&mut target)?
|
||||
else {
|
||||
return Err("Wrong constructor group".into());
|
||||
};
|
||||
*to_definition = definition;
|
||||
*to_default = default.clone();
|
||||
} else {
|
||||
let fact = capture(&source, supplied, &r.facet);
|
||||
write(&mut target, &fact, from_source, &review.references)?;
|
||||
}
|
||||
}
|
||||
if review.candidate.as_ref().is_some_and(|v| *v != target) {
|
||||
return Err("Candidate changes an unselected facet".into());
|
||||
}
|
||||
let mut expected = p.clone();
|
||||
let command = match target {
|
||||
OwnRecord::Element { parent, body } => {
|
||||
let e = expected.elements.get_mut(&review.target).unwrap();
|
||||
e.parent = parent;
|
||||
e.body = body;
|
||||
Command::EditElement(review.target, e.clone())
|
||||
}
|
||||
OwnRecord::Relation { body } => {
|
||||
let r = expected.relations.get_mut(&review.target).unwrap();
|
||||
r.body = body;
|
||||
Command::EditRelation(review.target, r.clone())
|
||||
}
|
||||
};
|
||||
let prepared = prepare_command(p, &Command::Batch(vec![command])).map_err(|e| e.to_string())?;
|
||||
if prepared.project != expected {
|
||||
return Err("Prepared candidate induces unreviewed changes outside selected facets".into());
|
||||
}
|
||||
let target = own(&prepared.project, review.target);
|
||||
let mut acknowledged = base.clone();
|
||||
for r in resolutions {
|
||||
if r.facet == constructor_selector {
|
||||
acknowledged
|
||||
.facets
|
||||
.retain(|f| !in_constructor(&f.selector()));
|
||||
for f in constructor_group(&source, &target) {
|
||||
acknowledged.upsert(f);
|
||||
}
|
||||
} else {
|
||||
acknowledged.upsert(capture(&source, &target, &r.facet));
|
||||
}
|
||||
}
|
||||
acknowledged.validate()?;
|
||||
Ok((prepared.project, acknowledged))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "typed_reviews_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,3 +58,8 @@ relative to a canvas whose origin may move when selection changes the toolbar.
|
||||
Viewport notifications fit only on actual size changes. Native testing also showed
|
||||
Iced WGPU places parent meshes after clipped child meshes; backgrounds must share
|
||||
their content clip to avoid hiding vector geometry while leaving text visible.
|
||||
|
||||
State camera/tool lifetime follows effective machine and diagram identity, even
|
||||
when selection changes through Catalog or Layer outside Behavior. Reset only on
|
||||
actual context change; preserve same-view return. Independent review reproduced
|
||||
the leak and native Windows/Linux tests verified the correction in316dc55.
|
||||
|
||||
+21
-8
@@ -16,17 +16,30 @@ The current forms are a closed typed-record adapter, not a second metamodel. Exp
|
||||
|
||||
Nested source ownership and persistent REST bundles are implemented and verified: Windows minimum-size source-to-graphics, nested reparent/source/Undo/Redo/Save/reopen, recovery, rebuilt Linux source/history/Save/reopen, independent review and both platform suites passed. Preserve the existing grammar/updater and separate allocation/order semantics.
|
||||
|
||||
The user-directed contextual-menu/source-tree, movable-port and direct-connection milestone has scoped Windows/Linux native acceptance. See release-port-drawing.md for exact builds, saved trials and minimum-size boundaries. Continue direct state-machine editing in the isolated state-integration checkout: persist typed machine context, use shared state/route geometry, retain drafts until accepted document commit, then verify creation, movement, transition drawing/reconnection, undo and reopen. This first behavior slice precedes scenario/process gestures; all broader release milestones remain required.
|
||||
Explorer/port discovery, custom routing, direct palette connections, four-side port
|
||||
anchors, contextual menus and source folders have scoped Windows/Linux acceptance.
|
||||
See release-explorer-usability.md, release-routing-verification.md,
|
||||
release-routing-linux.md and release-port-drawing.md for exact evidence and limits.
|
||||
|
||||
The user-directed actor/system port and model-navigation milestone is accepted: visible Add port → Connect boundary exchange, grouped roots, focused containment, breadcrumbs, contextual search and paging, clear full-depth outline, and separate allocations. Native Windows authoring/save/reopen, 251-root paging, 12-level navigation, 1440×900/1100×700 inspection, rebuilt Linux save/reopen and single-line font correction passed. Independent requirements, correctness, native UI and simplicity findings are addressed; release-explorer-usability.md records exact evidence and remaining performance boundaries. Resume source recovery acceptance and the release milestones below; the full scope remains intact.
|
||||
Direct state-machine editing is accepted through316dc55: typed persisted views,
|
||||
explicit-owner creation, movement, direct transitions/reconnection, typed detail
|
||||
forms, folding, placement and correct camera/tool lifetime. Windows default/minimum
|
||||
and Linux native acceptance plus independent reviews are recorded in
|
||||
release-state-editing.md. Direct scenario/process gestures remain required.
|
||||
|
||||
Editable connection routing has passed code/native acceptance and independent follow-up at desktop checkpoint `23535b4`, with the Linux-discovered viewport correction in `366ce44` also verified on the actual rebuilt Linux app. Current WSL automated checks and routing runtime acceptance are recorded in release-routing-linux.md. Continue production source authoring under release-source-integration.md and direct behavior editing. Typed port integration and its native authoring/reopen checks are complete at `fa096ce`; `release-diagram-port-correction.md` records the domain correction. Existing R5 state authoring is preserved and still needs direct gestures and full native acceptance.
|
||||
R6 data contracts are active in the isolated data integration checkout. Freeze the
|
||||
full payload/dictionary shape as schema/source4, keep existing typed ports, migrate
|
||||
older formats explicitly while preserving lexical ownership/comments, then verify
|
||||
GUI/text/REST parity and real engineering workflows. See release-data-contract.md
|
||||
and the implementation plan in that checkout. R3 semantic drift/reconciliation
|
||||
follows the R6 freeze; its current prose-only behavior is insufficient.
|
||||
|
||||
The user's routing correction is implemented as editable connection bend points with automatic obstacle-aware starting routes. Persistent commands, source/API parity, shared geometry and desktop gestures passed automated and native verification. `65fa9ab` resolves six default SA label-overlap pairs; follow-up typed reopen, short gestures and SVG export at 1100×700 passed. `release-routing-verification.md` records exact evidence, completed independent native follow-up and the 119 ms dense full-solve limitation. Route identity belongs to the diagram representation and does not alter semantic endpoints or ownership. Direct state gestures remain required and should reuse geometry/routing primitives only where semantics match.
|
||||
|
||||
Drag selection, typed port/schema 3 and routing corrections are complete. Make behavior diagrams directly editable using shared persisted geometry and ordinary commands, and replace the single huge source editing experience with a modular project/include workflow. These are release requirements, not future limitations. The source bundle core and nested source experiments provide evidence and reusable foundations, not production editor acceptance. Source projects must preserve identity, precise per-file diagnostics, Git-friendly ownership of declarations, cycle/missing/duplicate include failures, safe local include resolution and GUI/API parity. Behavior acceptance requires actual canvas movement and contextual creation/editing, not only selection plus external menus. The proposed first state-machine slice is in `release-state-gesture-design.md`; verify its interfaces before changing the shared schema.
|
||||
|
||||
Source direction: extend the production parser/span index and shared updater for selected nesting and shorthand constructs, retaining the existing virtual-bundle foundation. The 23-test experiment is evidence, not production code to copy: independent probes found item-comment misattachment, shorthand description rejection and unsupported deletion. Verify explicit comment ownership, all edit/delete paths, exact source Undo, invalid drafts and per-file diagnostics before adopting syntax. Direct behavior editing first tests a state-machine task (create in region, drag, draw/reconnect transition, edit guard, undo/reopen) before propagating the interaction design to scenarios/chains. Compare successful task completion, semantic clarity, mode switches and recoverability, not control counts.
|
||||
Continue source authoring improvements only through the production parser/updater.
|
||||
The23-test experiment is evidence, not architecture: independent probes exposed
|
||||
comment misattachment and unsupported edits. Additional shorthand must preserve
|
||||
complete edit/delete behavior and exact source history, and justify itself in real
|
||||
human authoring tasks. Shared state placements are now validated; scenario/chain
|
||||
interaction must preserve its own occurrence and ordering semantics.
|
||||
|
||||
1. **Release coverage audit and interfaces.** Compare actual core/UI/API with official Capella OA/SA semantics and the new objective. Freeze a coverage table and concrete variant/reference/transition contracts before editing shared interfaces. Distinguish unsupported later layers from missing OA/SA workflows; V2 exclusions are reconsidered. Native audit exercises onboarding/help/authoring at supported sizes. Independent audits have separate file ownership.
|
||||
2. **Complete engineering semantics.** Fill verified gaps in capability relationships and mission involvement; operational process/functional-chain function/control topology; scenario/state/data constructs needed by OA/SA engineers. Add typed semantic-content drift and reviewed existing-counterpart reconciliation, including reference mapping and explicit dependency/ownership decisions. Every addition has a usable graphical authoring workflow, equivalent source/API representation, meaningful reject/undo/delete/reidentify/transition tests and a worked example. Exact additions and invariants are recorded in the coverage/interface artifact from milestone1, avoiding speculative LA/PA concepts.
|
||||
|
||||
+117
-166
@@ -1,188 +1,139 @@
|
||||
# First release — current engineering state
|
||||
|
||||
## Objective and current phase
|
||||
## Objective and phase
|
||||
|
||||
The first-release goal remains active and incomplete. Deliver Rust/Iced Operational
|
||||
Analysis and System Analysis/System Architecture for system engineers, equivalent
|
||||
graphical/textual/REST semantics, hierarchical diagrams and typed ports, modular
|
||||
source projects, artifact traceability, editable behavior, useful offline help,
|
||||
and Windows/Linux release evidence. Later Arcadia layers remain future releases.
|
||||
Authoritative objective: attachment
|
||||
`07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md`, reread 2026-09-06.
|
||||
PLAN.md, TASK_LEDGER.md and release-semantics-audit.md retain full release scope.
|
||||
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
|
||||
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
|
||||
textual and REST semantics, artifact traceability, directly editable behavior,
|
||||
offline guidance, and Windows/Linux release evidence. Later Arcadia layers remain
|
||||
future releases. The full authoritative objective was reread on 2026-09-06:
|
||||
attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md.
|
||||
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
|
||||
|
||||
The latest user-directed drawing/port/menu/source-tree milestone is accepted at
|
||||
`fef2e0d` plus tooltip-layer follow-up `ad7feb5`. It does not complete the release.
|
||||
Root now integrates direct state-machine editing in an isolated worktree. There
|
||||
is meaningful implementation, independent review and real runtime progress; no
|
||||
external blocker or disk blocker exists.
|
||||
Direct state-machine editing is accepted through production316dc55. The main
|
||||
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts are now
|
||||
being implemented in a separate checkout; they are not accepted or released.
|
||||
Meaningful implementation and runtime progress continues; no external blocker exists.
|
||||
|
||||
## Accepted foundations and evidence
|
||||
## Accepted foundations
|
||||
|
||||
- Typed OA/SA model, schema 3 ports, validated atomic commands and ordinary history
|
||||
are shared across three Rust crates. Typed contracts and migration are documented
|
||||
in release-port-contract.md, release-port-core.md and release-port-migration.md.
|
||||
Desktop typed authoring checkpoint `fa096ce` passed native creation, generation,
|
||||
undo/redo and persistence. Original Capella was operated in a disposable workspace;
|
||||
exact observed semantics/workflows: release-capella-workflow-benchmark.md.
|
||||
- Explorer/port discovery `d364d3c`: visible Add port, grouped roots, focused children,
|
||||
breadcrumbs, contextual search/paging and full-depth outline. Windows port/Standard
|
||||
exchange, 251-root paging, 12-level navigation and 1100x700 trials passed; rebuilt
|
||||
Linux save/reopen/font correction passed. See release-explorer-usability.md.
|
||||
- Editable routes `23535b4`, viewport fix `366ce44`, automatic labels `65fa9ab`:
|
||||
authored bends/labels, nested group movement, folding/restoration, undo/redo,
|
||||
save/reopen, SVG and minimum-size gestures passed native and independent review.
|
||||
See release-routing-verification.md and release-routing-linux.md. Dense optimized
|
||||
full solve measured119ms; arbitrary dense crossings remain possible.
|
||||
- Nested source and coupled hosted source/model checkpoint `4e8575b`: source3
|
||||
element nesting/includes, token/provenance-aware structural writes, exact model
|
||||
source history, retained drafts and safe Save All/recovery. Full422 Windows and
|
||||
425 Linux tests, strict checks and builds passed at that checkpoint. Actual REST
|
||||
import/comment-only revision/cross-file reparent/stale ETag/restart passed.
|
||||
Windows/Linux source and nested graphical edits, save/reopen and recovery were
|
||||
exercised. See release-source-integration.md. Experiments remain evidence only;
|
||||
do not adopt their independent parser/writer.
|
||||
- Core typed OA/SA semantics, validated atomic commands and ordinary history are
|
||||
shared by three Rust crates. Typed ports, explicit unresolved migration and
|
||||
original Capella observations: release-port-{contract,core,migration}.md and
|
||||
release-capella-workflow-benchmark.md.
|
||||
- Explorer/port discovery d364d3c: Add port on participants, grouped roots, focused
|
||||
children, breadcrumbs, contextual paging/search and full-depth outline. Actual
|
||||
Windows creation,251-root/12-level trials,1100x700 and Linux reopen/font fixes:
|
||||
release-explorer-usability.md.
|
||||
- Routing23535b4, viewport366ce44, labels65fa9ab: authored bends/labels, group
|
||||
movement, fold restoration, Undo/Redo, save/reopen and SVG, including minimum-size
|
||||
native trials. Dense full solve measured119ms; arbitrary crossings remain possible.
|
||||
See release-routing-verification.md and release-routing-linux.md.
|
||||
- Source4e8575b: nested lexical ownership/includes, provenance-aware token writes,
|
||||
exact accepted source/model history, retained drafts, Save All/recovery and coupled
|
||||
hosted source/model revisions. Windows/Linux native and actual REST restart,
|
||||
stale ETag, comment-only edit and cross-file reparent passed. Full422 Windows /
|
||||
425 Linux tests at that checkpoint. See release-source-integration.md.
|
||||
- Direct drawing fef2e0d plus tooltip ad7feb5: OA Interaction, SA Functional and
|
||||
Boundary Exchange by palette click-click or drag; compatible highlights, automatic
|
||||
function ports, four-side draggable anchors, contextual menus and source folders.
|
||||
Independent pointer/tooltip findings fixed and native retested. Windows193 desktop
|
||||
and Linux189+4 focused tests followed earlier full485/488 suites. Scoped native
|
||||
Windows/Linux acceptance: release-port-drawing.md and release-context-menus.md.
|
||||
|
||||
## Drawing acceptance — current main checkout
|
||||
## Newly accepted state workspace
|
||||
|
||||
`93fd32c` fixes candidate scaling without weakening validation; `35133de` supplies
|
||||
persisted four-side normalized port anchors. `fef2e0d` adds direct palette drawing,
|
||||
shared automatic labels, contextual menus and the source folder tree. OA Interaction,
|
||||
SA Functional Exchange and Boundary Exchange accept click-click or drag. Function
|
||||
cards create/reuse typed ports atomically. Compatible targets highlight; Standard
|
||||
and Flow contracts remain distinct. Pan retains source; Escape cancels.
|
||||
Production316dc55 includes typed machine views, shared persisted geometry, atomic
|
||||
region/symbol/state creation, group movement, direct transitions and reconnection,
|
||||
Internal rows, Local edges, typed detail forms, folding, placement and navigation.
|
||||
Architecture and Behavior view selections are separate. Drafts survive rejected
|
||||
source/model commits; passive camera events never apply a form.
|
||||
|
||||
Independent review reproduced batched-pointer click corruption and overview port
|
||||
mispicking. Ordered CursorMoved coordinates, explicit context epochs and nearest
|
||||
port glyph picking fix these, with production regressions. Native review then
|
||||
reproduced port-tooltip overprint: separate Frames still share an Iced GPU layer.
|
||||
The fix uses a passive second stacked Canvas (a real renderer layer). It preserves
|
||||
interactive child state/input and resets held-button suppression on context change,
|
||||
matching releases, Escape and focus loss. CursorLeft alone does not end suppression.
|
||||
Independent/native findings fixed: covered GPU shapes, pseudo-label/edge collision,
|
||||
selection-relayout drag displacement, missing typed form access, folded-content
|
||||
access, wrong-view placement Apply, and camera/tool leakage on machine navigation.
|
||||
An actual owner/view change resets gesture state and fits; returning to the same
|
||||
view preserves camera/tool. Exact evidence and limitations: release-state-editing.md.
|
||||
|
||||
Evidence is in release-port-drawing.md and release-context-menus.md. Exact native
|
||||
review report: `D:/CodexBuilds/Capella2-release1-01a06e2e/drawing-independent-native-review.md`.
|
||||
Windows independent trials passed Standard rejection/completion, automatic function
|
||||
ports, undo/redo, save/reopen and1100x700. Overprint was reproduced on9A7FF074 and
|
||||
closed on3F534C67 at1440x900 and1100x700. Root tested final1EEBDC3C: first port drag,
|
||||
menu outside dismissal, first repeated drag, direct FE, readable identity and Save.
|
||||
Saved trial: `ui-port-tooltip-final/project.arcadia`,41objects/26links/0checks.
|
||||
Verification: full551 Windows /554 Linux workspace tests and strict workspace
|
||||
Clippy at e0b2210. Final316dc55:241 desktop tests, strict all-target desktop Clippy,
|
||||
and desktop builds on each platform; five new navigation tests included. Two guide
|
||||
tests passed after help changes. Source/reST/API state contracts and actual HTTP
|
||||
rejection/revision/source/restart tests passed earlier in the integration.
|
||||
|
||||
Root operated actual Linux7AC5EEE: first FE drag, Right-to-Bottom port drag, Reset/
|
||||
Undo, repeated Bottom-to-Left drag, source-offscreen arrow pan and completion,
|
||||
Save All/reopen, persisted geometry and source-tree expansion/open. Saved trial:
|
||||
`ui-port-tooltip-linux/project.arcadia`,41objects/27links/0checks. Runtime log empty;
|
||||
Linux window closed normally, exec13766 exit0. Linux was inspected at1440x900;
|
||||
minimum-size acceptance is Windows, not a claim of Linux minimum-size testing.
|
||||
Root and independent Windows native trials covered1440x900 and1100x700 creation,
|
||||
nested movement, first-drag transition, endpoint/menu reconnect, invalid target,
|
||||
Escape, timed Internal, Local/EntryPoint, placement Apply, Undo/Redo and save/reopen.
|
||||
Linux native covered1440x900 creation, first transition/move, Undo/Redo, fold and
|
||||
saved collapsed reopen. Final316dc55 native on both platforms proved same-view
|
||||
camera/tool preservation and different-machine Fit/Select reset. Logs are empty.
|
||||
|
||||
Verification scopes: full485 Windows /488 Linux workspace tests preceded the last
|
||||
pointer/tooltip refinements. Latest main Windows193 desktop tests, strict desktop
|
||||
all-target Clippy and debug build passed. Latest Linux189 desktop tests, strict
|
||||
desktop Clippy and release build passed; four new tooltip lifecycle tests then
|
||||
passed separately. A142-test Linux result from a shared-worktree Cargo cache was
|
||||
identified and EXCLUDED; scoped package cleaning and explicit main recompilation
|
||||
established the valid189-test result. Use a distinct CARGO_TARGET_DIR per worktree.
|
||||
## Active R6 implementation and next actions
|
||||
|
||||
## Active isolated state integration
|
||||
Checkout D:/CodexBuilds/Capella2-data-integration; branch
|
||||
codex/data-contract-integration, base5ce3eef+plan2bf4c54+navigation9118218.
|
||||
Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/source4
|
||||
with full data payload and dictionary slices together; existing typed ports remain.
|
||||
See release-data-implementation.md and release-data-contract.md in that checkout.
|
||||
|
||||
Worktree: `D:/CodexBuilds/Capella2-state-integration`, branch`codex/state-integration`.
|
||||
Target: `D:/CodexBuilds/Capella2-state-integration-target`. Main core is not yet changed.
|
||||
Core is committed0b58107; source/migration1230ab8. Root API/docs are integrated
|
||||
on the same working tree; desktop remains in flight and R6 is not accepted.
|
||||
Core15 R6 +28workflow tests passed (final allocation-arrow suppression arrived
|
||||
afterward and is queued for rerun). Earlier13ports/14scenarios and115 source/lib
|
||||
tests passed; strict core Clippy preceded the last geometry/performance fixes.
|
||||
|
||||
- `e7a8163` integrates reviewed core state-context commit a63654f, preserving latest
|
||||
anchors and routing labels. State views skip semantic port layout after boundary
|
||||
snapping; both routing and label passes reserve actual internal-row headers.
|
||||
- `2459581` retains behavior drafts until Document commit succeeds.
|
||||
- `53cd096` adds typed state-context forms, placement membership choices and normative
|
||||
source/JSON/REST/OpenAPI contracts. Strict HTML generation passed. Two actual HTTP
|
||||
tests passed, including malformed/invalid rejection, revision/source atomicity and
|
||||
restart. Destination pickers permit intermediate invalid drafts so coordinated
|
||||
element/diagram retargeting is reachable; Apply remains strict.
|
||||
- `e5660b8` integrates App dirty/Close/Save/navigation/mutation guards. Exact prepared
|
||||
commands acknowledge drafts only after accepted source/model writes. Review found
|
||||
passive viewport events applying properties, queued source controls creating
|
||||
competing drafts and stale delete confirmations; all three fixes have App tests.
|
||||
Six App lifecycle tests passed, including typed source-writeback rejection/retry
|
||||
and one Undo. Clean detached buffers after Undo are intentional; reachable bundle
|
||||
and accepted Document are restored exactly.
|
||||
- Integrated baseline:514 Windows workspace tests (277core,199desktop,38server),
|
||||
strict workspace all-target Clippy passed. This baseline precedes direct gestures;
|
||||
there is no native state-gesture acceptance yet.
|
||||
Root41 server tests pass, including3 new actual HTTP workflows for the published
|
||||
data example, protocol/artifact identity, atomic rejected defaults, query/export,
|
||||
reidentify/delete/restart, source3 bundle migration reports and old coupled3
|
||||
envelope upgrade without write/revision. Tests exposed dropped bundle reports and
|
||||
rejected legitimate old source/model pairs; both root fixes passed retest.
|
||||
Strict reST HTML generation and81 OpenAPI schema definitions passed. Actual frozen
|
||||
server32033FFE imported the published example; its complete Project/StoredProject
|
||||
responses passed served/current JSON Schema validation and obsolete shapes rejected.
|
||||
Evidence: api-r6-contract and r6-server-tests-after.log under the release directory.
|
||||
|
||||
Direct-state UI integration is now implemented (uncommitted): persisted canvas,
|
||||
creation within explicit regions, movement, direct transitions and endpoint
|
||||
reconnection, pan/zoom/Fit, typed detail forms, and contextual fold/unfold/hide.
|
||||
The old computed static state scene is removed. Source/model commands remain atomic.
|
||||
Independent review found removed typed-form access, unreachable folded contents,
|
||||
selection relayout altering drag deltas, fallback owner resetting the first drag,
|
||||
and same-size viewport events refitting the camera. Fixes preserve typed creation,
|
||||
use state-specific menus, window-space ordered gesture deltas, retained owner and
|
||||
actual-size resize checks. App tests also exposed Architecture placement leaking
|
||||
into Behavior selection; the two view selections are now separate.
|
||||
Independent core review found association transition closure pulling unrelated
|
||||
types and missing empty-enum warning; both corrected and focused tests passed.
|
||||
Repeated per-ancestor full scans were simplified to one scan per helper call.
|
||||
Explicit debug inheritance timings250/500/1000:147/664/2739ms. Optimized timing is
|
||||
still required; no optimized performance claim yet.
|
||||
|
||||
First direct-state Windows verification:231 desktop tests and strict desktop
|
||||
all-target Clippy passed, logs `D:/CodexBuilds/Capella2-state-direct-{tests,clippy}.log`.
|
||||
Native trial2A4B5C11 created a new SA StateMachine and dedicated view; it reproduced
|
||||
missing box fills/borders while text remained. Iced WGPU appends parent meshes after
|
||||
clipped children: the opaque parent background covered every node. The fix moves
|
||||
the content background into the clip and restricts the outer fill to the guidance
|
||||
strip. Native re-verification remains required. Saved reproduction is
|
||||
`ui-state-direct-01/project.arcadia`,42objects/25links/2 expected incomplete-machine
|
||||
checks. Trial window1442250 received normal close after verified Save; re-inventory.
|
||||
Current ownership: drawing_correctness owns desktop except data_view.rs;
|
||||
state_architecture_review now owns only data_view.rs bounded focused-dictionary
|
||||
correction. Its independent review found unbounded inherited rows/picklists,
|
||||
unreachable recursive paths and loss of scenario values on same-phase style or
|
||||
same-exchange reselection. Drawing worker fixes recursive drill-down/draft retention.
|
||||
data_core now verifies Linux in a separate data-linux-review checkout/target.
|
||||
Root owns API/OpenAPI/reST, integration and native input. No concurrent file writers.
|
||||
|
||||
Latest verification:551 Windows workspace tests (277core,236desktop,38server)
|
||||
and strict workspace all-target Clippy passed. A single let-and-return lint in the
|
||||
new label hit helper was fixed without changing behavior. Subsequent compact-toolbar
|
||||
changes passed desktop Clippy and build. Exact logs use `state-direct-workspace-*`
|
||||
and `state-compact-*` under D:/CodexBuilds.
|
||||
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
|
||||
the same evidence in c5276b1. Finish the3 concrete desktop review fixes and
|
||||
optimized inheritance measurement, then native data-authoring acceptance.
|
||||
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
|
||||
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
|
||||
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
|
||||
baselines/reconciliation only cover prose. Prefer historical typed-body snapshots
|
||||
and finite proposals; avoid a duplicate recursive semantic schema without evidence.
|
||||
4. Complete direct scenario/process gestures, persistent coverage/filters,
|
||||
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
|
||||
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
|
||||
Windows/Linux/MSRV checks, real runtime, reproducible docs and release packaging.
|
||||
|
||||
Native82AE5A2C closed the missing-shapes bug; root directly created Region, Initial,
|
||||
State, first-drag Initial→State transition, selected without motion, moved/undid the
|
||||
state, created Internal Monitor communications with generated After30s Event,
|
||||
Undo/Redo of both, and Fold/Unfold via right-click. Saved trial46objects/27links/1
|
||||
remaining model check uses ui-state-direct-01/project.arcadia; its original empty
|
||||
machine fixture is preserved in baseline-before-editing. Minimum1100x700 exposed
|
||||
excessive toolbar height; latest build removes selected-action row and shortens
|
||||
state heading. Native recheck is pending, along with reconnection, nested composite,
|
||||
Linux state runtime and independent final runtime review.
|
||||
## Environment and native ownership
|
||||
|
||||
Additional review/native fixes: Fold retains semantic selection; placement property
|
||||
Apply retains Behavior view without overwriting Architecture view. Initial names
|
||||
choose among12 deterministic route-aware positions, and label hit testing uses the
|
||||
same cached rectangle. This closes the observed rightward-line/name collision in
|
||||
code; native verification of this last refinement remains pending.
|
||||
Root exclusively owns native input. Latest saved Windows trial is window1706782,
|
||||
ui-state-navigation-windows,49objects/28links/1 expected missing-owner warning.
|
||||
Linux navigation trial3148598 closed normally, exec46107 exit0; baseline Linux
|
||||
trial721472 also closed normally, exec72680 exit0. Independent Windows3672536 is
|
||||
saved at1100x700. Older root drawing windows are saved. Re-inventory before input.
|
||||
Protected user7210312/6228978 were absent; do not reset/save/close if they return.
|
||||
|
||||
Root owns integration/native input; all workers have returned files. Every worktree
|
||||
uses its own Cargo target. Main remains ad7feb5; state work is isolated and not yet
|
||||
accepted into main. No release-completion claim.
|
||||
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
|
||||
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
|
||||
D:/CodexBuilds/Capella2-state-linux-review and -target. Use distinct Cargo targets
|
||||
per checkout; shared-cache results were previously caught and excluded.
|
||||
|
||||
## Exact next actions
|
||||
|
||||
1. Rebuild and verify the state rendering fix on the saved native reproduction;
|
||||
complete real state creation, connections, folding, form edit, Save/reopen and
|
||||
minimum-size trials. Commit only after reviewed behavior is demonstrated.
|
||||
2. Finish state-edit acceptance with explicit retained
|
||||
behavior view, creation in a region, movement, transition drawing/reconnection,
|
||||
guarded forms, discoverable missing placements, pan/zoom and contextual actions.
|
||||
Build/run/test native at default/minimum sizes; review/reproduce/fix findings.
|
||||
3. Continue direct scenario/process gestures, R3 semantic drift/reconciliation,
|
||||
R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag
|
||||
workflows, offline guidance and normative contracts. All remain release work.
|
||||
4. Fresh final requirements/domain/correctness/native UX/architecture reviews;
|
||||
Windows/Linux/MSRV checks, actual runtime, docs and packages/checksums before
|
||||
completing the broad goal. V2 or a subsystem checkpoint is insufficient.
|
||||
|
||||
## Native safety and environment
|
||||
|
||||
Root owns native input. Final Windows1EEBDC3C window2100032/exec55548 is saved on
|
||||
its isolated41/26 trial. Earlier windows524634 and328292 are saved on the Standard
|
||||
and auto-port review fixtures. Re-inventory and inspect current dirty state before
|
||||
operating any window. Protected user7210312 and earlier6228978 were absent; root did
|
||||
not close them and makes no recovery claim. If either returns, do not save/reset/close.
|
||||
|
||||
Main Windows target `D:/CodexBuilds/Capella2-01a06e2e`; Rust1.98.1. Use `cargo test
|
||||
--locked --workspace`, `cargo clippy --locked --workspace --all-targets -- -D warnings`
|
||||
and `cargo build --locked -p oa-desktop -p oa-server`. WSL Ubuntu24.04 uses the
|
||||
existing target/linux-cargo.toml vendor config and its own linux-target; launch
|
||||
with WGPU_BACKEND=vulkan and the Mesa lvp ICD. Release evidence lives under
|
||||
`D:/CodexBuilds/Capella2-release1-01a06e2e`. No remote push or usage reset is authorized.
|
||||
Do not retry the previously rejected broad recursive target deletion.
|
||||
Windows: cargo test --locked --workspace; cargo clippy --locked --workspace
|
||||
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
|
||||
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
|
||||
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
|
||||
|
||||
+49
-50
@@ -1,66 +1,65 @@
|
||||
# Task Ledger — first release
|
||||
|
||||
Authoritative objective: attachment `07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md`, read fully on 2026-09-05. STATE.md records execution evidence; PLAN.md is the living strategy; DECISIONS.md and linked release contracts retain detailed rationale.
|
||||
Authoritative objective: attachment07aee762-7d27-4cbe-81c3-2e82c7170711/
|
||||
goal-objective.md, read fully2026-09-06. STATE.md records execution; PLAN.md retains
|
||||
scope; DECISIONS.md and linked contracts preserve rationale.
|
||||
|
||||
## Goal and success criteria
|
||||
|
||||
System engineers can develop a coherent OA→SA system model, including iterative changes and evidence, through equivalent graphical, textual and REST workflows. Rust/Iced Windows and Linux release, strict reST semantics, navigable offline concepts/tutorials, and reproducible build/run/evaluation instructions. Later Arcadia layers are future releases. Completion requires actual native workflows, automated invariants, and fresh cross-disciplinary acceptance, not compilation or a feature count.
|
||||
System engineers develop and iteratively reconcile OA/SA models through equivalent
|
||||
graphical, textual and REST workflows. Rust/Iced Windows/Linux, strict reST,
|
||||
traceability, navigable offline guidance and reproducible deployment. Acceptance
|
||||
requires native workflows, semantic evaluators and fresh independent review.
|
||||
|
||||
## Given facts
|
||||
|
||||
- User prioritizes engineering usability and Capella domain fidelity, hierarchical containment/allocation displays, real function/component ports and intuitive transitions in both directions.
|
||||
- Text-only model development must be practical; modular files/includes are the proposed answer to the current huge flat file.
|
||||
- Drag selection must remain stable; behavior/scenario canvases must support direct editing consistent with architecture.
|
||||
- Git and worktrees are authorized. Remote publication is not requested.
|
||||
- Connection routes need explicit bend points/customization or sufficiently effective automatic routing. User observed straight connections prevent readable diagrams. Treat saved per-diagram routes and practical obstacle avoidance as a primary requirement, not visual decoration.
|
||||
- User prioritizes Capella domain fidelity, hierarchy, typed ports and intuitive
|
||||
transitions in both directions, without its clutter and navigation friction.
|
||||
- Direct exchange drawing must avoid large endpoint dropdowns; ports must move
|
||||
between sides; contextual menus should replace persistent action clutter.
|
||||
- Modular source must be practical for humans/AI and Git. Text/graph/API parity is
|
||||
required. Behavior canvases must support editing and preserve drag selection.
|
||||
- Git/worktrees are authorized. Remote publication is not requested.
|
||||
|
||||
## Verified facts
|
||||
|
||||
- Native modular source editing/reopen/repair/recovery passed on Windows at 1100×700. Production nested ownership, structural writeback and coupled REST source bundles now pass an initial 416-test workspace run. Actual service import/comment-only revision/cross-file move/stale ETag passed. Comment fidelity needs independent checks: review exposed owner-opening-line comment reassignment; root reproduced it and all 12 updater tests pass after the fix. Equal-model delete/recreate provenance was also reproduced and fixed in desktop and REST. Native nested workflow and final integrated review/checks remain.
|
||||
- Original Capella SAB creates FE function input/output endpoints; component ports
|
||||
are separately Standard/Flow with explicit allocation. Native observations are in
|
||||
release-capella-workflow-benchmark.md; adopted contracts preserve that distinction.
|
||||
- Explorer/port discovery, editable routing, nested modular source and coupled REST
|
||||
bundles have scoped Windows/Linux acceptance. Evidence links are in STATE.md.
|
||||
- Direct palette connections, four-side port anchors, context menus and source
|
||||
folders passed real Windows/Linux operation. Passive stacked Canvas fixes native
|
||||
tooltip overprint; separate Frames did not. See release-port-drawing.md.
|
||||
- Persisted state editing now passes Windows/Linux native and independent review:
|
||||
creation, movement, direct transitions, typed forms, reconnection, fold/reopen.
|
||||
See release-state-editing.md. Scenario/process canvases still need direct editing.
|
||||
- Ordinary Document commands atomically synchronize source/model; failed writes
|
||||
retain drafts. Source comments require occurrence/provenance-aware edits, not
|
||||
whole-model equality or flat reformatting. Independent counterexamples were fixed.
|
||||
- R3 existing-target reconciliation remains name/description only. R6 data shape
|
||||
expansion is active implementation, not accepted functionality.
|
||||
|
||||
- Explorer/port usability accepted at `d364d3c`: actual Windows Actor → Standard port → Boundary Exchange, save/reopen and Undo/Redo; contextual duplicate-name search, 251-root paging, 12-level ancestry and 1100×700 inspection. Linux port save/reopen and font-dependent outline wrapping correction were verified. Exact evidence: release-explorer-usability.md. This resolves the latest discovery/navigation complaint without claiming broader release completeness.
|
||||
## Assumptions and open questions
|
||||
|
||||
- Native Capella SAB authoring creates function input/output endpoints with an FE. Component ports separately carry Standard/Flow kind, orientation, interface roles and explicit allocated function ports. Details and successfully performed versus merely observed operations are in release-capella-workflow-benchmark.md.
|
||||
- Studio uses one typed Project and validated atomic commands across Rust core/desktop/server. Hierarchy projection distinguishes actual allocation from inferred parent summaries; IDs and placement are separate.
|
||||
- Drag selection bug fixed and committed `2ef16dc`; 71 desktop tests and real moved-card selection/property editing passed before typed-port integration.
|
||||
- Typed ports/schema 3 migration merged at `757ab88`. Existing port identities survive as Legacy and missing FE endpoints are generated deterministically; migration and port tests provide scoped evidence in release-port-core.md/release-port-migration.md.
|
||||
- Independent core review reproduced alias reservation and same-transaction allocation replacement defects; fixes have focused regression evidence. Typed desktop integration `fa096ce` passed full checks and native creation/generation, undo/redo, save/reopen and no-op synchronization trials. Long-reference previews were inspected at 1440×900 and 1100×700; detailed evidence is in STATE.md.
|
||||
- Per-diagram pins and labels, source/API parity and shared orthogonal routing are implemented in `4e5a88d`, `a7c6f96`, `68b1eff` and `30c7db1`. `65fa9ab` resolves all six reproduced default SA label-overlap pairs. Native OA/SA editing, movement/undo, fold restoration, save/reopen, SVG inspection and short gestures at 1100×700 passed. Routing code/native acceptance and independent native follow-up are complete at `23535b4`; exact scope is recorded in release-routing-verification.md.
|
||||
- The source experiment passed 23 tests, but an independent probe reproduced positional item-comment misattachment, shorthand description rejection and unsupported deletion. Whole-model equality does not establish source-comment fidelity. The production updater retained comment attachment on the same reorder probe; extend its parser/updater instead of copying the experimental parallel implementation.
|
||||
- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The source editor retains modular accepted source; nested element syntax and cross-file graphical updates passed Windows/Linux native acceptance at4e8575b. Port/exchange/steps shorthand remains an independent future implementation slice required by the current authoring strategy.
|
||||
- Independent source review exposed declaration/comment reassignment during UUID changes and swaps, Linux FIFO hangs during disk admission, file-mode changes on save, and overlong detached-file receipt lifetimes. Command provenance, pre-open regular-file admission, permission retention and saved-detach receipt retirement resolve the reproduced cases. Exact probes and test scopes are in STATE.md.
|
||||
- Nested aggregate files reduce cognitive load; any additional port/exchange/step
|
||||
shorthand must justify grammar complexity through real authoring tasks.
|
||||
- State placements proved viable without a second geometry system. Scenario/chain
|
||||
gestures still need their own occurrence/order semantics and task verification.
|
||||
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
|
||||
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
|
||||
- R3 typed historical bodies plus finite proposals appear simpler than a separate
|
||||
recursive semantic-value schema. This candidate awaits R6 freeze and review.
|
||||
|
||||
## Assumptions to test
|
||||
## Decisions and current strategy
|
||||
|
||||
- Aggregate-oriented source files will reduce cognitive load enough for routine engineering edits; file splitting alone may leave declaration syntax too flat.
|
||||
- Behavior layout can reuse NodePlacement plus typed diagram context without duplicating semantic state; reconnection/reordering must preserve existing occurrence/relation identities.
|
||||
- Editable per-diagram bends and the automatic initial route passed the recorded workshop trials. This does not guarantee an ideal arrangement or zero crossings for arbitrary models; the measured dense release full solve is 119 ms, above the initial 100 ms target.
|
||||
- Source comments and ownership can survive graphical edits by replacing only changed declaration spans. Generated IDs must stabilize before subsequent text edits.
|
||||
Preserve one typed core and validated command boundary. Retain current typed ports;
|
||||
R6 uses schema/source4 with explicit1/2/3 migration and no intermediate public4
|
||||
shape. Freeze payload, defaults, inheritance, associations and identified interface
|
||||
allocations together. Preserve lexical includes/comments during migration.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Which port/exchange/steps shorthand constructs add enough clarity to justify extending the accepted nested ownership syntax? Preserve comment attachment, exact source Undo and complete edit/delete support against the independent probe cases.
|
||||
- What remaining OA/SA gaps materially block target workflows? R3 semantic reconciliation, R6 data contracts, directly editable behavior, lifecycle/recovery and hosted editing remain in the release plan.
|
||||
|
||||
## Candidate designs and current decisions
|
||||
|
||||
- Source candidates compared explicit source3 aggregate files/includes with nested owner/context syntax lowering to the same model. The chosen implementation direction extends the production parser/span index and shared updater for selected constructs, preserving the pure bundle foundation. Do not copy the experiment's separate parser/writer. Freeze grammar only after comment ownership, edit/delete coverage, invalid drafts and exact source Undo pass their acceptance tasks.
|
||||
- Behavior alternatives: independent UI-only coordinates, new behavior-layout schema, or existing diagram placements with explicit context. Prefer the last candidate provisionally: it preserves text/API parity and command history without a second geometry system. Validate on direct state-machine creation/move/reconnect before extending all behavior views.
|
||||
- Ports: adopted typed separation based on official metamodel plus direct native evidence; rejected generic InOut/interface port conflation and inferred allocation facts. See release-port-contract.md. Revisit only with contradictory domain evidence or concrete usability failures.
|
||||
- Routing: a unique per-diagram route list references existing relation/trace IDs; pinned points and relative label placement are persisted, while orthogonal segments are derived by a shared pure router. This avoids duplicate semantic edge identities and preserves text/API/SVG parity. Folded endpoint promotion suspends custom geometry without deleting it. See release-routing-contract.md.
|
||||
|
||||
## Current strategy and evaluators
|
||||
|
||||
Accept native per-file editing, safe project saves and exact source Undo, then extend production source parsing/updating using the independent probe findings as regressions and integrate REST bundles. Linux routing is accepted with actual WSLg Save/reopen; all11 current Linux disk integration tests also pass. Full release Linux acceptance remains open. Prototype persisted direct behavior gestures next. Continue R3 reconciliation, R6 data contracts, lifecycle/recovery, hosted editing, offline guidance and fresh Windows/Linux release acceptance; no subsystem checkpoint constitutes release completion.
|
||||
|
||||
Meaningful progress now includes verified drag and typed-port authoring, accepted editable routing with native save/reopen, group movement and minimum-size evidence, and tested source-bundle/nested-writeback foundations with independent limitations exposed. Production source and direct behavior usability remain open engineering tasks; feature counts alone do not establish completion.
|
||||
|
||||
Current user-directed drawing evidence: palette creation and draggable per-placement port anchors passed scoped native Windows trials with Save/reopen; full485 Windows and488 Linux workspace suites passed before final pointer-release and hover refinements. Fresh independent review exposed post-release motion turning clicks into edits, nearest-port mispicking at overview zoom, and missing target identity feedback. The first two have production regressions and fixes; named owner/alias hover details and source-preserving pan are implemented, with final native acceptance pending. See release-port-drawing.md. The preceding goal turn made concrete progress through implementation, saved source/SVG artifacts, actual runtime verification and reproduced findings; it was not a wait or no-progress turn. Direct behavior and the broader release requirements remain active.
|
||||
2026-09-06 follow-up: direct drawing, movable ports, menus and source folders now
|
||||
have scoped native Windows/Linux acceptance. Independent QA falsified the initial
|
||||
separate-Frame tooltip fix; a real stacked GPU layer passed native retest. Current
|
||||
main Windows193 desktop tests and Linux189+4 focused tests passed. Independent
|
||||
state/representation/lifecycle integration passed514 Windows workspace tests and
|
||||
strict Clippy in its isolated checkout. Direct state-canvas authoring is underway;
|
||||
all broader release requirements remain active. This turn made concrete implementation,
|
||||
review and runtime progress, not a blocked or waiting-only turn. See STATE.md.
|
||||
Finish and independently verify R6, then semantic R3 reconciliation and direct
|
||||
scenario/process gestures. Complete lifecycle/recovery, hosted editing, guidance
|
||||
and fresh cross-disciplinary Windows/Linux acceptance. No subsystem checkpoint
|
||||
completes the first release. Current work is making measured implementation,
|
||||
review and runtime progress; no external blocker exists.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# R6 data and interface contract proposal
|
||||
|
||||
Implementation freeze (2026-09-06): `release-data-implementation.md` supersedes
|
||||
the earlier version-number and generic-port proposals below. Current contract is
|
||||
schema/source4 with explicit1/2/3 migration; existing typed PortContract remains
|
||||
authoritative. Value Record default `[]` is allowed at minimum0; Reference fields
|
||||
have no explicit default. See docs/language.rst4.5 for the implemented specification.
|
||||
|
||||
Status: read-only design, 2026-09-05. No implementation, test pass, or native Studio acceptance is claimed here. This is the recommended integration contract for the lead to freeze before the R6 worker changes exhaustive enums. It extends the current typed model and transaction path; it does not introduce an instance database, expression evaluator, units engine, or plugin metamodel.
|
||||
|
||||
## Evidence and scope
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# R6 implementation freeze — schema/source 4
|
||||
|
||||
Status: active implementation in isolated D:/CodexBuilds/Capella2-data-integration.
|
||||
State editing is accepted in main a8f451e; DATA includes its navigation fix and evidence.
|
||||
The full first-release goal remains incomplete.
|
||||
|
||||
## Outcome and contract
|
||||
|
||||
Implement the complete data/interface contract in release-data-contract.md before
|
||||
accepting schema/source4 artifacts: nature/mechanism, bounded scalar types/defaults,
|
||||
field direction/storage, qualified scenario value paths, single specialization,
|
||||
identified interface-item allocations/protocols and binary reference associations.
|
||||
Use its exact types and validation rules. Existing PortContract and legacy
|
||||
resolution/split behavior are authoritative; the old R6 InOut paragraph is superseded
|
||||
by release-port-contract.md. R6 never creates a new InOut functional contract.
|
||||
|
||||
Retain three crates, Project and validated atomic Command/Document boundaries.
|
||||
Use data.rs shared helpers for effective fields, item ancestry, inherited interface
|
||||
allocations, carried field paths and value validation. Reference visitors own live
|
||||
references; no second model or mutation API. Inherited rows expose original identity.
|
||||
Scenario paths disambiguate reused records and stop at Reference storage.
|
||||
|
||||
## Sequence and ownership
|
||||
|
||||
1. Core owner freezes public shapes/helpers, implements typed data validation,
|
||||
reference/removal/transition maintenance and focused meaningful regressions.
|
||||
2. Source/migration owner preserves V1/V2/current3 inputs through an explicit4
|
||||
reader; updates parser/source-project migration without flattening nesting,
|
||||
includes or comments. Frozen strict DTOs only for changed old payloads plus a
|
||||
closed old constructor catalog avoid cloning the whole metamodel.
|
||||
3. Desktop owner integrates typed forms, path pickers, readable nested dictionary
|
||||
and interface-allocation rows. Root integrates OpenAPI/spec and actual REST.
|
||||
4. Full tests/strict checks, source/JSON/HTTP roundtrips, invalid/stale transactions,
|
||||
Windows/Linux native authoring, compact UI and fresh independent review.
|
||||
|
||||
Separate files per owner; communicate model/helper signatures before dependent edits.
|
||||
Use a dedicated target for this worktree. No simultaneous Cargo mutations.
|
||||
Do not publish or accept intermediate4 files until both payload and dictionary
|
||||
slices are complete. R3 semantic reconciliation follows this shape/reference freeze.
|
||||
|
||||
## Required evidence
|
||||
|
||||
- Physical flow and Operation parameters use bounded reusable types and defaults.
|
||||
- Two parameters using one Record retain distinct value paths through calls/replies,
|
||||
reidentify/delete, graphical/source edits and initial transitions.
|
||||
- Two interfaces allocate one item with independent protocols/artifact links; removing
|
||||
one allocation retains the item and the other allocation, and Undo restores it.
|
||||
- Specialization/inherited fields and associations preserve ownership and identity;
|
||||
invalid cycles/defaults/paths/ranges reject atomically, incomplete drafts warn.
|
||||
- V1/V2/current3 migration preserves identities, typed ports, comments, includes and
|
||||
nested ownership; deterministic allocation IDs and unresolved legacy annotations
|
||||
have explicit diagnostics. GUI/source/REST save/reopen agree.
|
||||
|
||||
Alternatives: silent changes under schema3 rejected because existing saved3 projects
|
||||
are real compatibility inputs. Whole-model frozen DTO/validator duplication rejected
|
||||
in favor of strict changed-payload compatibility adapters. Generic JSON diff or
|
||||
opaque body replacement is not the R3 solution. Instance literals, unit conversion,
|
||||
expression execution and multiple inheritance remain explicitly outside this
|
||||
bounded contract; the release requirements for categories/filters are separate.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Direct state editing — acceptance evidence
|
||||
|
||||
Accepted production source:316dc55,2026-09-06. This is the state-machine slice of
|
||||
direct behavior editing, not acceptance of scenario/process gestures or the release.
|
||||
|
||||
## Delivered behavior and boundaries
|
||||
|
||||
Dedicated typed StateMachine views reuse persistent placements and shared core
|
||||
projection/routing. Palette creation inside explicit owners, nested group movement,
|
||||
direct external transitions, endpoint reconnection, typed Local/Internal/timed
|
||||
forms, fold/unfold/hide, placement editing and pan/zoom/Fit use ordinary atomic
|
||||
Document commands. Behavior selection/view is independent of Architecture.
|
||||
Creation places missing ancestry in the same command; dragging never reparents.
|
||||
Internal transitions are rows; Entry/Exit symbols snap to the containing boundary.
|
||||
Reconnection preserves relation identity and other fields and validates atomically.
|
||||
|
||||
Invalid drafts survive rejected writes. Save/Close/navigation/mutations account
|
||||
for behavior drafts; passive viewport events do not apply forms. Ordered window
|
||||
coordinates prevent selection-induced layout changes corrupting drag displacement.
|
||||
Same-view returns retain camera/tool; actual machine/view changes reset and fit.
|
||||
|
||||
## Automated and independent evidence
|
||||
|
||||
- Full551 Windows workspace tests (277core,236desktop,38server) and strict workspace
|
||||
all-target Clippy at e0b2210; full554 Linux (280core,236desktop,38server), strict
|
||||
workspace Clippy and optimized desktop build from an isolated checkout/target.
|
||||
- Final316dc55:241 desktop tests and strict desktop all-target Clippy on Windows
|
||||
and Linux, plus successful desktop builds. Five navigation regressions supplement
|
||||
the earlier suite. Four tested pre-fix navigation scenarios failed as predicted;
|
||||
all14 state App tests passed after the fix.
|
||||
- Two guide tests passed after5ce3eef. State source/JSON round trips, reference and
|
||||
membership rules, atomic command planners, draft lifecycle and two actual HTTP
|
||||
state-contract tests passed in the earlier integration. HTTP tests exercised
|
||||
rejection, revision/source atomicity and restart.
|
||||
- Independent native report:
|
||||
D:/CodexBuilds/Capella2-release1-01a06e2e/state-independent-native-review.md.
|
||||
Independent architecture review found camera/tool leakage after Catalog/Layer
|
||||
owner changes;316dc55 closes it. No material review finding remains in this slice.
|
||||
|
||||
## Actual Windows operation
|
||||
|
||||
Root created a machine/view, Region, Initial and State, drew Initial→State by its
|
||||
first drag, moved/undid State, created a timed Internal transition and generated
|
||||
After30s event, undid/redid both, folded/unfolded and saved. Actual GPU shape hiding
|
||||
was reproduced, fixed and retested; route-aware Initial label placement was also
|
||||
verified through movement and selecting the relocated label.
|
||||
|
||||
An independent reviewer operated DE2723E7 at1440x900 and1100x700: nested Region
|
||||
and State creation, composite descendant movement, semantic fold selection,
|
||||
actual target-handle reconnect, invalid Initial target/Escape, placement width Apply
|
||||
remaining in Behavior, EntryPoint creation, typed Local transition, contextual
|
||||
reconnect and Save All/actual reopen. Final fixture49objects/28links/1check.
|
||||
The remaining check explicitly asks for a machine owner on this root-level test
|
||||
machine; it is intentionally incomplete model content.
|
||||
|
||||
Root operated final316dc55 binary31A7E8505838FE99B73072B90D32072C280AF494F039710448D8AE36651C05ED:
|
||||
created a second machine view, panned A, armed Region, left/returned through
|
||||
Architecture and observed preserved94% camera/tool. Selected B through Model
|
||||
explorer and returned to Behavior:78% Fit and Select/move, with no inherited pan.
|
||||
Save All completed; runtime.log0bytes.
|
||||
|
||||
## Actual Linux operation
|
||||
|
||||
Root operated e0b2210 ELF1525135FDA1C4A0E42DC6EB9CD6F8FE4DCB30A1690ABA48AD1F3E8E9528DFC05
|
||||
through WSLg at1440x900. Opened an isolated empty-machine baseline; created Region,
|
||||
Initial and State, first-drag Initial→State, first State movement with correct
|
||||
selection/reroute, Undo/Redo, Fold and Save. Actual reopen retained collapse;
|
||||
Unfold restored moved vertices and transition. Saved45objects/26links/1owner check.
|
||||
Empty runtime log; normal close, exec72680 exit0.
|
||||
|
||||
Root then operated final316dc55 ELF
|
||||
e2c2945a2fc750774a925eee33b5b1620c6537580b7ab502e97f44cbb4c4bab8
|
||||
on the saved two-view49/28/1 fixture. Panned A and armed Region; Architecture round
|
||||
trip retained94% camera/tool. Model explorer selected B; Behavior fitted78% and
|
||||
reset to Select/move. Empty runtime log; normal close, exec46107 exit0.
|
||||
|
||||
## Reproduction artifacts and limits
|
||||
|
||||
Evidence root:D:/CodexBuilds/Capella2-release1-01a06e2e. Saved models and immutable
|
||||
binaries are in ui-state-direct-01, ui-state-direct-03, ui-state-linux,
|
||||
ui-state-navigation-windows and ui-state-navigation-linux. Final Linux directory
|
||||
contains build-manifest.json, SHA256SUMS, desktop test/Clippy/build logs. Windows
|
||||
logs use D:/CodexBuilds/Capella2-state-navigation-*; full baseline logs use
|
||||
Capella2-state-direct-workspace-* and ui-state-linux/VERIFICATION.md.
|
||||
|
||||
Native Linux minimum-size testing is not claimed;1100x700 evidence is Windows.
|
||||
Independent native review did not cover every pseudo kind, source-handle or Local
|
||||
reconnection, concurrent regions, guard/effect edits or full dirty-close matrix.
|
||||
Automated checks cover relevant semantics/lifecycle but are not relabeled native
|
||||
trials. Fitting a nested diagram at55% produces small labels; use zoom/pan to work.
|
||||
Scenario/process direct editing remains required release work.
|
||||
@@ -1,7 +1,10 @@
|
||||
# Direct state editing — bounded design, 2026-09-05
|
||||
|
||||
Read-only investigation; implementation and native verification remain pending.
|
||||
Connection routing became the user's next priority after this design handoff.
|
||||
Originally a read-only proposal. Implemented in the isolated state-integration
|
||||
checkout through e0b2210; current verification and outstanding acceptance are in
|
||||
STATE.md. The old file/line seams below describe the pre-implementation baseline.
|
||||
State layouts now use the typed context and commands proposed here; full scenario
|
||||
and process direct editing remain separate release work.
|
||||
|
||||
## Decision and first task
|
||||
|
||||
@@ -121,4 +124,6 @@ behavior view independently of selection, including root Events. Reconnect
|
||||
clones the relation and changes one endpoint; preserve all other data and reject
|
||||
invalid cardinalities. Keep form editing in a bounded panel so the canvas remains
|
||||
usable at 1100×700. These are decision-ready interfaces; no implementation or
|
||||
native gesture acceptance is claimed.
|
||||
native gesture acceptance is claimed by this design record. The implemented
|
||||
contract and subsequent acceptance evidence are in release-state-editing.md;
|
||||
production316dc55 closes the direct state slice, while scenario/process work remains.
|
||||
|
||||
Reference in New Issue
Block a user