Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu a8f451ebae Record Windows and Linux direct state editing acceptance
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 03:31:26 +03:00
sorokin.oleg.edu 316dc559d7 Reset state camera and tools only when navigation changes view
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 03:19:18 +03:00
57 changed files with 490 additions and 10282 deletions
File diff suppressed because it is too large Load Diff
+11 -24
View File
@@ -34,7 +34,10 @@ fn occurrence(
id
}
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
let items = crate::data::interface_items(p, interface);
let items = match p.elements[&interface].shared() {
Some(SharedBody::Interface { items }) => items.clone(),
_ => vec![],
};
let contract = if p.elements[&owner].kind() == Kind::Function {
if direction == PortDirection::Out {
PortContract::FunctionOutput {
@@ -165,11 +168,6 @@ 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,
@@ -310,11 +308,6 @@ 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,
@@ -324,8 +317,6 @@ 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,
@@ -336,8 +327,6 @@ 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,
@@ -351,9 +340,6 @@ 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,
@@ -362,12 +348,13 @@ pub fn coastal_rescue() -> Project {
"Distress reporting interface",
None,
);
p.relate(
RelationKind::InterfaceItemAllocation,
"Distress payload",
interface,
information,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface {
items: vec![information],
};
let vessel_port = port(
&mut p,
actor,
+13 -92
View File
@@ -1,4 +1,4 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
@@ -340,8 +340,6 @@ fn reference_field(key: &str) -> bool {
| "referenced"
| "local"
| "field"
| "path"
| "supertype"
| "sender"
| "receiver"
| "action"
@@ -380,14 +378,10 @@ fn enum_field(key: &str) -> bool {
| "message_kind"
| "transition_kind"
| "side"
| "mechanism"
| "storage"
| "transmission"
| "acquisition"
)
}
/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
/// Parse V3, or explicitly migrate a valid V1/V2 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)
@@ -552,7 +546,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 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
@@ -1343,12 +1337,12 @@ impl Parser<'_> {
.map(|(key, span)| ((d.id, key.clone()), span.start)),
);
}
let decoded =
let project =
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
.map_err(|(_, error)| error)?;
Ok(ParsedDocument {
project: decoded.project,
migration: decoded.migration,
project,
migration: None,
locations,
fields,
})
@@ -1356,11 +1350,9 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if !matches!(self.text(), "3" | "4") {
return Err(self
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
if self.text() != "3" {
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
}
let schema_version = if self.text() == "3" { 3 } else { 4 };
self.cursor += 1;
self.take(";")?;
let start = self.current().span.start;
@@ -1373,7 +1365,7 @@ impl Parser<'_> {
self.take("{")?;
Some(SourceHeader {
project: Project {
schema_version,
schema_version: SCHEMA_VERSION,
id,
alias,
name,
@@ -1548,9 +1540,7 @@ pub(crate) fn resolve_declarations(
declarations: &[Declaration],
aliases: &BTreeMap<String, Id>,
limits: ParseLimits,
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
let legacy = project.schema_version == 3;
let mut legacy_elements = serde_json::Map::new();
) -> Result<Project, (usize, Error)> {
let mut nodes = Vec::new();
for (index, declaration) in declarations.iter().enumerate() {
(|| -> Result<(), Error> {
@@ -1571,9 +1561,6 @@ 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");
@@ -1614,17 +1601,9 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
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)?;
let body_fields =
merge_fields(payload(&body), 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 {
@@ -1645,9 +1624,6 @@ 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 {
@@ -1749,58 +1725,6 @@ 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,
@@ -1811,10 +1735,7 @@ pub(crate) fn resolve_declarations(
},
)
})?;
Ok(crate::persistence::DecodeResult {
project,
migration: None,
})
Ok(project)
}
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
+10 -40
View File
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
fn project_source(body: &str) -> String {
format!(
"arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
"arcadia 3; 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 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
"arcadia 3; 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 4; 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 3; 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,34 +406,12 @@ 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.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_eq!(
compiled.bundle, input,
"every example identity is already explicit"
);
assert!(
compiled
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
compiled.diagnostics.is_empty(),
"{:?}",
compiled.diagnostics
);
@@ -495,15 +473,7 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
ProjectLimits::default(),
)
.unwrap();
assert_eq!(result.diagnostics.len(), 2);
assert!(
result
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
"{:?}",
result.diagnostics
);
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
assert_eq!(
result.index.declarations[&assess].path,
"sa/assurance.arcadia"
@@ -530,6 +500,6 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
"sa/participants.arcadia",
"views.arcadia",
] {
assert_eq!(result.bundle.files[path], compiled.bundle.files[path]);
assert_eq!(result.bundle.files[path], input.files[path]);
}
}
-1
View File
@@ -1,5 +1,4 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod data;
pub mod language;
pub mod model;
pub mod persistence;
+4 -6
View File
@@ -11,12 +11,10 @@ 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 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.
`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.
Do not update these frozen shapes when extending the current model. Add another
versioned migration when the persistent contract changes again. Golden V2
+13 -18
View File
@@ -62,9 +62,16 @@ pub(crate) fn migrate(
}],
});
}
let mut report = MigrationReport::data_upgrade(2);
let mut report = MigrationReport {
from_schema: 2,
to_schema: 3,
preserved_ports: vec![],
generated_endpoints: vec![],
orientation_defaults: vec![],
required_resolutions: vec![],
};
let mut value = serde_json::to_value(&old)?;
value["schema_version"] = Value::from(3);
value["schema_version"] = Value::from(SCHEMA_VERSION);
for element in old.elements.values() {
if let Some(super::model::SharedBody::Port {
direction,
@@ -197,23 +204,11 @@ pub(crate) fn migrate(
_ => {}
}
}
// 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;
let project: Project = serde_json::from_value(value)?;
limits.check_model(&project).map_err(FileError::Limit)?;
super::super::validate_project(&project)?;
Ok(DecodeResult {
project: migrated.project,
project,
migration: Some(report),
})
}
-33
View File
@@ -1,33 +0,0 @@
# 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.
-630
View File
@@ -1,630 +0,0 @@
//! 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),
})
}
+13 -148
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 4;
pub const SCHEMA_VERSION: u32 = 3;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -265,79 +265,17 @@ pub enum ExchangeItemKind {
Information,
Material,
Energy,
}
#[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)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataTypeDefinition {
Boolean,
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>,
},
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -515,8 +453,6 @@ 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,
}
@@ -566,25 +502,18 @@ 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 {
supertype: Option<Id>,
items: Vec<Id>,
},
Port {
contract: PortContract,
@@ -743,27 +672,17 @@ impl ElementBody {
match k {
Kind::ExchangeItem => SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
},
Kind::DataType => SharedBody::DataType {
definition: DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
default: None,
unit: String::new(),
definition: DataTypeDefinition::String,
},
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 { supertype: None },
Kind::Interface => SharedBody::Interface { items: vec![] },
// Port creation requires an explicit contract and owner.
Kind::Port => return None,
Kind::Scenario => SharedBody::Scenario {
@@ -833,8 +752,6 @@ impl SharedBody {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationKind {
InterfaceItemAllocation,
DataAssociation,
Allocation,
PortAllocation,
Interaction,
@@ -858,9 +775,7 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 23] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
pub const ALL: [Self; 21] = [
Self::Allocation,
Self::PortAllocation,
Self::Interaction,
@@ -885,8 +800,6 @@ 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,
@@ -942,8 +855,6 @@ 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",
@@ -975,8 +886,6 @@ 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",
@@ -1013,25 +922,6 @@ 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,
@@ -1149,8 +1039,6 @@ 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,
@@ -1176,9 +1064,7 @@ impl RelationBody {
}
pub fn endpoints(&self) -> (Id, Id) {
match self {
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1203,9 +1089,7 @@ impl RelationBody {
}
pub fn set_endpoints(&mut self, a: Id, b: Id) {
match self {
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1241,25 +1125,6 @@ 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 {
+2 -49
View File
@@ -5,8 +5,6 @@ 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)]
@@ -17,50 +15,6 @@ 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)]
@@ -147,14 +101,13 @@ 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) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => DecodeResult {
Some(3) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
));
}
}
+18 -8
View File
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
pub outgoing_items: Vec<Id>,
}
/// 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)
/// R6 specialization has one explicit seam; current models support identity only.
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool {
carried == declared
}
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
carried.iter().all(|item| {
@@ -130,7 +130,13 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
Some(
interfaces
.into_iter()
.flat_map(|id| crate::data::interface_items(p, id))
.filter_map(|id| p.elements.get(&id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.collect(),
)
}
@@ -897,10 +903,14 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
..
}) => {
let mut allowed = declared_interface_items(p, port.id);
if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
let items = crate::data::interface_items(p, interface);
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 r.kind() == RelationKind::BoundaryExchange
&& !items_satisfy(p, r.body.items(), &items)
&& !items_satisfy(p, r.body.items(), items)
{
error(
out,
@@ -1163,7 +1173,7 @@ pub(crate) fn validate_boundary_interface_requirements(
if !p.relations.values().any(|role| {
role.kind() == RelationKind::InterfaceProvision
&& role.source() == peer.id
&& crate::data::interface_satisfies(p, role.target(), required.target())
&& role.target() == required.target()
}) {
warning(
out,
+2 -8
View File
@@ -335,11 +335,6 @@ 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;
}
@@ -375,8 +370,7 @@ 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()
|| r.kind() == RelationKind::DataAssociation)
|| (d.projection.state_machine().is_some()
&& r.source() == r.target()
&& nodes
.iter()
@@ -402,7 +396,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)),
+10 -43
View File
@@ -33,13 +33,8 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
type_ref: TypeRef::Named(id),
..
} => visit(id),
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => {
if let Some(id) = supertype {
SharedBody::Interface { items } => {
for id in items {
visit(id);
}
}
@@ -88,9 +83,6 @@ 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);
}
}
@@ -385,12 +377,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => clear(supertype),
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
@@ -426,10 +413,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
clear(sender);
clear(receiver);
clear(reply_to);
values.retain(|value| {
!removed.contains(&value.field)
&& value.path.iter().all(|id| !removed.contains(id))
});
values.retain(|value| !removed.contains(&value.field));
}
OccurrenceBody::Action { action, lifeline } => {
clear(action);
@@ -501,37 +485,20 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
exchange: Some(id), ..
},
..
}) => 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)
}),
}) => p
.relations
.get(id)
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
_ => None,
})
.collect();
for (id, retained) in allowed {
for (id, fields) in allowed {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
{
*values = retained;
values.retain(|value| fields.contains(&value.field));
}
}
}
+3 -128
View File
@@ -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, Relation, RelationBody, RelationKind, Size};
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -55,122 +55,6 @@ 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 },
@@ -582,14 +466,6 @@ 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;
@@ -703,7 +579,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-relation must leave and return at distinct anchors.
// A real self-transition 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()
@@ -1231,8 +1107,7 @@ fn label_text(project: &Project, edge: Id) -> String {
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::DataAssociation => relation.name.as_str(),
| RelationKind::BoundaryExchange => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
RelationKind::CapabilityGeneralization => "specializes",
-17
View File
@@ -114,7 +114,6 @@ pub struct PreparedCommand {
struct IdentityProvenance {
original: BTreeSet<Id>,
current_to_original: BTreeMap<Id, Id>,
explicit_replacement: bool,
}
impl IdentityProvenance {
@@ -124,12 +123,10 @@ 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
@@ -183,13 +180,6 @@ 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,
@@ -297,13 +287,6 @@ 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)
}
+1 -1
View File
@@ -999,7 +999,7 @@ mod transaction_tests {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
)
.unwrap();
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
+20 -52
View File
@@ -1,11 +1,9 @@
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
//! Pure, bounded source3 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;
@@ -280,24 +278,21 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
}
}
/// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
/// Compile all reachable files as one source3 model and stabilize omitted 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 (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 (compiled, insertions) = compile_once(bundle, limits)?;
if insertions.is_empty() {
return Ok(compiled);
}
if compiled.decoded.migration.is_some() {
return source_migration::upgrade(&compiled, limits);
}
Ok(compiled)
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)
}
fn compile_once(
@@ -573,7 +568,7 @@ fn compile_once(
span,
}),
};
let decoded =
let project =
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
@@ -583,7 +578,7 @@ fn compile_once(
related: Vec::new(),
}]
})?;
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
let diagnostics = model_diagnostics(bundle, &index, &project);
let errors: Vec<_> = diagnostics
.iter()
.filter(|issue| issue.diagnostic.severity == validation::Severity::Error)
@@ -595,7 +590,10 @@ fn compile_once(
Ok((
CompiledSourceProject {
bundle: bundle.clone(),
decoded,
decoded: DecodeResult {
project,
migration: None,
},
index,
diagnostics,
},
@@ -720,7 +718,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
Ok(())
}
/// Canonical, readable source4 export. Like `language::format`, the caller is
/// Canonical, readable source3 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 {
@@ -736,7 +734,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.push_str(&unindent(&declaration));
}
let mut entry = format!(
"arcadia 4;\n{}{{\n description {};\n",
"arcadia 3;\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
@@ -925,7 +923,6 @@ 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);
@@ -954,7 +951,6 @@ 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(
@@ -1123,9 +1119,6 @@ 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(
@@ -1345,11 +1338,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, self.legacy_value_paths))
.map(|item| item_key(item, self.before, self.old_aliases))
.collect();
let new_keys: Option<Vec<_>> = after
.iter()
.map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
.map(|item| item_key(item, self.after, self.new_aliases))
.collect();
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
old.iter().collect::<BTreeSet<_>>().len() == old.len()
@@ -1456,7 +1449,6 @@ fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
) -> Option<String> {
use language::EditKind;
match &value.kind {
@@ -1470,31 +1462,7 @@ 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| {
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}")
}
})
aliases.get(alias).map(|id| format!("{key}:{id}"))
} else {
None
}
@@ -1,125 +0,0 @@
//! 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 = &current.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 &current.bundle.files {
let declarations =
language::edit_declarations(source, path == &current.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 = &current.bundle.files[&current.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(&current.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)
}
-20
View File
@@ -251,8 +251,6 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
}
(
RelationBody::Allocation { .. }
| RelationBody::InterfaceItemAllocation { .. }
| RelationBody::DataAssociation { .. }
| RelationBody::ExchangeAllocation { .. }
| RelationBody::CapabilityInvolvement { .. }
| RelationBody::CapabilityInclude { .. }
@@ -423,24 +421,6 @@ 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) {
+94 -120
View File
@@ -388,10 +388,68 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
if let Some(shared) = e.shared() {
match shared {
SharedBody::DataType { .. }
| SharedBody::DataField { .. }
| SharedBody::Interface { .. }
| SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
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::Port { .. } => crate::ports::validate_port(p, e, &mut out),
SharedBody::Scenario {
viewpoint,
@@ -585,11 +643,9 @@ 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(
@@ -1380,10 +1436,23 @@ 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> {
crate::data::carried_field_paths(p, relation)
.into_iter()
.map(|f| f.field)
.collect()
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
}
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
match body {
@@ -1614,125 +1683,36 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
}
}
}
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 allowed = exchange
.and_then(|id| p.relations.get(&id))
.map(|r| carried_fields(p, r));
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.path, value.field)) {
if !fields.insert(value.field) {
error(
out,
e.id,
"duplicate_message_value",
"values",
"Each qualified carried field has at most one contextual value annotation.",
"Each carried field has at most one contextual value annotation in a message.",
);
}
if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
if allowed
.as_ref()
.is_some_and(|allowed| !allowed.contains(&value.field))
{
error(
out,
e.id,
"message_value_path_limit",
"values.path",
"The value path exceeds the bounded authoring depth.",
"message_value_field",
"values.field",
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
);
}
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,
@@ -2150,11 +2130,7 @@ 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
&& !matches!(
r.kind(),
RelationKind::StateTransition | RelationKind::DataAssociation
))
|| (a.id == b.id && r.kind() != RelationKind::StateTransition)
{
error(
out,
@@ -2405,10 +2381,8 @@ 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 =>
{
matches!(definition, DataTypeDefinition::Record { .. })
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => {
*definition == DataTypeDefinition::Record
}
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
matches!(
-38
View File
@@ -1,38 +0,0 @@
// 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
View File
@@ -1,312 +0,0 @@
{
"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": {}
}
+9 -102
View File
@@ -168,20 +168,6 @@ 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,
@@ -204,30 +190,18 @@ 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 {
minimum: None,
maximum: None,
},
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
DataTypeDefinition::Integer,
DataTypeDefinition::String,
] {
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,
default: None,
unit: String::new(),
};
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
SharedBody::DataType { definition };
}
for kind in [
ExchangeItemKind::Material,
@@ -242,11 +216,7 @@ project mission "Harbor safety — 港 🌊" {
None,
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
};
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind };
}
let scenario = p
.elements
@@ -491,7 +461,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 5; project mission \"x\" {}", "Unsupported schema"),
("arcadia 4; project mission \"x\" {}", "Unsupported schema"),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field",
@@ -795,27 +765,9 @@ 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"], 4);
assert_eq!(retained["schema_version"], 3);
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)
@@ -827,46 +779,6 @@ 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)
@@ -885,10 +797,6 @@ 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);
@@ -918,7 +826,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() + allocations.len()
original["relations"].as_object().unwrap().len()
);
assert_eq!(
from_json.diagrams.len(),
@@ -991,7 +899,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, 4);
assert_eq!(from_text.schema_version, 3);
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() {
@@ -1192,7 +1100,6 @@ 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;
+5 -12
View File
@@ -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, 4));
assert_eq!((report.from_schema, report.to_schema), (2, 3));
assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1);
@@ -56,7 +56,6 @@ 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() {
@@ -144,7 +143,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
}
}
let canonical = language::format(&first.project);
assert!(canonical.starts_with("arcadia 4;"));
assert!(canonical.starts_with("arcadia 3;"));
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, first.project);
@@ -224,13 +223,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
_ => 0,
})
})
.sum::<usize>()
+ decode(&old)
.migration
.unwrap()
.generated_interface_allocations
.len()
* 2;
.sum::<usize>();
for limits in [
ParseLimits {
max_graph_objects: graph + 1,
@@ -251,7 +244,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
));
}
let accepted = ParseLimits {
max_graph_objects: migrated.elements.len() + migrated.relations.len(),
max_graph_objects: graph + 2,
max_records: records,
max_step_references: reference_count,
..ParseLimits::UNLIMITED
@@ -274,7 +267,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, 4);
assert_eq!(result.project.schema_version, 3);
assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project);
}
+5 -6
View File
@@ -1,6 +1,6 @@
use oa_core::{language, persistence, *};
const SOURCE: &str = r#"arcadia 4;
const SOURCE: &str = r#"arcadia 3;
project typed_ports "Typed ports" {
sa_system producer "Producer" {}
sa_actor consumer "Consumer" {}
@@ -9,8 +9,7 @@ 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" {}
interface_item_allocation service_data "Service data" { source @service; target @data; }
sa_interface service "Service" { items [@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; }; }; }
@@ -27,7 +26,7 @@ project typed_ports "Typed ports" {
"#;
#[test]
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
fn source3_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);
@@ -70,7 +69,7 @@ fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references
}
#[test]
fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
for (remove, expected) in [
("source_port @output; ", "source_port"),
("target_port @input; ", "target_port"),
@@ -91,7 +90,7 @@ fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation()
}
#[test]
fn source4_rejects_mixed_component_kinds_and_nested_reference_typos() {
fn source3_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
-228
View File
@@ -1,228 +0,0 @@
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")
);
}
-215
View File
@@ -1,215 +0,0 @@
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(&current, &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);
}
+15 -18
View File
@@ -447,12 +447,11 @@ 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.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
let requirement = p.relate(
RelationKind::InterfaceRequirement,
"Caller requires service",
@@ -500,12 +499,11 @@ 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.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![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,
@@ -661,12 +659,11 @@ 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.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
+1 -4
View File
@@ -94,9 +94,7 @@ 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 { supertype: None },
default: None,
unit: String::new(),
definition: DataTypeDefinition::Record,
};
let field = p
.add_element(Kind::DataField, layer, "Requested level", Some(record))
@@ -162,7 +160,6 @@ fn fixture(layer: Layer) -> Fixture {
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
values.push(MessageValue {
path: vec![payload],
field,
expression: "requested_level".into(),
});
+6 -6
View File
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
"arcadia 3; 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 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(&entry, "arcadia 3; 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 4; project p \"P\" { include \"new.arcadia\"; }".into(),
"arcadia 3; 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 4; project p \"P\" { include \"child.arcadia\"; }".into(),
"arcadia 3; 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 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"alias.arcadia\"; }",
"arcadia 3; 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 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("project.arcadia".into(), "arcadia 3; 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_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()),
]),
},
ProjectLimits::default(),
@@ -91,9 +91,8 @@ 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::Event {
body: EventBody::Signal { items },
}) = candidate.elements.get_mut(&contract).unwrap().shared_mut()
let Some(SharedBody::Interface { 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 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("project.arcadia".into(), "arcadia 3; 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()
+33 -35
View File
@@ -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 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 3; 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 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 3; 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 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
"arcadia 3; 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 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" {}",
"arcadia 3; 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 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"missing.arcadia\"; }";
let source = "arcadia 3; 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 4;project p\"P\"{include \"child.arcadia\";}",
"arcadia 3;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 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"sub/work.arcadia\"; }";
let entry = "arcadia 3; 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 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 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 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 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"item.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"event.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"ports.arcadia\"; }",
"arcadia 3; 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 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"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",
"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",
)],
);
let current = compile(&input);
@@ -1029,9 +1029,8 @@ 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::Event {
body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
if let Some(SharedBody::Interface { items }) =
project.elements.get_mut(&service).unwrap().shared_mut()
{
*items = vec![b, a];
} else {
@@ -1052,10 +1051,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 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"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",
"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",
)],
);
let current = compile(&input);
@@ -1064,9 +1063,8 @@ 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::Event {
body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
if let Some(SharedBody::Interface { items }) =
project.elements.get_mut(&service).unwrap().shared_mut()
{
*items = vec![b];
} else {
@@ -1104,7 +1102,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
}; }; }
"#;
let current = compile(&bundle(
"arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }",
&[("conversation.arcadia", source)],
));
let mut project = current.decoded.project.clone();
@@ -1153,7 +1151,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 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
@@ -1162,7 +1160,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 {
@@ -1187,7 +1185,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 {
+9 -47
View File
@@ -328,12 +328,11 @@ 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.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
let port = |p: &mut Project, name: &str, parent, direction| {
let id = Id::new_v4();
let contract = if p.elements[&parent].kind() == Kind::Function {
@@ -741,9 +740,6 @@ 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);
@@ -808,40 +804,8 @@ 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 = 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}"))
};
let deleted = execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
accepted(&deleted);
}
}
@@ -926,15 +890,13 @@ 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
})
)));
}
+8 -10
View File
@@ -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 supported older-schema migration.
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 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, "migration": stored.migration
"source_project": stored.source_project
}),
))
})
@@ -947,16 +947,14 @@ 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();
let migration = if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?.migration().cloned()
} else {
None
};
Ok((diagnostics, migration))
if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?;
}
Ok(diagnostics)
});
let (diagnostics, migration) = result.unwrap_or_else(|error| (candidate_issues(error), None));
let diagnostics = result.unwrap_or_else(candidate_issues);
let valid = !diagnostics.iter().any(|issue| issue.severity == "error");
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":migration})));
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":null})));
}
_ => return Err(ApiError::bad_request("Validation requires one matching format/payload: json with model, arcadia with source, or source-project with source_project.")),
};
+3 -13
View File
@@ -128,10 +128,9 @@ 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,
migration: None,
})
}
@@ -174,10 +173,6 @@ 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
@@ -334,12 +329,7 @@ impl Store {
let prepared = if let Some(bundle) = envelope.source_project {
let prepared = PreparedProject::from_bundle(bundle.clone())
.map_err(|error| corrupt(error.detail))?;
// 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
{
if prepared.bundle() != &bundle || prepared.model() != &decoded.project {
return Err(corrupt(
"Stored source and model disagree, or source lacks stable identities."
.into(),
@@ -357,7 +347,7 @@ impl Store {
revision: envelope.revision,
model: decoded.project,
source_project: prepared.compiled.bundle,
migration: decoded.migration.or(prepared.migration),
migration: decoded.migration,
};
if stored.resource_id != filename_id || stored.revision == 0 {
return Err(corrupt(
+6 -8
View File
@@ -8,8 +8,6 @@ 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"]
@@ -1931,7 +1929,7 @@ async fn legacy_json_import_and_model_reidentification_keep_the_service_address(
}
#[tokio::test]
async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
async fn schema3_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();
@@ -1947,7 +1945,7 @@ async fn current_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"], oa_core::SCHEMA_VERSION);
assert_eq!(summary["schema_version"], 3);
assert_eq!(
summary["migration"],
serde_json::to_value(&expected.migration).unwrap()
@@ -1960,7 +1958,7 @@ async fn current_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"], oa_core::SCHEMA_VERSION);
assert_eq!(envelope["model"]["schema_version"], 3);
let validation = server
.client
.post(server.url(&format!("/projects/{id}/validation")))
@@ -2002,7 +2000,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert!(source.starts_with("arcadia 3;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
let no_op = server
.client
@@ -2051,7 +2049,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, oa_core::SCHEMA_VERSION);
assert_eq!(migrated.model.schema_version, 3);
assert_eq!(
migrated
.migration
@@ -2076,7 +2074,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"], oa_core::SCHEMA_VERSION);
assert_eq!(saved["model"]["schema_version"], 3);
assert!(saved.get("migration").is_none());
server.stop().await;
}
@@ -1,317 +0,0 @@
//! 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 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()),
("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()),
("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\" {}\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()),
("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()),
("work.arcadia".into(), "// Operational rationale\r\noa_activity assess \"Assess assistance\" {}\r\n".into()),
]),
};
+8 -32
View File
@@ -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, 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
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
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">{
&quot;entry&quot;: &quot;rescue.arcadia&quot;,
&quot;files&quot;: {
&quot;rescue.arcadia&quot;: &quot;arcadia 4; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;rescue.arcadia&quot;: &quot;arcadia 3; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;oa/work.arcadia&quot;: &quot;// Engineering rationale\noa_activity assess \&quot;Assess assistance\&quot; {}\n&quot;
}
}</pre>
@@ -807,9 +807,8 @@ 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. 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>
the complete bundle, not separately per file. Bundle syntax is source3;
single-file legacy 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
@@ -966,11 +965,8 @@ 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">{&quot;Timer&quot;:{&quot;expression&quot;:&quot;after</span> 5 <span class="pre">s&quot;}}</span></span>. Each value record contains a
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>
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>
<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,
@@ -982,26 +978,6 @@ 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>
@@ -1009,7 +985,7 @@ advance its ETag. Canonical exports omit transient migration reports.</p>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;arcadia&quot;, &quot;source&quot;:&quot;complete source document&quot;}</pre>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 4; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 3; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
if its candidate is invalid, including a model record that fails typed decoding.
Malformed request JSON or an invalid format/payload envelope is a 400.
+8 -35
View File
@@ -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, 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
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
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 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
"rescue.arcadia": "arcadia 3; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
}
}
@@ -172,9 +172,8 @@ 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. 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.
the complete bundle, not separately per file. Bundle syntax is source3;
single-file legacy 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.
@@ -356,11 +355,8 @@ 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``, 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.
carried DataField UUID in ``field`` and its textual ``expression``; 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
@@ -374,29 +370,6 @@ 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
----------
@@ -410,7 +383,7 @@ or::
or::
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
if its candidate is invalid, including a model record that fails typed decoding.
+42 -215
View File
@@ -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">4</dd>
<dd class="version">3</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,13 +637,14 @@ 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, 2 and 3. MUST, MUST NOT,
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
and MAY indicate requirements, prohibitions, and permitted choices.</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>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>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
@@ -661,7 +662,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> (4), <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> (3), <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>
@@ -721,20 +722,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; <span class="docutils literal">mechanism</span>: ExchangeMechanism; <span class="docutils literal">supertype</span>: item ref?</p></td>
<td><p>Information; Unspecified; none</p></td>
<td><p><span class="docutils literal">kind</span>: Information, Material, Energy, Event</p></td>
<td><p>Information</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>: 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">&quot;&quot;</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>
</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; <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">&quot;&quot;</span>; Unspecified; Value; none</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">&quot;&quot;</span></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">supertype</span>: interface ref?</p></td>
<td><p>none; membership is an identified interface_item_allocation relation</p></td>
<td><p><span class="docutils literal">items</span>: exchange-item refs</p></td>
<td><p><span class="docutils literal">[]</span></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>
@@ -882,14 +883,6 @@ 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> / &quot;&quot;; <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>
@@ -970,8 +963,7 @@ 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 or an explicit
DataAssociation between two ends of the same Record type.</p>
except for a state transition that returns to its source state.</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
@@ -1019,13 +1011,12 @@ 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 4;
<pre class="literal-block">arcadia 3;
project ports_example &quot;Typed function contract&quot; {
sa_function send &quot;Send&quot; {}
sa_function receive &quot;Receive&quot; {}
sa_exchange_item payload &quot;Payload&quot; {}
sa_interface old_service &quot;Preserved service&quot; {}
interface_item_allocation service_payload &quot;Service payload&quot; { source &#64;old_service; target &#64;payload; }
sa_interface old_service &quot;Preserved service&quot; { items [&#64;payload]; }
sa_port output &quot;Output&quot; {
parent &#64;send;
contract { FunctionOutput { outgoing_items [&#64;payload]; }; };
@@ -1134,19 +1125,11 @@ 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">{ path [&#64;parameter]; field &#64;data_field; expression &quot;value expression&quot;; }</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>Each MessageValue is <span class="docutils literal">{ field &#64;data_field; expression &quot;value expression&quot;; }</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>ScenarioUse references an existing same-layer scenario and maps its lifelines
with <span class="docutils literal">bindings [ { referenced &#64;callee_line; local &#64;caller_line; } ];</span>.
Each binding is one-to-one and references a direct lifeline of the appropriate
@@ -1297,137 +1280,6 @@ 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 &quot;value&quot;; }</span> or <span class="docutils literal">{ Enumeration &quot;Ready&quot;; }</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 &quot;Engineering data&quot; {
sa_data_type voltage &quot;Voltage&quot; {
definition { Real { minimum 0.0; maximum 48.0; }; };
default { Real 24.0; }; unit &quot;V&quot;;
}
sa_data_type coordinate &quot;Coordinate&quot; {
definition { Record { supertype none; }; };
sa_data_field latitude &quot;Latitude&quot; { type_ref Real; }
}
sa_data_type located &quot;Located asset&quot; {
definition { Record { supertype &#64;coordinate; }; };
sa_data_field supply &quot;Supply&quot; { type_ref { Named &#64;voltage; }; }
}
sa_exchange_item navigate &quot;Navigate&quot; {
mechanism Operation;
sa_data_field origin &quot;Origin&quot; { type_ref { Named &#64;coordinate; }; direction In; }
sa_data_field destination &quot;Destination&quot; { type_ref { Named &#64;coordinate; }; direction In; }
sa_data_field accepted &quot;Accepted&quot; { type_ref Boolean; direction Return; }
}
sa_interface navigation &quot;Navigation&quot; {}
sa_interface monitoring &quot;Monitoring&quot; {}
interface_item_allocation nav_operation &quot;Navigation operation&quot; {
source &#64;navigation; target &#64;navigate; transmission Synchronous; acquisition Accept;
}
interface_item_allocation monitor_operation &quot;Monitoring operation&quot; {
source &#64;monitoring; target &#64;navigate; transmission Asynchronous; acquisition Read;
}
data_association location_link &quot;Location relationship&quot; {
source &#64;coordinate; target &#64;located;
source_role &quot;location&quot;; target_role &quot;assets&quot;;
source_maximum 1;
}
}</code></pre>
</section>
</section>
<section id="diagrams-artifacts-and-cross-layer-traceability">
<h2>5. Diagrams, artifacts, and cross-layer traceability</h2>
@@ -1456,7 +1308,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 4;
<pre class="code text literal-block"><code>arcadia 3;
project operating &quot;Operating modes&quot; {
oa_entity crew &quot;Crew&quot; {}
oa_state_machine modes &quot;Modes&quot; { parent &#64;crew; }
@@ -1665,7 +1517,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 = &quot;arcadia&quot; &quot;4&quot; &quot;;&quot; project
<pre class="code text literal-block"><code>document = &quot;arcadia&quot; &quot;3&quot; &quot;;&quot; project
project = &quot;project&quot; alias string identity? &quot;{&quot; project-entry* &quot;}&quot;
project-entry = &quot;description&quot; string &quot;;&quot; | declaration
declaration = element
@@ -1702,16 +1554,16 @@ Ordinary string fields require quoted strings and cannot contain an unquoted
<span class="docutils literal">&#64;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-source-projects">
<h3>6.1. Modular source projects</h3>
<section id="modular-source3-projects">
<h3>6.1. Modular source3 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 source4 project header above. A project entry
root-level <span class="docutils literal">.arcadia</span> file with the source3 project header above. A project entry
MAY also be <span class="docutils literal">include string &quot;;&quot;</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 4;
<pre class="literal-block">arcadia 3;
project rescue &quot;Rescue&quot; id &quot;00000000-0000-4000-8000-000000000001&quot; {
description &quot;Operational model&quot;;
include &quot;oa/participants.arcadia&quot;;
@@ -1769,12 +1621,9 @@ 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 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>
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>
</section>
<section id="nested-element-ownership">
<h3>6.2. Nested element ownership</h3>
@@ -1978,31 +1827,9 @@ 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 V4 source bundle (<span class="docutils literal">arcadia 4</span>); other suffixes use V4 JSON. Both forms preserve the same
the accepted V3 source bundle (<span class="docutils literal">arcadia 3</span>); other suffixes use V3 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:&lt;interface-uuid&gt;:&lt;item-uuid&gt;</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
@@ -2026,7 +1853,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 and schema-4 migrations.</p>
the added defaults through the explicit schema-3 migration.</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
@@ -2043,13 +1870,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 4. Model/source exports contain only the canonical model. Migration
schema 3. 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 V4 graph are
construction. Both the old V2 contract and the resulting V3 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 these same V2-to-3-to-4 stages.</p>
model and then uses this same V2-to-3 stage.</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
@@ -2065,20 +1892,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 V4 result, including repeated occurrences.
input therefore yields the same V3 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
V4 projects. Future versions remain errors until a separate migration exists.</p>
V3 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 4;
<pre class="code text literal-block"><code>arcadia 3;
project harbor &quot;Harbor assistance&quot; {
oa_entity control &quot;Harbor control&quot; {}
oa_activity assess &quot;Assess assistance&quot; {}
+41 -219
View File
@@ -1,7 +1,7 @@
Arcadia Studio OA / SA Language and Editing Contract
====================================================
:Version: 4
:Version: 3
:Status: Normative current implementation contract; first-release integration
:Core implementation: ``crates/oa-core``
:Text suffix: ``.arcadia``
@@ -11,14 +11,15 @@ 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, 2 and 3. MUST, MUST NOT,
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
and MAY indicate requirements, prohibitions, and permitted choices.
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.
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.
OA describes stakeholders, operational work and needs. SA describes the
selected system boundary, external actors, functions, services and expected
@@ -39,7 +40,7 @@ Actual verification evidence is separate from this contract.
2. One authoritative model
--------------------------
A Project stores ``schema_version`` (4), ``id``, ``alias``, ``name``,
A Project stores ``schema_version`` (3), ``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.
@@ -95,17 +96,17 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
- ``steps``: occurrence refs
- ``[]``
* - ``oa_exchange_item``, ``sa_exchange_item``
- ``kind``: Information, Material, Energy; ``mechanism``: ExchangeMechanism; ``supertype``: item ref?
- Information; Unspecified; none
- ``kind``: Information, Material, Energy, Event
- Information
* - ``oa_data_type``, ``sa_data_type``
- ``definition``: DataTypeDefinition; ``default``: DataValue?; ``unit``: string
- String with minimum_length 0 / maximum_length none; none; ``""``
- ``definition``: Boolean, Integer, Real, String, Record, or Enumeration constructor
- String
* - ``oa_data_field``, ``sa_data_field``
- ``type_ref``; ``minimum``: u32; ``maximum``: optional u32; ``unit``: string; ``direction``: ParameterDirection; ``storage``: FieldStorage; ``default``: DataValue list?
- String; 1; 1; ``""``; Unspecified; Value; none
- ``type_ref``; ``minimum``: u32; ``maximum``: optional u32; ``unit``: string
- String; 1; 1; ``""``
* - ``oa_interface``, ``sa_interface``
- ``supertype``: interface ref?
- none; membership is an identified interface_item_allocation relation
- ``items``: exchange-item refs
- ``[]``
* - ``oa_port``, ``sa_port``
- ``contract``: required PortContract; ``legacy``: optional LegacyPortContract
- No implicit contract; ``legacy`` defaults none. OA admits Legacy only.
@@ -229,12 +230,6 @@ 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 / []
@@ -295,8 +290,7 @@ 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 or an explicit
DataAssociation between two ends of the same Record type.
except for a state transition that returns to its source state.
Direct Allocation has at most one performer per leaf. Role sharing is a
different relationship: several OA participants can realize a Role. A direct
@@ -353,13 +347,12 @@ Port deletion cascades to incident FEs; FE deletion retains independent ports.
For example::
arcadia 4;
arcadia 3;
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" {}
interface_item_allocation service_payload "Service payload" { source @old_service; target @payload; }
sa_interface old_service "Preserved service" { items [@payload]; }
sa_port output "Output" {
parent @send;
contract { FunctionOutput { outgoing_items [@payload]; }; };
@@ -481,19 +474,11 @@ 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 ``{ 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.
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.
ScenarioUse references an existing same-layer scenario and maps its lifelines
with ``bindings [ { referenced @callee_line; local @caller_line; } ];``.
@@ -635,141 +620,6 @@ 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
----------------------------------------------------
@@ -804,7 +654,7 @@ This complete source example places one state from an existing machine:
.. code-block:: text
arcadia 4;
arcadia 3;
project operating "Operating modes" {
oa_entity crew "Crew" {}
oa_state_machine modes "Modes" { parent @crew; }
@@ -1025,7 +875,7 @@ sets and enum constructors are closed by sections 3–5.
.. code-block:: text
document = "arcadia" "4" ";" project
document = "arcadia" "3" ";" project
project = "project" alias string identity? "{" project-entry* "}"
project-entry = "description" string ";" | declaration
declaration = element
@@ -1067,18 +917,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 source projects
6.1. Modular source3 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 source4 project header above. A project entry
root-level ``.arcadia`` file with the source3 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 4;
arcadia 3;
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
description "Operational model";
include "oa/participants.arcadia";
@@ -1144,12 +994,9 @@ 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 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.
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.
6.2. Nested element ownership
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1381,35 +1228,10 @@ 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 V4 source bundle (``arcadia 4``); other suffixes use V4 JSON. Both forms preserve the same
the accepted V3 source bundle (``arcadia 3``); other suffixes use V3 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
@@ -1434,7 +1256,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 and schema-4 migrations.
the added defaults through the explicit schema-3 migration.
The V2 adapter preserves every original identity, alias, prose, parent,
placement, trace and artifact. Generic ports become Legacy with their original
@@ -1453,14 +1275,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 4. Model/source exports contain only the canonical model. Migration
schema 3. 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 V4 graph are
construction. Both the old V2 contract and the resulting V3 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 these same V2-to-3-to-4 stages.
model and then uses this same V2-to-3 stage.
Save validates a snapshot, writes a sibling temporary file, flushes it, and
atomically replaces the destination. Failed serialization or validation must
@@ -1479,7 +1301,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 V4 result, including repeated occurrences.
input therefore yields the same V3 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
@@ -1487,7 +1309,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
V4 projects. Future versions remain errors until a separate migration exists.
V3 projects. Future versions remain errors until a separate migration exists.
10. Complete authoring example
------------------------------
@@ -1497,7 +1319,7 @@ the assigned UUIDs explicitly.
.. code-block:: text
arcadia 4;
arcadia 3;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity assess "Assess assistance" {}
+63 -652
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Arcadia Studio model API",
"version": "1.0.0",
"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."
"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."
},
"servers": [
{
@@ -389,7 +389,7 @@
}
}
},
"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."
"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."
}
},
"/api/v1/projects/{id}": {
@@ -1902,9 +1902,7 @@
"CapabilityGeneralization",
"MissionInvolvement",
"SequenceLink",
"PortAllocation",
"InterfaceItemAllocation",
"DataAssociation"
"PortAllocation"
]
},
"Position": {
@@ -2006,185 +2004,40 @@
{
"type": "string",
"enum": [
"Boolean"
"Boolean",
"Integer",
"Real",
"String",
"Record"
]
},
{
"type": "object",
"properties": {
"Integer": {
"oneOf": [
{
"type": "object",
"properties": {
"minimum": {
"anyOf": [
{
"type": "integer",
"format": "int64",
"minimum": -9223372036854775808,
"maximum": 9223372036854775807
},
{
"type": "null"
"Enumeration": {
"type": "object",
"properties": {
"literals": {
"type": "array",
"items": {
"type": "string"
}
}
]
},
"maximum": {
"anyOf": [
{
"type": "integer",
"format": "int64",
"minimum": -9223372036854775808,
"maximum": 9223372036854775807
},
{
"type": "null"
}
]
},
"required": [
"literals"
],
"additionalProperties": false
}
},
"required": [
"minimum",
"maximum"
"Enumeration"
],
"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
]
}
]
},
@@ -2549,27 +2402,17 @@
"type": "object",
"properties": {
"kind": {
"$ref": "#/components/schemas/ExchangeItemKind"
},
"mechanism": {
"$ref": "#/components/schemas/ExchangeMechanism"
},
"supertype": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
"type": "string",
"enum": [
"Information",
"Material",
"Energy",
"Event"
]
}
},
"required": [
"kind",
"mechanism",
"supertype"
"kind"
],
"additionalProperties": false
}
@@ -2587,25 +2430,10 @@
"properties": {
"definition": {
"$ref": "#/components/schemas/DataTypeDefinition"
},
"default": {
"anyOf": [
{
"$ref": "#/components/schemas/DataValue"
},
{
"type": "null"
}
]
},
"unit": {
"type": "string"
}
},
"required": [
"definition",
"default",
"unit"
"definition"
],
"additionalProperties": false
}
@@ -2626,15 +2454,13 @@
},
"minimum": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
"minimum": 0
},
"maximum": {
"anyOf": [
{
"type": "integer",
"minimum": 0,
"maximum": 4294967295
"minimum": 0
},
{
"type": "null"
@@ -2643,35 +2469,13 @@
},
"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",
"direction",
"storage",
"default"
"unit"
],
"additionalProperties": false
}
@@ -2687,20 +2491,16 @@
"Interface": {
"type": "object",
"properties": {
"supertype": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
]
"items": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"required": [
"supertype"
"items"
],
"additionalProperties": false
}
@@ -3781,128 +3581,6 @@
"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
}
]
},
@@ -3939,12 +3617,7 @@
},
"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": {
@@ -3965,11 +3638,7 @@
"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": {
@@ -4287,7 +3956,7 @@
"properties": {
"schema_version": {
"type": "integer",
"const": 4
"const": 3
},
"id": {
"type": "string",
@@ -4401,7 +4070,7 @@
"source_project"
],
"additionalProperties": false,
"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."
"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."
},
"ProjectSummary": {
"type": "object",
@@ -5145,29 +4814,15 @@
"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
},
{
@@ -5177,30 +4832,15 @@
"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
},
{
@@ -6169,14 +5809,6 @@
"MessageValue": {
"type": "object",
"properties": {
"path": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"default": []
},
"field": {
"type": "string",
"format": "uuid"
@@ -6189,8 +5821,7 @@
"field",
"expression"
],
"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."
"additionalProperties": false
},
"LifelineBinding": {
"type": "object",
@@ -6452,13 +6083,12 @@
"type": "integer",
"enum": [
1,
2,
3
2
]
},
"to_schema": {
"type": "integer",
"const": 4
"const": 3
},
"preserved_ports": {
"type": "array",
@@ -6549,91 +6179,6 @@
"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": [
@@ -6645,11 +6190,11 @@
"required_resolutions"
],
"additionalProperties": false,
"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>."
"description": "Transient observations from explicit V1→V2→V3 or V2→V3 migration. No save or revision increase occurs merely by reading an old envelope."
},
"SourceBundle": {
"type": "object",
"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.",
"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.",
"properties": {
"entry": {
"type": "string"
@@ -6722,149 +6267,15 @@
},
"source_project": {
"$ref": "#/components/schemas/SourceBundle"
},
"migration": {
"anyOf": [
{
"$ref": "#/components/schemas/MigrationReport"
},
{
"type": "null"
}
]
}
},
"required": [
"revision",
"changed",
"issues",
"source_project",
"migration"
"source_project"
],
"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
}
]
}
}
}
-106
View File
@@ -1,106 +0,0 @@
# 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.
-396
View File
@@ -1,396 +0,0 @@
# 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"
-12
View File
@@ -1,12 +0,0 @@
[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"
-118
View File
@@ -1,118 +0,0 @@
# 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.
@@ -1,28 +0,0 @@
# 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
-934
View File
@@ -1,934 +0,0 @@
//! 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"
);
}
}
@@ -1,909 +0,0 @@
//! 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, &current, &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, &current, &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, &current, &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(&current))
.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, &current, &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
+7 -33
View File
@@ -11,7 +11,7 @@ attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md.
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
Direct state-machine editing is accepted through production316dc55. The main
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts are now
checkout can fast-forward to this integration. 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.
@@ -76,39 +76,13 @@ Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/
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.
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.
Ownership: data_core owns core model/data/validation/references/ports/transitions
and non-source tests; state_architecture_review owns source/migration/persistence
and their tests; drawing_correctness owns desktop. Root owns API/OpenAPI/reST,
integration and native input. Do not edit workers' files or reset their changes.
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.
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.
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.
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.
1. Commit state evidence and fast-forward main from ad7feb5; coordinate Git index
before carrying documentation into the active R6 checkout.
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
-6
View File
@@ -1,11 +1,5 @@
# 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
-59
View File
@@ -1,59 +0,0 @@
# 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.